ungraduate1394 commited on
Commit ·
c32102b
0
Parent(s):
Saved progress at the end of the loop
Browse filesReplit-Commit-Author: Agent
Replit-Commit-Session-Id: c29cee77-62e9-4c59-8869-90c93983f51f
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: e7ee9f19-8382-4992-89c8-79b4e20447cb
Replit-Helium-Checkpoint-Created: true
- .gitattributes +35 -0
- Dockerfile +62 -0
- README.md +130 -0
- adversarial_reviewer.py +191 -0
- app.py +1058 -0
- audit_logger.py +186 -0
- embedding_memory.py +115 -0
- github_app.py +53 -0
- job_queue.py +138 -0
- mcp_config.json +27 -0
- memory_engine.py +134 -0
- notifier.py +95 -0
- red_team_fuzzer.py +1503 -0
- requirements.txt +20 -0
- sast_gate.py +200 -0
- supply_chain.py +258 -0
- swebench_harness.py +128 -0
- training_store.py +362 -0
- verification_loop.py +141 -0
- webhook_server.py +235 -0
- worker_pool.py +76 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim AS builder
|
| 2 |
+
|
| 3 |
+
ENV DEBIAN_FRONTEND=noninteractive \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PYTHONDONTWRITEBYTECODE=1
|
| 6 |
+
|
| 7 |
+
RUN apt-get update && \
|
| 8 |
+
apt-get install -y --no-install-recommends git curl ca-certificates build-essential && \
|
| 9 |
+
rm -rf /var/lib/apt/lists/* && \
|
| 10 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
| 11 |
+
ln -s $HOME/.local/bin/uv /usr/local/bin/uv
|
| 12 |
+
|
| 13 |
+
WORKDIR /build
|
| 14 |
+
COPY requirements.txt .
|
| 15 |
+
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt mcp-server-fetch
|
| 16 |
+
|
| 17 |
+
FROM python:3.12-slim AS runtime
|
| 18 |
+
|
| 19 |
+
LABEL org.opencontainers.image.title="Rhodawk AI DevSecOps Engine v4.0"
|
| 20 |
+
LABEL org.opencontainers.image.description="Autonomous CI/CD healing, red-team CEGIS, SAST, supply-chain gate, SWE-bench, and data flywheel"
|
| 21 |
+
LABEL org.opencontainers.image.version="4.0.0"
|
| 22 |
+
|
| 23 |
+
ENV DEBIAN_FRONTEND=noninteractive \
|
| 24 |
+
PYTHONUNBUFFERED=1 \
|
| 25 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 26 |
+
GRADIO_SERVER_NAME=0.0.0.0 \
|
| 27 |
+
GRADIO_SERVER_PORT=7860
|
| 28 |
+
|
| 29 |
+
RUN apt-get update && \
|
| 30 |
+
apt-get install -y --no-install-recommends \
|
| 31 |
+
git curl ca-certificates build-essential && \
|
| 32 |
+
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
| 33 |
+
apt-get install -y --no-install-recommends nodejs && \
|
| 34 |
+
rm -rf /var/lib/apt/lists/* && \
|
| 35 |
+
curl -LsSf https://astral.sh/uv/install.sh | env HOME=/root sh && \
|
| 36 |
+
ln -s /root/.local/bin/uv /usr/local/bin/uv
|
| 37 |
+
|
| 38 |
+
RUN npm install -g --quiet @modelcontextprotocol/server-github
|
| 39 |
+
|
| 40 |
+
RUN userdel -r node 2>/dev/null || true && \
|
| 41 |
+
useradd -m -u 1000 -s /bin/bash rhodawk
|
| 42 |
+
|
| 43 |
+
RUN mkdir -p /data /app && chown -R rhodawk:rhodawk /data /app
|
| 44 |
+
|
| 45 |
+
WORKDIR /app
|
| 46 |
+
|
| 47 |
+
COPY --from=builder /wheels /wheels
|
| 48 |
+
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
|
| 49 |
+
|
| 50 |
+
ENV HOME=/home/rhodawk
|
| 51 |
+
ENV PATH=$HOME/.local/bin:/usr/local/bin:$PATH
|
| 52 |
+
|
| 53 |
+
USER rhodawk
|
| 54 |
+
|
| 55 |
+
COPY --chown=rhodawk:rhodawk *.py mcp_config.json ./
|
| 56 |
+
|
| 57 |
+
EXPOSE 7860 7861
|
| 58 |
+
|
| 59 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
|
| 60 |
+
CMD curl -f http://localhost:7860/ || exit 1
|
| 61 |
+
|
| 62 |
+
CMD ["python", "-u", "app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Rhodawk AI DevSecOps Engine
|
| 3 |
+
emoji: 🦅
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: true
|
| 8 |
+
license: apache-2.0
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# 🦅 Rhodawk AI — Autonomous DevSecOps Control Plane v4.0
|
| 12 |
+
|
| 13 |
+
> **Autonomous CI/CD healing + Zero-Day Discovery. The only DevSecOps engine that attacks its own green repos.**
|
| 14 |
+
|
| 15 |
+
Rhodawk AI v4.0 is an enterprise-grade autonomous DevSecOps control plane with two modes:
|
| 16 |
+
- **Blue Team**: detects failing tests, deploys AI (Aider + Qwen via OpenRouter) to generate a fix, gates through SAST + supply chain + adversarial LLM review, opens a PR.
|
| 17 |
+
- **Red Team / CEGIS**: when all tests pass (repo is GREEN), autonomously attacks the codebase — discovering mathematical invariants, synthesizing Property-Based Tests via Hypothesis, fuzzing to exhaustion, and handing the minimal crashing counter-example to the Blue Team for patching.
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## Architecture
|
| 22 |
+
|
| 23 |
+
```
|
| 24 |
+
GitHub Repo
|
| 25 |
+
│
|
| 26 |
+
▼
|
| 27 |
+
pytest discovery & execution
|
| 28 |
+
│
|
| 29 |
+
├── FAIL ──► Blue Team Healing Loop
|
| 30 |
+
│ │
|
| 31 |
+
│ ├── Memory Engine (TF-IDF — similar past fixes)
|
| 32 |
+
│ ├── Aider Agent (Qwen 2.5 Coder 32B via OpenRouter + MCP)
|
| 33 |
+
│ ├── SAST Gate (bandit + secret scanner)
|
| 34 |
+
│ ├── Supply Chain Gate (pip-audit + typosquatting)
|
| 35 |
+
│ ├── Adversarial LLM Review (hostile red-team model)
|
| 36 |
+
│ └── Open PR → Immutable Audit Trail (SHA-256 JSONL)
|
| 37 |
+
│
|
| 38 |
+
└── ALL PASS ──► 🆕 Red Team CEGIS Engine
|
| 39 |
+
│
|
| 40 |
+
├── AST Universal Analyzer
|
| 41 |
+
│ rank functions by complexity, overflow risk, recursion, mutations
|
| 42 |
+
├── Red Team LLM (Attacker)
|
| 43 |
+
│ synthesize Hypothesis property-based test (invariant attack)
|
| 44 |
+
├── Deterministic Fuzzing Loop (50,000 examples, aggressive boundaries)
|
| 45 |
+
│
|
| 46 |
+
├── CRASH ──► Package minimal counter-example (zero-day)
|
| 47 |
+
│ └── CEGIS Handoff → Blue Team patches it → PR
|
| 48 |
+
│
|
| 49 |
+
└── NO CRASH ──► Inject survived inputs → harder invariant → retry
|
| 50 |
+
(up to 4 CEGIS rounds, escalates to Claude for final round)
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## Required Secrets
|
| 56 |
+
|
| 57 |
+
Set these in **Settings → Secrets** of your HuggingFace Space:
|
| 58 |
+
|
| 59 |
+
| Secret | Description |
|
| 60 |
+
|--------|-------------|
|
| 61 |
+
| `GITHUB_TOKEN` | GitHub PAT with `repo` + `pull_request` scope |
|
| 62 |
+
| `GITHUB_REPO` | Target repository in `owner/repo` format |
|
| 63 |
+
| `OPENROUTER_API_KEY` | OpenRouter API key (Qwen 2.5 Coder 32B) |
|
| 64 |
+
|
| 65 |
+
## Optional Secrets
|
| 66 |
+
|
| 67 |
+
| Secret | Description |
|
| 68 |
+
|--------|-------------|
|
| 69 |
+
| `TELEGRAM_BOT_TOKEN` | Telegram bot token for notifications |
|
| 70 |
+
| `TELEGRAM_CHAT_ID` | Telegram chat ID |
|
| 71 |
+
| `SLACK_WEBHOOK_URL` | Slack incoming webhook URL |
|
| 72 |
+
| `RHODAWK_TENANT_ID` | Namespace for multi-tenant deployments (default: `default`) |
|
| 73 |
+
| `RHODAWK_MODEL` | Override AI model (default: `openrouter/qwen/qwen-2.5-coder-32b-instruct:free`) |
|
| 74 |
+
| `RHODAWK_RED_TEAM_ENABLED` | Enable/disable Red Team CEGIS engine (default: `true`) |
|
| 75 |
+
| `RHODAWK_RED_TEAM_MODEL` | Red Team attacker model (default: Qwen 2.5 Coder 32B) |
|
| 76 |
+
| `RHODAWK_RED_TEAM_MODEL_STRONG` | Escalation model for final CEGIS rounds (default: Claude 3.5 Sonnet) |
|
| 77 |
+
| `RHODAWK_CEGIS_ROUNDS` | Max CEGIS re-attack rounds per target (default: `4`) |
|
| 78 |
+
| `RHODAWK_FUZZ_EXAMPLES` | Hypothesis max_examples per PBT run (default: `50000`) |
|
| 79 |
+
| `RHODAWK_FUZZ_TIMEOUT` | Fuzzing subprocess timeout in seconds (default: `180`) |
|
| 80 |
+
| `RHODAWK_MAX_TARGETS` | Max AST attack targets per audit (default: `8`) |
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## Enterprise Features
|
| 85 |
+
|
| 86 |
+
### SAST Gate (Pre-PR Security Scanning)
|
| 87 |
+
Every AI-generated diff is scanned for:
|
| 88 |
+
- Hardcoded secrets, API keys, and tokens
|
| 89 |
+
- Dangerous Python patterns (`os.system`, `eval`, `pickle.loads`, `subprocess` with `shell=True`)
|
| 90 |
+
- Bandit SAST findings (HIGH+ severity blocks the PR)
|
| 91 |
+
|
| 92 |
+
**If the SAST gate blocks a PR, it is never opened.** The AI agent cannot bypass this gate.
|
| 93 |
+
|
| 94 |
+
### Immutable Audit Trail
|
| 95 |
+
Every AI action is appended to a SHA-256 chained JSONL file:
|
| 96 |
+
- AI model version and prompt hash are logged per job
|
| 97 |
+
- Each entry references the previous entry's hash (tamper-evident chain)
|
| 98 |
+
- Chain integrity can be verified on-demand from the dashboard
|
| 99 |
+
- Suitable as SOC 2 / ISO 27001 evidence
|
| 100 |
+
|
| 101 |
+
### Namespaced Job Queue
|
| 102 |
+
Jobs are keyed by `(tenant_id, repo, test_path)` — the foundation for multi-tenant SaaS. Backed by atomic JSON writes today, designed to swap to PostgreSQL with zero application changes.
|
| 103 |
+
|
| 104 |
+
### 🆕 Red Team CEGIS Engine (v4.0)
|
| 105 |
+
The industry's first autonomous zero-day discovery loop integrated directly into a CI/CD healing platform:
|
| 106 |
+
|
| 107 |
+
- **AST Universal Analyzer** — scores every Python function by cyclomatic complexity, arithmetic operations, recursion depth, and mutable argument mutation. Ranks targets by attack priority with composite scoring.
|
| 108 |
+
- **Red Team LLM (The Attacker)** — dispatches an adversarial LLM with the function's full AST profile. The LLM synthesizes a Hypothesis property-based test targeting: integer overflow, commutativity, associativity, idempotency, roundtrip encoding, monotonicity, aliasing, and exception-swallowing.
|
| 109 |
+
- **Deterministic Fuzzing Loop** — executes the generated PBT via subprocess (`shell=False` enforced, secrets stripped) with up to 50,000 randomized examples. Captures the minimal falsifying counter-example with exact input values.
|
| 110 |
+
- **CEGIS Re-attack Loop** — if no crash is found, the survived inputs are injected back into the LLM prompt ("these inputs didn't work, try harder") and a new invariant is synthesized. Up to 4 rounds, escalating to a stronger model on the final round.
|
| 111 |
+
- **CEGIS Handoff (Red → Blue)** — the crash is packaged as a deterministic pytest with the exact failing input baked in. The Blue Team processes it identically to a human-written failing test: SAST → supply chain → adversarial review → PR.
|
| 112 |
+
|
| 113 |
+
**Result:** Zero-day vulnerabilities are discovered, reproduced, patched, and PRed autonomously — without any human writing a test.
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## Roadmap
|
| 118 |
+
|
| 119 |
+
1. **Firecracker microVMs** — per-job execution isolation replacing subprocess
|
| 120 |
+
2. **GitHub App** — fine-grained per-repo OAuth replacing Personal Access Tokens
|
| 121 |
+
3. **PostgreSQL job store** — horizontal scaling for concurrent audits
|
| 122 |
+
4. **Webhook event triggers** — event-driven audits on push/PR events
|
| 123 |
+
5. **Fine-tuned model** — trained on proprietary `(test_failure, fix)` dataset
|
| 124 |
+
6. **Data flywheel** — every PR approval/rejection becomes training signal
|
| 125 |
+
7. **Concurrency fuzzer** — Hypothesis stateful testing + threading to find race conditions
|
| 126 |
+
8. **Multi-language AST** — extend Red Team to JavaScript (Babel AST), Go (go/ast), Rust (syn)
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
*Built by Rhodawk AI — the autonomous DevSecOps control plane.*
|
adversarial_reviewer.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import requests
|
| 6 |
+
from requests.exceptions import HTTPError
|
| 7 |
+
|
| 8 |
+
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
|
| 9 |
+
|
| 10 |
+
# Three-model rotation chain — each is a different provider/size to avoid
|
| 11 |
+
# hitting the same rate limit bucket twice in a row.
|
| 12 |
+
# Override the primary with RHODAWK_ADVERSARY_MODEL env var if needed.
|
| 13 |
+
ADVERSARY_MODEL_PRIMARY = os.getenv(
|
| 14 |
+
"RHODAWK_ADVERSARY_MODEL",
|
| 15 |
+
"openrouter/qwen/qwen-2.5-7b-instruct:free"
|
| 16 |
+
)
|
| 17 |
+
ADVERSARY_MODEL_SECONDARY = "openrouter/google/gemma-2-9b-it:free"
|
| 18 |
+
ADVERSARY_MODEL_TERTIARY = "openrouter/mistralai/mistral-7b-instruct:free"
|
| 19 |
+
|
| 20 |
+
# Ordered list — tried left to right, skipping on 429 or 404
|
| 21 |
+
_MODEL_CHAIN = [
|
| 22 |
+
ADVERSARY_MODEL_PRIMARY,
|
| 23 |
+
ADVERSARY_MODEL_SECONDARY,
|
| 24 |
+
ADVERSARY_MODEL_TERTIARY,
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
# Seconds to wait after a 429 before trying the next model in the chain
|
| 28 |
+
_RATE_LIMIT_WAIT = 20
|
| 29 |
+
|
| 30 |
+
ADVERSARY_SYSTEM_PROMPT = """You are a hostile senior security engineer and code quality enforcer.
|
| 31 |
+
Your ONLY job is to find problems in AI-generated code fixes. Be adversarial. Be thorough. Be brutal.
|
| 32 |
+
|
| 33 |
+
You are reviewing a diff produced by an AI to fix a failing test. Your job is to find:
|
| 34 |
+
1. SECURITY ISSUES: hardcoded credentials, injection risks, path traversal, insecure deserialization,
|
| 35 |
+
dangerous imports (os.system, eval, exec), secrets in code, SSRF vectors
|
| 36 |
+
2. CORRECTNESS ISSUES: does the fix actually solve the root cause or just suppress the symptom?
|
| 37 |
+
Does it handle edge cases? Will it break on different inputs?
|
| 38 |
+
3. REGRESSION RISKS: does this change break other functionality? Does it change public API signatures?
|
| 39 |
+
Does it modify behavior for the passing cases?
|
| 40 |
+
4. CODE QUALITY: does this fix increase cyclomatic complexity significantly? Does it introduce
|
| 41 |
+
dead code, duplicate logic, or anti-patterns?
|
| 42 |
+
5. SUPPLY CHAIN: does it add new dependencies? Are they trustworthy? Do they have known CVEs?
|
| 43 |
+
|
| 44 |
+
Respond ONLY in this exact JSON format:
|
| 45 |
+
{
|
| 46 |
+
"verdict": "APPROVE" | "CONDITIONAL" | "REJECT",
|
| 47 |
+
"confidence": 0.0-1.0,
|
| 48 |
+
"critical_issues": ["issue1", "issue2"],
|
| 49 |
+
"warnings": ["warning1", "warning2"],
|
| 50 |
+
"summary": "one sentence verdict summary",
|
| 51 |
+
"retry_guidance": "if REJECT: specific guidance for what the primary AI should do differently"
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
verdict rules:
|
| 55 |
+
- REJECT if ANY critical_issues exist (security vulnerabilities, correctness failures that will cause runtime errors, regressions)
|
| 56 |
+
- CONDITIONAL if only warnings exist (code quality, minor style, non-critical concerns)
|
| 57 |
+
- APPROVE if no issues found
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _call_openrouter(model: str, system: str, user: str, timeout: int = 60) -> dict:
|
| 62 |
+
headers = {
|
| 63 |
+
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 64 |
+
"Content-Type": "application/json",
|
| 65 |
+
"HTTP-Referer": "https://rhodawk.ai",
|
| 66 |
+
"X-Title": "Rhodawk AI Adversarial Reviewer",
|
| 67 |
+
}
|
| 68 |
+
payload = {
|
| 69 |
+
"model": model.replace("openrouter/", ""),
|
| 70 |
+
"messages": [
|
| 71 |
+
{"role": "system", "content": system},
|
| 72 |
+
{"role": "user", "content": user},
|
| 73 |
+
],
|
| 74 |
+
"temperature": 0.1,
|
| 75 |
+
"max_tokens": 1024,
|
| 76 |
+
"response_format": {"type": "json_object"},
|
| 77 |
+
}
|
| 78 |
+
resp = requests.post(
|
| 79 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 80 |
+
headers=headers,
|
| 81 |
+
json=payload,
|
| 82 |
+
timeout=timeout,
|
| 83 |
+
)
|
| 84 |
+
resp.raise_for_status()
|
| 85 |
+
data = resp.json()
|
| 86 |
+
content = data["choices"][0]["message"]["content"]
|
| 87 |
+
return json.loads(content)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _call_with_model_chain(user_prompt: str) -> tuple[dict, str]:
|
| 91 |
+
"""
|
| 92 |
+
Try each model in the chain in order.
|
| 93 |
+
On 429 (rate limit): wait _RATE_LIMIT_WAIT seconds then try next model.
|
| 94 |
+
On 404 (model not found): immediately try next model.
|
| 95 |
+
Returns (result_dict, model_used_string).
|
| 96 |
+
Raises RuntimeError if all models fail.
|
| 97 |
+
"""
|
| 98 |
+
last_error = None
|
| 99 |
+
for model in _MODEL_CHAIN:
|
| 100 |
+
try:
|
| 101 |
+
result = _call_openrouter(model, ADVERSARY_SYSTEM_PROMPT, user_prompt)
|
| 102 |
+
return result, model
|
| 103 |
+
except HTTPError as e:
|
| 104 |
+
status = e.response.status_code if e.response is not None else 0
|
| 105 |
+
if status == 429:
|
| 106 |
+
# Rate limited — wait before trying next model
|
| 107 |
+
time.sleep(_RATE_LIMIT_WAIT)
|
| 108 |
+
last_error = e
|
| 109 |
+
continue
|
| 110 |
+
elif status == 404:
|
| 111 |
+
# Model not found — skip immediately
|
| 112 |
+
last_error = e
|
| 113 |
+
continue
|
| 114 |
+
else:
|
| 115 |
+
# Other HTTP error — skip to next model
|
| 116 |
+
last_error = e
|
| 117 |
+
continue
|
| 118 |
+
except Exception as e:
|
| 119 |
+
last_error = e
|
| 120 |
+
continue
|
| 121 |
+
|
| 122 |
+
raise RuntimeError(f"All models in chain failed. Last error: {last_error}")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def run_adversarial_review(
|
| 126 |
+
diff_text: str,
|
| 127 |
+
test_path: str,
|
| 128 |
+
original_failure: str,
|
| 129 |
+
repo: str,
|
| 130 |
+
) -> dict:
|
| 131 |
+
"""
|
| 132 |
+
Run the adversarial LLM review on an AI-generated diff.
|
| 133 |
+
|
| 134 |
+
Returns a dict with:
|
| 135 |
+
verdict: "APPROVE" | "CONDITIONAL" | "REJECT"
|
| 136 |
+
critical_issues: list[str]
|
| 137 |
+
warnings: list[str]
|
| 138 |
+
summary: str
|
| 139 |
+
retry_guidance: str
|
| 140 |
+
model_used: str
|
| 141 |
+
review_hash: str
|
| 142 |
+
timestamp: str
|
| 143 |
+
"""
|
| 144 |
+
if not OPENROUTER_API_KEY:
|
| 145 |
+
return {
|
| 146 |
+
"verdict": "APPROVE",
|
| 147 |
+
"critical_issues": [],
|
| 148 |
+
"warnings": ["Adversarial review skipped — OPENROUTER_API_KEY not set"],
|
| 149 |
+
"summary": "Review skipped",
|
| 150 |
+
"retry_guidance": "",
|
| 151 |
+
"model_used": "none",
|
| 152 |
+
"review_hash": "skipped",
|
| 153 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
user_prompt = (
|
| 157 |
+
f"REPOSITORY: {repo}\n"
|
| 158 |
+
f"FAILING TEST: {test_path}\n\n"
|
| 159 |
+
f"ORIGINAL FAILURE OUTPUT:\n```\n{original_failure[:1500]}\n```\n\n"
|
| 160 |
+
f"AI-GENERATED DIFF TO REVIEW:\n```diff\n{diff_text[:3000]}\n```\n\n"
|
| 161 |
+
f"Find every problem with this diff. Be adversarial."
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
result, model_used = _call_with_model_chain(user_prompt)
|
| 166 |
+
except Exception as e:
|
| 167 |
+
return {
|
| 168 |
+
"verdict": "CONDITIONAL",
|
| 169 |
+
"critical_issues": [],
|
| 170 |
+
"warnings": [f"Adversarial review failed after trying all models: {e}"],
|
| 171 |
+
"summary": "Review unavailable — proceeding with SAST gate only",
|
| 172 |
+
"retry_guidance": "",
|
| 173 |
+
"model_used": "failed",
|
| 174 |
+
"review_hash": "failed",
|
| 175 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
review_input = f"{diff_text}{original_failure}"
|
| 179 |
+
review_hash = hashlib.sha256(review_input.encode()).hexdigest()[:16]
|
| 180 |
+
|
| 181 |
+
return {
|
| 182 |
+
"verdict": result.get("verdict", "CONDITIONAL"),
|
| 183 |
+
"confidence": result.get("confidence", 0.5),
|
| 184 |
+
"critical_issues": result.get("critical_issues", []),
|
| 185 |
+
"warnings": result.get("warnings", []),
|
| 186 |
+
"summary": result.get("summary", ""),
|
| 187 |
+
"retry_guidance": result.get("retry_guidance", ""),
|
| 188 |
+
"model_used": model_used,
|
| 189 |
+
"review_hash": review_hash,
|
| 190 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 191 |
+
}
|
app.py
ADDED
|
@@ -0,0 +1,1058 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Autonomous DevSecOps Control Plane v3.0
|
| 3 |
+
=====================================================
|
| 4 |
+
The code review monster. No competitor has this capability stack.
|
| 5 |
+
|
| 6 |
+
Full loop:
|
| 7 |
+
1. Clone repo → discover tests → run pytest
|
| 8 |
+
2. FAIL → retrieve similar fixes from memory (data flywheel)
|
| 9 |
+
3. Dispatch Aider with failure + memory context via MCP tools
|
| 10 |
+
4. Re-run tests on the patched code (verification — CLOSES THE LOOP)
|
| 11 |
+
5. If still failing → retry with new failure context (up to MAX_RETRIES)
|
| 12 |
+
6. SAST gate: bandit + 16-pattern secret scanner
|
| 13 |
+
7. Supply chain gate: pip-audit + typosquatting detection
|
| 14 |
+
8. Adversarial LLM review: second model plays hostile red-team reviewer
|
| 15 |
+
9. If adversary REJECTs → loop back with critique as context
|
| 16 |
+
10. All clear → open PR, record to training store, update memory
|
| 17 |
+
11. Webhook server runs in parallel for event-driven triggers
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import glob
|
| 21 |
+
import hashlib
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import signal
|
| 25 |
+
import subprocess
|
| 26 |
+
import tempfile
|
| 27 |
+
import threading
|
| 28 |
+
import time
|
| 29 |
+
from typing import Optional
|
| 30 |
+
|
| 31 |
+
import gradio as gr
|
| 32 |
+
import requests
|
| 33 |
+
from git import Repo
|
| 34 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 35 |
+
|
| 36 |
+
from adversarial_reviewer import run_adversarial_review
|
| 37 |
+
from audit_logger import export_compliance_report, log_audit_event, read_audit_trail, verify_chain_integrity
|
| 38 |
+
from github_app import get_github_token
|
| 39 |
+
from job_queue import JobStatus, get_job_status_enum, get_metrics, list_all_jobs, upsert_job
|
| 40 |
+
from memory_engine import get_memory_stats, record_fix_outcome, retrieve_similar_fixes
|
| 41 |
+
from embedding_memory import retrieve_similar_fixes_v2
|
| 42 |
+
from notifier import (
|
| 43 |
+
notify,
|
| 44 |
+
notify_audit_complete,
|
| 45 |
+
notify_audit_start,
|
| 46 |
+
notify_chain_integrity,
|
| 47 |
+
notify_patch_failed,
|
| 48 |
+
notify_pr_created,
|
| 49 |
+
notify_sast_blocked,
|
| 50 |
+
notify_test_failed,
|
| 51 |
+
)
|
| 52 |
+
from sast_gate import run_sast_gate
|
| 53 |
+
from red_team_fuzzer import get_red_team_logs, get_red_team_stats, run_red_team_cegis
|
| 54 |
+
from supply_chain import run_supply_chain_gate
|
| 55 |
+
from training_store import export_training_data, get_statistics, record_attempt, update_test_result
|
| 56 |
+
from verification_loop import (
|
| 57 |
+
MAX_RETRIES,
|
| 58 |
+
ADVERSARIAL_REJECTION_MULTIPLIER,
|
| 59 |
+
VerificationAttempt,
|
| 60 |
+
VerificationResult,
|
| 61 |
+
build_initial_prompt,
|
| 62 |
+
build_retry_prompt,
|
| 63 |
+
)
|
| 64 |
+
from webhook_server import set_job_dispatcher, start_webhook_server
|
| 65 |
+
from worker_pool import MAX_WORKERS, run_parallel_audit
|
| 66 |
+
|
| 67 |
+
# ──────────────────────────────────────────────────────────────
|
| 68 |
+
# SECRETS — env only, never hardcoded
|
| 69 |
+
# ──────────────────────────────────────────────────────────────
|
| 70 |
+
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
| 71 |
+
GITHUB_REPO = os.getenv("GITHUB_REPO")
|
| 72 |
+
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
|
| 73 |
+
TENANT_ID = os.getenv("RHODAWK_TENANT_ID", "default")
|
| 74 |
+
MODEL = os.getenv("RHODAWK_MODEL", "openrouter/qwen/qwen-2.5-coder-32b-instruct:free")
|
| 75 |
+
RED_TEAM_ENABLED = os.getenv("RHODAWK_RED_TEAM_ENABLED", "true").lower() != "false"
|
| 76 |
+
|
| 77 |
+
for _key, _val in [("GITHUB_TOKEN", GITHUB_TOKEN), ("GITHUB_REPO", GITHUB_REPO), ("OPENROUTER_API_KEY", OPENROUTER_API_KEY)]:
|
| 78 |
+
if not _val:
|
| 79 |
+
raise EnvironmentError(f"Required secret '{_key}' is not set. Add it in HuggingFace Space Settings → Secrets.")
|
| 80 |
+
|
| 81 |
+
# ──────────────────────────────────────────────────────────────
|
| 82 |
+
# PATHS & CONSTANTS
|
| 83 |
+
# ──────────────────────────────────────────────────────────────
|
| 84 |
+
PERSISTENT_DIR = "/data"
|
| 85 |
+
REPO_DIR = f"{PERSISTENT_DIR}/repo"
|
| 86 |
+
VENV_DIR = f"{PERSISTENT_DIR}/target_venv"
|
| 87 |
+
MCP_RUNTIME_CONFIG = "/tmp/mcp_runtime.json"
|
| 88 |
+
|
| 89 |
+
# ──────────────────────────────────────────────────────────────
|
| 90 |
+
# GLOBAL STATE
|
| 91 |
+
# ──────────────────────────────────────────────────────────────
|
| 92 |
+
dashboard_logs: list[str] = []
|
| 93 |
+
_log_lock = threading.Lock()
|
| 94 |
+
_audit_event = threading.Event()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def ui_log(message: str, level: str = "INFO"):
|
| 98 |
+
ts = time.strftime("%H:%M:%S")
|
| 99 |
+
icons = {"OK": "✅", "FAIL": "❌", "WARN": "⚠", "SAST": "🛡", "ADV": "🔴", "PR": "🔁",
|
| 100 |
+
"SKIP": "⏭", "MEM": "🧠", "CHAIN": "⛓", "SUPPLY": "📦", "RETRY": "🔄", "INFO": " ",
|
| 101 |
+
"RED": "⚔️", "ATTACK": "🗡", "CRASH": "💥", "BENCH": "🧪", "POOL": "⚡"}
|
| 102 |
+
line = f"[{ts}] {icons.get(level, ' ')} {message}"
|
| 103 |
+
print(line)
|
| 104 |
+
with _log_lock:
|
| 105 |
+
dashboard_logs.append(line)
|
| 106 |
+
if len(dashboard_logs) > 300:
|
| 107 |
+
dashboard_logs.pop(0)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ──────────────────────────────────────────────────────────────
|
| 111 |
+
# SUBPROCESS RUNNER — shell=False enforced, secrets stripped
|
| 112 |
+
# ──────────────────────────────────────────────────────────────
|
| 113 |
+
def run_subprocess_safe(cmd: list, cwd: str = REPO_DIR, timeout: int = 300,
|
| 114 |
+
env_overrides: dict = None, raise_on_error: bool = True) -> tuple[str, int]:
|
| 115 |
+
if isinstance(cmd, str):
|
| 116 |
+
raise TypeError("SECURITY: String commands forbidden. Use list.")
|
| 117 |
+
env = os.environ.copy()
|
| 118 |
+
for k in ["OPENROUTER_API_KEY", "GITHUB_TOKEN", "GITHUB_PERSONAL_ACCESS_TOKEN",
|
| 119 |
+
"TELEGRAM_BOT_TOKEN", "SLACK_WEBHOOK_URL", "RHODAWK_WEBHOOK_SECRET"]:
|
| 120 |
+
env.pop(k, None)
|
| 121 |
+
if env_overrides:
|
| 122 |
+
env.update(env_overrides)
|
| 123 |
+
proc = None
|
| 124 |
+
try:
|
| 125 |
+
proc = subprocess.Popen(cmd, shell=False, cwd=cwd, stdout=subprocess.PIPE,
|
| 126 |
+
stderr=subprocess.PIPE, text=True, env=env, start_new_session=True)
|
| 127 |
+
stdout, stderr = proc.communicate(timeout=timeout)
|
| 128 |
+
output = (stdout or "") + "\n" + (stderr or "")
|
| 129 |
+
if raise_on_error and proc.returncode != 0:
|
| 130 |
+
raise subprocess.CalledProcessError(proc.returncode, cmd, stdout, stderr)
|
| 131 |
+
return output, proc.returncode
|
| 132 |
+
except subprocess.TimeoutExpired:
|
| 133 |
+
if proc:
|
| 134 |
+
try:
|
| 135 |
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
| 136 |
+
except ProcessLookupError:
|
| 137 |
+
pass
|
| 138 |
+
proc.communicate()
|
| 139 |
+
raise RuntimeError(f"Command timed out after {timeout}s: {cmd[0]}")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ──────────────────────────────────────────────────────────────
|
| 143 |
+
# GIT HELPERS
|
| 144 |
+
# ──────────────────────────────────────────────────────────────
|
| 145 |
+
def configure_git_credentials():
|
| 146 |
+
cred_path = "/tmp/.git-credentials"
|
| 147 |
+
with open(cred_path, "w") as f:
|
| 148 |
+
f.write(f"https://x-token:{GITHUB_TOKEN}@github.com\n")
|
| 149 |
+
os.chmod(cred_path, 0o600)
|
| 150 |
+
run_subprocess_safe(["git", "config", "--global", "credential.helper", f"store --file {cred_path}"], cwd="/tmp")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def write_mcp_config() -> str:
|
| 154 |
+
config = {
|
| 155 |
+
"mcpServers": {
|
| 156 |
+
# @modelcontextprotocol/server-fetch does not exist on npm.
|
| 157 |
+
# The fetch MCP server is a Python package; invoke via uvx.
|
| 158 |
+
"fetch-docs": {
|
| 159 |
+
"command": "uvx", "args": ["mcp-server-fetch"],
|
| 160 |
+
"env": {"FETCH_ALLOWED_DOMAINS": "docs.python.org,pypi.org,docs.github.com,packaging.python.org,peps.python.org,semver.org"}
|
| 161 |
+
},
|
| 162 |
+
"github-manager": {
|
| 163 |
+
"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
|
| 164 |
+
"env": {"GITHUB_PERSONAL_ACCESS_TOKEN": GITHUB_TOKEN}
|
| 165 |
+
},
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
with open(MCP_RUNTIME_CONFIG, "w") as f:
|
| 169 |
+
json.dump(config, f, indent=2)
|
| 170 |
+
os.chmod(MCP_RUNTIME_CONFIG, 0o600)
|
| 171 |
+
return MCP_RUNTIME_CONFIG
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def safe_git_pull():
|
| 175 |
+
_, code = run_subprocess_safe(["git", "pull", "--ff-only", "origin", "main"], cwd=REPO_DIR, raise_on_error=False)
|
| 176 |
+
if code != 0:
|
| 177 |
+
run_subprocess_safe(["git", "fetch", "origin"], cwd=REPO_DIR)
|
| 178 |
+
run_subprocess_safe(["git", "reset", "--hard", "origin/main"], cwd=REPO_DIR)
|
| 179 |
+
run_subprocess_safe(["git", "clean", "-fd"], cwd=REPO_DIR)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def cleanup_stale_branch(branch_name: str):
|
| 183 |
+
run_subprocess_safe(["git", "branch", "-D", branch_name], cwd=REPO_DIR, raise_on_error=False)
|
| 184 |
+
run_subprocess_safe(["git", "push", "origin", "--delete", branch_name], cwd=REPO_DIR, raise_on_error=False)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def create_fix_branch(branch_name: str) -> bool:
|
| 188 |
+
run_subprocess_safe(["git", "checkout", "main"], cwd=REPO_DIR, raise_on_error=False)
|
| 189 |
+
run_subprocess_safe(["git", "pull", "--ff-only", "origin", "main"], cwd=REPO_DIR, raise_on_error=False)
|
| 190 |
+
run_subprocess_safe(["git", "branch", "-D", branch_name], cwd=REPO_DIR, raise_on_error=False)
|
| 191 |
+
_, code = run_subprocess_safe(["git", "checkout", "-b", branch_name], cwd=REPO_DIR, raise_on_error=False)
|
| 192 |
+
return code == 0
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def ensure_fix_committed(test_path: str) -> None:
|
| 196 |
+
status, _ = run_subprocess_safe(["git", "status", "--porcelain"], cwd=REPO_DIR, raise_on_error=False)
|
| 197 |
+
if not status.strip():
|
| 198 |
+
return
|
| 199 |
+
run_subprocess_safe(["git", "add", "."], cwd=REPO_DIR, raise_on_error=False)
|
| 200 |
+
message = f"[Rhodawk] Auto-heal {os.path.basename(test_path)}"
|
| 201 |
+
run_subprocess_safe(["git", "commit", "-m", message], cwd=REPO_DIR, raise_on_error=False)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def push_fix_branch(branch_name: str) -> bool:
|
| 205 |
+
_, code = run_subprocess_safe(["git", "push", "-u", "origin", branch_name], cwd=REPO_DIR, raise_on_error=False)
|
| 206 |
+
return code == 0
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def create_github_pr(repo: str, branch: str, test_path: str, token: str) -> str:
|
| 210 |
+
headers = {
|
| 211 |
+
"Authorization": f"Bearer {token}",
|
| 212 |
+
"Accept": "application/vnd.github+json",
|
| 213 |
+
"X-GitHub-API-Version": "2022-11-28",
|
| 214 |
+
}
|
| 215 |
+
payload = {
|
| 216 |
+
"title": f"[Rhodawk] Auto-heal: {os.path.basename(test_path)}",
|
| 217 |
+
"head": branch,
|
| 218 |
+
"base": "main",
|
| 219 |
+
"body": (
|
| 220 |
+
"## Rhodawk AI Autonomous Fix\n\n"
|
| 221 |
+
"This PR was generated autonomously by Rhodawk AI v4.0.\n"
|
| 222 |
+
"- Tests verified green after fix\n"
|
| 223 |
+
"- SAST gate passed\n"
|
| 224 |
+
"- Supply chain gate passed\n"
|
| 225 |
+
"- Adversarial LLM review completed\n\n"
|
| 226 |
+
f"**Test fixed:** `{test_path}`\n"
|
| 227 |
+
),
|
| 228 |
+
"draft": False,
|
| 229 |
+
}
|
| 230 |
+
resp = requests.post(
|
| 231 |
+
f"https://api.github.com/repos/{repo}/pulls",
|
| 232 |
+
headers=headers,
|
| 233 |
+
json=payload,
|
| 234 |
+
timeout=30,
|
| 235 |
+
)
|
| 236 |
+
resp.raise_for_status()
|
| 237 |
+
return resp.json()["html_url"]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def get_current_diff() -> str:
|
| 241 |
+
try:
|
| 242 |
+
diff, _ = run_subprocess_safe(["git", "diff", "HEAD~1", "HEAD", "--unified=3"], cwd=REPO_DIR, raise_on_error=False)
|
| 243 |
+
return diff
|
| 244 |
+
except Exception:
|
| 245 |
+
# Try working tree diff if no commits yet
|
| 246 |
+
try:
|
| 247 |
+
diff, _ = run_subprocess_safe(["git", "diff", "--unified=3"], cwd=REPO_DIR, raise_on_error=False)
|
| 248 |
+
return diff
|
| 249 |
+
except Exception:
|
| 250 |
+
return ""
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def get_changed_files() -> list[str]:
|
| 254 |
+
try:
|
| 255 |
+
out, _ = run_subprocess_safe(["git", "diff", "--name-only", "HEAD~1", "HEAD"], cwd=REPO_DIR, raise_on_error=False)
|
| 256 |
+
return [f.strip() for f in out.splitlines() if f.strip()]
|
| 257 |
+
except Exception:
|
| 258 |
+
return []
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def setup_target_venv() -> str:
|
| 262 |
+
if not os.path.exists(VENV_DIR):
|
| 263 |
+
ui_log("Creating isolated virtualenv via uv...")
|
| 264 |
+
run_subprocess_safe(["uv", "venv", VENV_DIR], cwd="/tmp")
|
| 265 |
+
pytest_bin = os.path.join(VENV_DIR, "bin", "pytest")
|
| 266 |
+
req_path = os.path.join(REPO_DIR, "requirements.txt")
|
| 267 |
+
if os.path.exists(req_path):
|
| 268 |
+
ui_log("Installing target repo deps via uv...")
|
| 269 |
+
run_subprocess_safe(
|
| 270 |
+
["uv", "pip", "install", "--python", VENV_DIR, "--quiet", "-r", req_path],
|
| 271 |
+
cwd=REPO_DIR, timeout=600,
|
| 272 |
+
)
|
| 273 |
+
return pytest_bin
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# ──────────────────────────────────────────────────────────────
|
| 277 |
+
# AIDER RUNNER
|
| 278 |
+
# ──────────────────────────────────────────────────────────────
|
| 279 |
+
def run_aider(mcp_config_path: str, prompt: str, context_files: list[str]) -> tuple[str, int]:
|
| 280 |
+
fd, prompt_path = tempfile.mkstemp(prefix="aider_prompt_", suffix=".txt")
|
| 281 |
+
try:
|
| 282 |
+
with os.fdopen(fd, "w") as f:
|
| 283 |
+
f.write(prompt)
|
| 284 |
+
valid = [f for f in context_files if os.path.exists(os.path.join(REPO_DIR, f))]
|
| 285 |
+
cmd = ["aider", "--model", MODEL, "--yes", "--no-stream",
|
| 286 |
+
"--message-file", prompt_path]
|
| 287 |
+
if mcp_config_path and os.path.exists(mcp_config_path):
|
| 288 |
+
cmd += ["--mcp-config", mcp_config_path]
|
| 289 |
+
cmd += valid
|
| 290 |
+
|
| 291 |
+
return run_subprocess_safe(cmd, cwd=REPO_DIR, timeout=600,
|
| 292 |
+
env_overrides={"OPENROUTER_API_KEY": OPENROUTER_API_KEY},
|
| 293 |
+
raise_on_error=False)
|
| 294 |
+
finally:
|
| 295 |
+
try:
|
| 296 |
+
os.unlink(prompt_path)
|
| 297 |
+
except OSError:
|
| 298 |
+
pass
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# ──────────────────────────────────────────────────────────────
|
| 302 |
+
# THE FULL LOOP — this is the product
|
| 303 |
+
# ──────────────────────────────────────────────────────────────
|
| 304 |
+
def process_failing_test(
|
| 305 |
+
test_path: str,
|
| 306 |
+
initial_failure: str,
|
| 307 |
+
pytest_bin: str,
|
| 308 |
+
mcp_config_path: str,
|
| 309 |
+
job_id: str,
|
| 310 |
+
branch_name: str,
|
| 311 |
+
) -> VerificationResult:
|
| 312 |
+
"""
|
| 313 |
+
The core autonomous healing loop:
|
| 314 |
+
memory retrieval → aider fix → test verification → adversarial review
|
| 315 |
+
→ SAST gate → supply chain gate → PR open
|
| 316 |
+
Retries up to MAX_RETRIES with accumulating context.
|
| 317 |
+
"""
|
| 318 |
+
filename = os.path.basename(test_path)
|
| 319 |
+
src_file = f"src/{filename.replace('test_', '')}"
|
| 320 |
+
context_files = [test_path]
|
| 321 |
+
|
| 322 |
+
# E.g., 'agents/test_generator.py' -> 'agents/generator.py'
|
| 323 |
+
src_file = test_path.replace('test_', '')
|
| 324 |
+
|
| 325 |
+
# Try finding the file in the same directory first
|
| 326 |
+
if os.path.exists(os.path.join(REPO_DIR, src_file)):
|
| 327 |
+
context_files.append(src_file)
|
| 328 |
+
else:
|
| 329 |
+
# Fallback to the src/ directory pattern
|
| 330 |
+
filename = os.path.basename(test_path)
|
| 331 |
+
fallback_src = f"src/{filename.replace('test_', '')}"
|
| 332 |
+
if os.path.exists(os.path.join(REPO_DIR, fallback_src)):
|
| 333 |
+
src_file = fallback_src
|
| 334 |
+
context_files.append(src_file)
|
| 335 |
+
|
| 336 |
+
if os.path.exists(os.path.join(REPO_DIR, "requirements.txt")):
|
| 337 |
+
context_files.append("requirements.txt")
|
| 338 |
+
|
| 339 |
+
attempt_history: list[VerificationAttempt] = []
|
| 340 |
+
current_failure = initial_failure
|
| 341 |
+
|
| 342 |
+
cleanup_stale_branch(branch_name)
|
| 343 |
+
if not create_fix_branch(branch_name):
|
| 344 |
+
return VerificationResult(
|
| 345 |
+
success=False,
|
| 346 |
+
attempts=attempt_history,
|
| 347 |
+
failure_reason=f"Unable to create fix branch {branch_name}",
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
max_total_attempts = MAX_RETRIES + max(0, ADVERSARIAL_REJECTION_MULTIPLIER)
|
| 351 |
+
for attempt_num in range(1, max_total_attempts + 1):
|
| 352 |
+
ui_log(f"Attempt {attempt_num}/{max_total_attempts}: {test_path}", "RETRY" if attempt_num > 1 else "INFO")
|
| 353 |
+
|
| 354 |
+
# ── Step 1: Retrieve similar fixes from memory ──────────
|
| 355 |
+
try:
|
| 356 |
+
similar_fixes = retrieve_similar_fixes_v2(current_failure, top_k=3)
|
| 357 |
+
except Exception:
|
| 358 |
+
similar_fixes = retrieve_similar_fixes(current_failure, top_k=3)
|
| 359 |
+
if similar_fixes:
|
| 360 |
+
ui_log(f"Memory: found {len(similar_fixes)} similar past fix(es) (best similarity: {similar_fixes[0]['similarity']})", "MEM")
|
| 361 |
+
|
| 362 |
+
# ── Step 2: Build prompt with memory + retry context ────
|
| 363 |
+
if attempt_num == 1:
|
| 364 |
+
prompt = build_initial_prompt(test_path, src_file, branch_name, current_failure, similar_fixes)
|
| 365 |
+
else:
|
| 366 |
+
prompt = build_retry_prompt(test_path, src_file, branch_name, initial_failure, attempt_history, similar_fixes)
|
| 367 |
+
|
| 368 |
+
prompt_hash = hashlib.sha256(prompt.encode()).hexdigest()[:16]
|
| 369 |
+
|
| 370 |
+
log_audit_event("AIDER_DISPATCH", job_id, GITHUB_REPO, MODEL, {
|
| 371 |
+
"test": test_path, "attempt": attempt_num, "prompt_hash": prompt_hash,
|
| 372 |
+
"memory_hits": len(similar_fixes),
|
| 373 |
+
}, "DISPATCHED")
|
| 374 |
+
|
| 375 |
+
# ── Step 3: Run Aider ───────────────────────────────────
|
| 376 |
+
aider_output, aider_code = run_aider(mcp_config_path, prompt, context_files)
|
| 377 |
+
|
| 378 |
+
if aider_code != 0:
|
| 379 |
+
ui_log(f"Aider non-zero exit on attempt {attempt_num}", "WARN")
|
| 380 |
+
ui_log(f"AIDER CRASH REASON: {aider_output.strip()[:800]}", "FAIL") # <--- ADD THIS LINE
|
| 381 |
+
attempt_history.append(VerificationAttempt(
|
| 382 |
+
|
| 383 |
+
attempt_number=attempt_num, prompt_hash=prompt_hash,
|
| 384 |
+
aider_exit_code=aider_code, test_exit_code=-1,
|
| 385 |
+
test_output="Aider failed to produce output", diff_produced="",
|
| 386 |
+
))
|
| 387 |
+
record_fix_outcome(current_failure, test_path, "", success=False)
|
| 388 |
+
if attempt_num < max_total_attempts:
|
| 389 |
+
time.sleep(RETRY_BACKOFF_SECONDS := 5)
|
| 390 |
+
continue
|
| 391 |
+
return VerificationResult(success=False, attempts=attempt_history,
|
| 392 |
+
failure_reason=f"Aider failed on all {MAX_RETRIES} attempts")
|
| 393 |
+
|
| 394 |
+
# ── Step 4: Get the diff Aider produced ─────────────────
|
| 395 |
+
diff_text = get_current_diff()
|
| 396 |
+
changed_files = get_changed_files()
|
| 397 |
+
|
| 398 |
+
# ── Step 5: RE-RUN TESTS — close the loop ───────────────
|
| 399 |
+
ui_log(f"Verifying fix — re-running tests (attempt {attempt_num})...", "INFO")
|
| 400 |
+
test_output, test_code = run_subprocess_safe(
|
| 401 |
+
[pytest_bin, test_path, "-v", "--tb=short"], cwd=REPO_DIR, timeout=120, raise_on_error=False
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
attempt = VerificationAttempt(
|
| 405 |
+
attempt_number=attempt_num, prompt_hash=prompt_hash,
|
| 406 |
+
aider_exit_code=aider_code, test_exit_code=test_code,
|
| 407 |
+
test_output=test_output, diff_produced=diff_text,
|
| 408 |
+
)
|
| 409 |
+
attempt_history.append(attempt)
|
| 410 |
+
|
| 411 |
+
# ── Step 6: SAST gate ────────────────────────────────────
|
| 412 |
+
ui_log("Running SAST gate on AI diff...", "SAST")
|
| 413 |
+
sast_report = run_sast_gate(diff_text, changed_files, REPO_DIR)
|
| 414 |
+
log_audit_event("SAST_SCAN", job_id, GITHUB_REPO, MODEL, {
|
| 415 |
+
"attempt": attempt_num, "passed": sast_report.passed,
|
| 416 |
+
"findings": len(sast_report.findings), "blocked_reason": sast_report.blocked_reason,
|
| 417 |
+
}, "PASSED" if sast_report.passed else "BLOCKED")
|
| 418 |
+
|
| 419 |
+
if not sast_report.passed:
|
| 420 |
+
ui_log(f"SAST BLOCKED: {sast_report.blocked_reason}", "SAST")
|
| 421 |
+
notify_sast_blocked(test_path, sast_report.blocked_reason)
|
| 422 |
+
record_fix_outcome(current_failure, test_path, diff_text, success=False)
|
| 423 |
+
# Revert and retry with SAST failure as context
|
| 424 |
+
run_subprocess_safe(["git", "checkout", "."], cwd=REPO_DIR, raise_on_error=False)
|
| 425 |
+
current_failure = f"Previous fix was SAST-blocked: {sast_report.blocked_reason}\n\nOriginal failure:\n{initial_failure}"
|
| 426 |
+
if attempt_num < max_total_attempts:
|
| 427 |
+
continue
|
| 428 |
+
return VerificationResult(success=False, attempts=attempt_history,
|
| 429 |
+
failure_reason=f"SAST gate blocked all attempts")
|
| 430 |
+
|
| 431 |
+
# ── Step 7: Supply chain gate ────────────────────────────
|
| 432 |
+
ui_log("Running supply chain gate...", "SUPPLY")
|
| 433 |
+
sc_report = run_supply_chain_gate(diff_text, REPO_DIR)
|
| 434 |
+
log_audit_event("SUPPLY_CHAIN_SCAN", job_id, GITHUB_REPO, MODEL, {
|
| 435 |
+
"attempt": attempt_num, "passed": sc_report.passed,
|
| 436 |
+
"new_packages": sc_report.new_packages, "blocked_reason": sc_report.blocked_reason,
|
| 437 |
+
}, "PASSED" if sc_report.passed else "BLOCKED")
|
| 438 |
+
|
| 439 |
+
if not sc_report.passed:
|
| 440 |
+
ui_log(f"SUPPLY CHAIN BLOCKED: {sc_report.blocked_reason}", "SUPPLY")
|
| 441 |
+
run_subprocess_safe(["git", "checkout", "."], cwd=REPO_DIR, raise_on_error=False)
|
| 442 |
+
current_failure = f"Previous fix introduced supply chain risk: {sc_report.blocked_reason}\n\nOriginal:\n{initial_failure}"
|
| 443 |
+
if attempt_num < max_total_attempts:
|
| 444 |
+
continue
|
| 445 |
+
return VerificationResult(success=False, attempts=attempt_history,
|
| 446 |
+
failure_reason=f"Supply chain gate blocked all attempts")
|
| 447 |
+
|
| 448 |
+
# ── Step 8: ADVERSARIAL LLM REVIEW ──────────────────────
|
| 449 |
+
ui_log("Dispatching adversarial reviewer (red team)...", "ADV")
|
| 450 |
+
adv_review = run_adversarial_review(diff_text, test_path, initial_failure, GITHUB_REPO)
|
| 451 |
+
verdict = adv_review.get("verdict", "CONDITIONAL")
|
| 452 |
+
|
| 453 |
+
log_audit_event("ADVERSARIAL_REVIEW", job_id, GITHUB_REPO, MODEL, {
|
| 454 |
+
"attempt": attempt_num, "verdict": verdict,
|
| 455 |
+
"model": adv_review.get("model_used"), "review_hash": adv_review.get("review_hash"),
|
| 456 |
+
"critical_issues": adv_review.get("critical_issues", []),
|
| 457 |
+
"summary": adv_review.get("summary", ""),
|
| 458 |
+
}, verdict)
|
| 459 |
+
|
| 460 |
+
if adv_review.get("warnings"):
|
| 461 |
+
for w in adv_review["warnings"]:
|
| 462 |
+
ui_log(f"Adversary warning: {w}", "ADV")
|
| 463 |
+
|
| 464 |
+
if verdict == "REJECT":
|
| 465 |
+
ui_log(f"ADVERSARIAL REJECTED: {adv_review.get('summary', '')}", "ADV")
|
| 466 |
+
for issue in adv_review.get("critical_issues", []):
|
| 467 |
+
ui_log(f" Critical: {issue}", "ADV")
|
| 468 |
+
|
| 469 |
+
record_fix_outcome(current_failure, test_path, diff_text, success=False)
|
| 470 |
+
run_subprocess_safe(["git", "checkout", "."], cwd=REPO_DIR, raise_on_error=False)
|
| 471 |
+
|
| 472 |
+
# Inject adversary critique into next attempt
|
| 473 |
+
critique = adv_review.get("retry_guidance", "")
|
| 474 |
+
issues = "\n".join(adv_review.get("critical_issues", []))
|
| 475 |
+
current_failure = (
|
| 476 |
+
f"Your previous fix was REJECTED by adversarial review.\n"
|
| 477 |
+
f"Critical issues found:\n{issues}\n"
|
| 478 |
+
f"Guidance: {critique}\n\n"
|
| 479 |
+
f"Original failure:\n{initial_failure}"
|
| 480 |
+
)
|
| 481 |
+
if attempt_num < max_total_attempts:
|
| 482 |
+
continue
|
| 483 |
+
return VerificationResult(success=False, attempts=attempt_history,
|
| 484 |
+
failure_reason="Adversarial reviewer rejected all attempts")
|
| 485 |
+
|
| 486 |
+
# ── Step 9: Check if tests pass ──────────────────────────
|
| 487 |
+
if test_code != 0:
|
| 488 |
+
ui_log(f"Tests still failing after attempt {attempt_num}. Retrying with new context...", "RETRY")
|
| 489 |
+
record_fix_outcome(current_failure, test_path, diff_text, success=False)
|
| 490 |
+
current_failure = (
|
| 491 |
+
f"Attempt {attempt_num} fix did not solve the problem.\n"
|
| 492 |
+
f"New failure:\n{test_output[:1500]}\n\n"
|
| 493 |
+
f"Original failure:\n{initial_failure}"
|
| 494 |
+
)
|
| 495 |
+
run_subprocess_safe(["git", "checkout", "."], cwd=REPO_DIR, raise_on_error=False)
|
| 496 |
+
if attempt_num < max_total_attempts:
|
| 497 |
+
continue
|
| 498 |
+
return VerificationResult(success=False, attempts=attempt_history,
|
| 499 |
+
failure_reason=f"Tests still failing after {MAX_RETRIES} attempts",
|
| 500 |
+
final_test_output=test_output)
|
| 501 |
+
|
| 502 |
+
# ── EVERYTHING PASSED ────────────────────────────────────
|
| 503 |
+
ui_log(f"VERIFIED GREEN on attempt {attempt_num}: {test_path}", "OK")
|
| 504 |
+
ensure_fix_committed(test_path)
|
| 505 |
+
diff_text = get_current_diff()
|
| 506 |
+
record_fix_outcome(current_failure, test_path, diff_text, success=True)
|
| 507 |
+
|
| 508 |
+
return VerificationResult(
|
| 509 |
+
success=True, attempts=attempt_history,
|
| 510 |
+
final_diff=diff_text, final_test_output=test_output,
|
| 511 |
+
total_attempts=attempt_num,
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
return VerificationResult(success=False, attempts=attempt_history,
|
| 515 |
+
failure_reason="Max retries exceeded")
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
# ──────────────────────────────────────────────────────────────
|
| 519 |
+
# MAIN AUDIT ORCHESTRATOR
|
| 520 |
+
# ──────────────────────────────────────────────────────────────
|
| 521 |
+
def process_audit_test(
|
| 522 |
+
test_path: str,
|
| 523 |
+
pytest_bin: str,
|
| 524 |
+
mcp_config_path: str,
|
| 525 |
+
tenant_id: str,
|
| 526 |
+
target_repo: str,
|
| 527 |
+
) -> dict:
|
| 528 |
+
filename = os.path.basename(test_path)
|
| 529 |
+
branch_name = f"rhodawk/auto-patch/{filename.replace('.py','').replace('_','-')}"
|
| 530 |
+
|
| 531 |
+
current_status = get_job_status_enum(tenant_id, target_repo, test_path)
|
| 532 |
+
if current_status == JobStatus.DONE:
|
| 533 |
+
ui_log(f"Skipping (DONE): {test_path}", "SKIP")
|
| 534 |
+
return {"skipped": True}
|
| 535 |
+
if current_status == JobStatus.RUNNING:
|
| 536 |
+
ui_log(f"Cleaning interrupted job: {test_path}", "WARN")
|
| 537 |
+
cleanup_stale_branch(branch_name)
|
| 538 |
+
safe_git_pull()
|
| 539 |
+
|
| 540 |
+
job_id = upsert_job(tenant_id, target_repo, test_path, JobStatus.RUNNING)
|
| 541 |
+
ui_log(f"Testing: {test_path} [job:{job_id}]")
|
| 542 |
+
|
| 543 |
+
initial_output, pytest_code = run_subprocess_safe(
|
| 544 |
+
[pytest_bin, test_path, "-v", "--tb=short"], cwd=REPO_DIR, timeout=120, raise_on_error=False
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
if pytest_code == 0:
|
| 548 |
+
ui_log(f"PASSED: {test_path}", "OK")
|
| 549 |
+
upsert_job(tenant_id, target_repo, test_path, JobStatus.DONE, "tests passed")
|
| 550 |
+
log_audit_event("TEST_PASS", job_id, target_repo, MODEL,
|
| 551 |
+
{"test": test_path, "attempt": 0}, "PASSED")
|
| 552 |
+
record_attempt(tenant_id, target_repo, test_path, initial_output, MODEL,
|
| 553 |
+
"baseline", 0, test_passed_after=True)
|
| 554 |
+
return {"success": True, "already_green": True}
|
| 555 |
+
|
| 556 |
+
ui_log(f"FAILED: {test_path} — entering healing loop...", "FAIL")
|
| 557 |
+
notify_test_failed(test_path)
|
| 558 |
+
log_audit_event("TEST_FAIL", job_id, target_repo, MODEL, {"test": test_path}, "FAILED")
|
| 559 |
+
|
| 560 |
+
result = process_failing_test(test_path, initial_output, pytest_bin, mcp_config_path, job_id, branch_name)
|
| 561 |
+
|
| 562 |
+
attempt_id = record_attempt(
|
| 563 |
+
tenant_id, target_repo, test_path, initial_output, MODEL,
|
| 564 |
+
hashlib.sha256(initial_output.encode()).hexdigest()[:16],
|
| 565 |
+
attempt_number=result.total_attempts or len(result.attempts),
|
| 566 |
+
diff_produced=result.final_diff,
|
| 567 |
+
test_passed_after=result.success,
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
if result.success:
|
| 571 |
+
pr_url = ""
|
| 572 |
+
try:
|
| 573 |
+
if push_fix_branch(branch_name):
|
| 574 |
+
pr_url = create_github_pr(target_repo, branch_name, test_path, get_github_token(target_repo))
|
| 575 |
+
except Exception as e:
|
| 576 |
+
ui_log(f"PR creation failed for {test_path}: {e}", "WARN")
|
| 577 |
+
|
| 578 |
+
update_test_result(attempt_id, True, pr_url)
|
| 579 |
+
upsert_job(tenant_id, target_repo, test_path, JobStatus.DONE,
|
| 580 |
+
f"healed in {result.total_attempts} attempt(s) — PR submitted",
|
| 581 |
+
pr_url=pr_url, model_version=MODEL)
|
| 582 |
+
if pr_url:
|
| 583 |
+
notify_pr_created(test_path, pr_url)
|
| 584 |
+
ui_log(f"PR submitted for {test_path} (healed in {result.total_attempts} attempt(s))", "PR")
|
| 585 |
+
log_audit_event("PR_SUBMITTED", job_id, target_repo, MODEL,
|
| 586 |
+
{"test": test_path, "attempts": result.total_attempts, "branch": branch_name, "pr_url": pr_url}, "SUCCESS")
|
| 587 |
+
run_subprocess_safe(["git", "checkout", "main"], cwd=REPO_DIR, raise_on_error=False)
|
| 588 |
+
safe_git_pull()
|
| 589 |
+
return {"success": True, "pr_url": pr_url}
|
| 590 |
+
|
| 591 |
+
update_test_result(attempt_id, False)
|
| 592 |
+
upsert_job(tenant_id, target_repo, test_path, JobStatus.FAILED, result.failure_reason)
|
| 593 |
+
notify_patch_failed(test_path)
|
| 594 |
+
ui_log(f"UNRESOLVED after {MAX_RETRIES} attempts: {result.failure_reason}", "FAIL")
|
| 595 |
+
log_audit_event("HEALING_EXHAUSTED", job_id, target_repo, MODEL,
|
| 596 |
+
{"test": test_path, "reason": result.failure_reason}, "FAILED")
|
| 597 |
+
run_subprocess_safe(["git", "checkout", "main"], cwd=REPO_DIR, raise_on_error=False)
|
| 598 |
+
safe_git_pull()
|
| 599 |
+
return {"success": False, "error": result.failure_reason}
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
def enterprise_audit_loop(repo_override: str = None, branch: str = "main", specific_test: str = None):
|
| 603 |
+
target_repo = repo_override or GITHUB_REPO
|
| 604 |
+
ui_log("═" * 70)
|
| 605 |
+
ui_log(f"AUDIT START — Tenant: {TENANT_ID} | Repo: {target_repo} | Model: {MODEL}")
|
| 606 |
+
notify_audit_start(target_repo)
|
| 607 |
+
log_audit_event("AUDIT_START", "orchestrator", target_repo, MODEL,
|
| 608 |
+
{"tenant": TENANT_ID, "branch": branch}, "STARTED")
|
| 609 |
+
|
| 610 |
+
try:
|
| 611 |
+
configure_git_credentials()
|
| 612 |
+
mcp_config_path = write_mcp_config()
|
| 613 |
+
|
| 614 |
+
if not os.path.exists(REPO_DIR):
|
| 615 |
+
ui_log("Cloning repository...")
|
| 616 |
+
Repo.clone_from(f"https://github.com/{target_repo}.git", REPO_DIR)
|
| 617 |
+
run_subprocess_safe(["git", "config", "user.name", "Rhodawk AI"], cwd=REPO_DIR)
|
| 618 |
+
run_subprocess_safe(["git", "config", "user.email", "agent@rhodawk.ai"], cwd=REPO_DIR)
|
| 619 |
+
else:
|
| 620 |
+
ui_log("Syncing to latest origin/main...")
|
| 621 |
+
safe_git_pull()
|
| 622 |
+
|
| 623 |
+
pytest_bin = setup_target_venv()
|
| 624 |
+
|
| 625 |
+
if specific_test:
|
| 626 |
+
test_files = [os.path.join(REPO_DIR, specific_test)] if os.path.exists(os.path.join(REPO_DIR, specific_test)) else []
|
| 627 |
+
else:
|
| 628 |
+
test_files = sorted(glob.glob(f"{REPO_DIR}/**/test_*.py", recursive=True))
|
| 629 |
+
|
| 630 |
+
ui_log(f"Discovered {len(test_files)} test file(s).")
|
| 631 |
+
|
| 632 |
+
relative_tests = [os.path.relpath(test_path, REPO_DIR) for test_path in test_files]
|
| 633 |
+
pool_result = run_parallel_audit(
|
| 634 |
+
relative_tests,
|
| 635 |
+
process_audit_test,
|
| 636 |
+
pytest_bin=pytest_bin,
|
| 637 |
+
mcp_config_path=mcp_config_path,
|
| 638 |
+
tenant_id=TENANT_ID,
|
| 639 |
+
target_repo=target_repo,
|
| 640 |
+
)
|
| 641 |
+
ui_log(
|
| 642 |
+
f"Worker pool complete — workers={MAX_WORKERS}, healed={pool_result['healed']}, "
|
| 643 |
+
f"failed={pool_result['failed']}, skipped={pool_result['skipped']}",
|
| 644 |
+
"POOL",
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
all_green = True
|
| 648 |
+
for relative_test in relative_tests:
|
| 649 |
+
status = get_job_status_enum(TENANT_ID, target_repo, relative_test)
|
| 650 |
+
if status != JobStatus.DONE:
|
| 651 |
+
all_green = False
|
| 652 |
+
break
|
| 653 |
+
|
| 654 |
+
if all_green and RED_TEAM_ENABLED and relative_tests:
|
| 655 |
+
ui_log("All tests GREEN — activating Red Team CEGIS.", "RED")
|
| 656 |
+
run_red_team_cegis(
|
| 657 |
+
repo_dir=REPO_DIR,
|
| 658 |
+
pytest_bin=pytest_bin,
|
| 659 |
+
mcp_config_path=mcp_config_path,
|
| 660 |
+
blue_team_fn=process_failing_test,
|
| 661 |
+
tenant_id=TENANT_ID,
|
| 662 |
+
log_audit_fn=log_audit_event,
|
| 663 |
+
notify_fn=notify,
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
except Exception as e:
|
| 667 |
+
ui_log(f"FATAL: {e}", "FAIL")
|
| 668 |
+
notify(f"🔴 *FATAL*\n`{e}`", "ERROR")
|
| 669 |
+
log_audit_event("AUDIT_CRASH", "orchestrator", target_repo, MODEL, {"error": str(e)}, "CRASHED")
|
| 670 |
+
return
|
| 671 |
+
finally:
|
| 672 |
+
_audit_event.clear()
|
| 673 |
+
|
| 674 |
+
final_metrics = get_metrics()
|
| 675 |
+
training_stats = get_statistics()
|
| 676 |
+
notify_audit_complete(final_metrics)
|
| 677 |
+
|
| 678 |
+
is_valid, integrity_msg = verify_chain_integrity()
|
| 679 |
+
notify_chain_integrity(is_valid, integrity_msg)
|
| 680 |
+
|
| 681 |
+
ui_log("═" * 70)
|
| 682 |
+
ui_log(f"AUDIT COMPLETE — Fix success rate: {training_stats['fix_success_rate']} | "
|
| 683 |
+
f"SAST blocks: {training_stats['sast_blocked']} | "
|
| 684 |
+
f"Adversarial rejects: {training_stats['adversarially_rejected']} | "
|
| 685 |
+
f"Patterns learned: {training_stats['patterns_learned']}")
|
| 686 |
+
log_audit_event("AUDIT_COMPLETE", "orchestrator", target_repo, MODEL,
|
| 687 |
+
{**final_metrics, **training_stats}, "COMPLETE")
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def trigger_audit_fn():
|
| 691 |
+
if _audit_event.is_set():
|
| 692 |
+
return "⚠️ Audit already running."
|
| 693 |
+
if not _audit_event.is_set():
|
| 694 |
+
_audit_event.set()
|
| 695 |
+
threading.Thread(target=enterprise_audit_loop, daemon=True).start()
|
| 696 |
+
return "🚀 Audit triggered — full healing loop deployed."
|
| 697 |
+
return "⚠️ Audit already running."
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
# ──────────────────────────────────────────────────────────────
|
| 701 |
+
# REGISTER WEBHOOK DISPATCHER
|
| 702 |
+
# ──────────────────────────────────────────────────────────────
|
| 703 |
+
def _webhook_dispatch(**kwargs):
|
| 704 |
+
if not _audit_event.is_set():
|
| 705 |
+
_audit_event.set()
|
| 706 |
+
threading.Thread(target=enterprise_audit_loop, kwargs=kwargs, daemon=True).start()
|
| 707 |
+
|
| 708 |
+
set_job_dispatcher(_webhook_dispatch)
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
# ──────────────────────────────────────────────────────────────
|
| 712 |
+
# DASHBOARD DATA GETTERS
|
| 713 |
+
# ──────────────────────────────────────────────────────────────
|
| 714 |
+
def get_live_logs() -> str:
|
| 715 |
+
with _log_lock:
|
| 716 |
+
return "\n".join(dashboard_logs[-100:])
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
def get_metrics_row():
|
| 720 |
+
m = get_metrics()
|
| 721 |
+
status = "🟡 Running..." if _audit_event.is_set() else "🟢 Idle / Secure"
|
| 722 |
+
return (status, m["total"], m["done"], m["prs_created"], m["failed"], m["sast_blocked"])
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
def get_job_table() -> list[list]:
|
| 726 |
+
jobs = list_all_jobs()[:30]
|
| 727 |
+
rows = []
|
| 728 |
+
for j in jobs:
|
| 729 |
+
icons = {"DONE": "✅", "FAILED": "❌", "SAST_BLOCKED": "🛡 Blocked",
|
| 730 |
+
"RUNNING": "🔄", "PENDING": "⏳"}
|
| 731 |
+
rows.append([j.get("test_path", ""), icons.get(j["status"], j["status"]),
|
| 732 |
+
j.get("pr_url", "—"), j.get("model_version", "—"), j.get("updated_at", "")])
|
| 733 |
+
return rows or [["No jobs yet", "", "", "", ""]]
|
| 734 |
+
|
| 735 |
+
|
| 736 |
+
def get_audit_display() -> str:
|
| 737 |
+
events = read_audit_trail(40)
|
| 738 |
+
if not events:
|
| 739 |
+
return "No audit events yet."
|
| 740 |
+
lines = []
|
| 741 |
+
for e in reversed(events):
|
| 742 |
+
lines.append(
|
| 743 |
+
f"[{e['timestamp_utc']}] {e['event_type']:25s} | {e['outcome']:10s} | "
|
| 744 |
+
f"hash:{e['entry_hash'][:12]}..."
|
| 745 |
+
)
|
| 746 |
+
return "\n".join(lines)
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def get_chain_integrity_display() -> str:
|
| 750 |
+
valid, msg = verify_chain_integrity()
|
| 751 |
+
return f"{'🔒 VERIFIED' if valid else '🚨 COMPROMISED'} — {msg}"
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
def get_training_stats_display() -> str:
|
| 755 |
+
try:
|
| 756 |
+
stats = get_statistics()
|
| 757 |
+
mem = get_memory_stats()
|
| 758 |
+
return (
|
| 759 |
+
f"Total fix attempts: {stats['total_attempts']}\n"
|
| 760 |
+
f"Successful fixes: {stats['successful_fixes']} ({stats['fix_success_rate']})\n"
|
| 761 |
+
f"SAST blocked: {stats['sast_blocked']}\n"
|
| 762 |
+
f"Adversarially rejected: {stats['adversarially_rejected']}\n"
|
| 763 |
+
f"Human-merged PRs: {stats['human_merged']}\n"
|
| 764 |
+
f"Memory patterns stored: {mem['patterns_stored']} ({mem['successful_patterns']} with success signal)\n\n"
|
| 765 |
+
f"Top recurring failures:\n" +
|
| 766 |
+
"\n".join(f" {t['attempts']}x {t['path']}" for t in stats.get("top_failing_tests", []))
|
| 767 |
+
)
|
| 768 |
+
except Exception as e:
|
| 769 |
+
return f"Stats unavailable: {e}"
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
def get_training_export() -> str:
|
| 773 |
+
try:
|
| 774 |
+
data = export_training_data(limit=100)
|
| 775 |
+
if not data:
|
| 776 |
+
return "No training data yet. Run audits to accumulate (failure, fix) pairs."
|
| 777 |
+
lines = data.split("\n")
|
| 778 |
+
return f"# {len(lines)} training examples (JSONL format)\n# Copy and use for fine-tuning\n\n" + data[:8000]
|
| 779 |
+
except Exception as e:
|
| 780 |
+
return f"Export failed: {e}"
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
def get_webhook_log_display() -> str:
|
| 784 |
+
from webhook_server import get_webhook_log
|
| 785 |
+
events = get_webhook_log(30)
|
| 786 |
+
if not events:
|
| 787 |
+
return f"No webhook events yet.\n\nWebhook endpoint: POST http://this-space:7861/webhook/github\nHealth check: GET http://this-space:7861/webhook/health"
|
| 788 |
+
lines = [f"[{e['timestamp']}] {e['event_type']:20s} | {e['status']:8s} | {e.get('repo','')} | {e.get('detail','')}" for e in events]
|
| 789 |
+
return "\n".join(lines)
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def get_red_team_display() -> str:
|
| 793 |
+
stats = get_red_team_stats()
|
| 794 |
+
return (
|
| 795 |
+
f"Zero-days discovered: {stats['zero_days_discovered']}\n"
|
| 796 |
+
f"Property-based tests generated: {stats['pbts_generated']}\n"
|
| 797 |
+
f"Artifacts directory: {stats['red_team_dir']}\n\n"
|
| 798 |
+
f"Recent Red Team logs:\n{stats['recent_logs']}"
|
| 799 |
+
)
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
def trigger_swebench_eval(max_instances: int = 25) -> str:
|
| 803 |
+
def _run():
|
| 804 |
+
try:
|
| 805 |
+
from swebench_harness import run_swebench_eval
|
| 806 |
+
result = run_swebench_eval(max_instances=int(max_instances))
|
| 807 |
+
ui_log(
|
| 808 |
+
f"SWE-bench complete — pass@1={result['pass_at_1']:.2%}, "
|
| 809 |
+
f"resolved={result['resolved']}/{result['total']}",
|
| 810 |
+
"BENCH",
|
| 811 |
+
)
|
| 812 |
+
except Exception as e:
|
| 813 |
+
ui_log(f"SWE-bench eval failed: {e}", "BENCH")
|
| 814 |
+
|
| 815 |
+
threading.Thread(target=_run, daemon=True).start()
|
| 816 |
+
return f"🧪 SWE-bench Verified evaluation started for {int(max_instances)} instance(s)."
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
def get_swebench_display() -> str:
|
| 820 |
+
path = "/data/swebench_report.md"
|
| 821 |
+
if not os.path.exists(path):
|
| 822 |
+
return "No SWE-bench report yet. Start an evaluation to generate pass@1 results."
|
| 823 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 824 |
+
return f.read()[:12000]
|
| 825 |
+
|
| 826 |
+
|
| 827 |
+
def export_compliance_display() -> str:
|
| 828 |
+
try:
|
| 829 |
+
return f"Compliance report exported: {export_compliance_report()}"
|
| 830 |
+
except Exception as e:
|
| 831 |
+
return f"Compliance export failed: {e}"
|
| 832 |
+
|
| 833 |
+
|
| 834 |
+
def reset_queue():
|
| 835 |
+
import shutil
|
| 836 |
+
shutil.rmtree("/data/jobs", ignore_errors=True)
|
| 837 |
+
return "✅ Job queue cleared."
|
| 838 |
+
|
| 839 |
+
|
| 840 |
+
# ──────────────────────────────────────────────────────────────
|
| 841 |
+
# GRADIO ENTERPRISE DASHBOARD
|
| 842 |
+
# ──────────────────────────────────────────────────────────────
|
| 843 |
+
THEME = gr.themes.Base(
|
| 844 |
+
primary_hue="violet", secondary_hue="slate", neutral_hue="slate",
|
| 845 |
+
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"],
|
| 846 |
+
).set(
|
| 847 |
+
body_background_fill="#0a0a0f",
|
| 848 |
+
body_text_color="#e2e8f0",
|
| 849 |
+
block_background_fill="#12121c",
|
| 850 |
+
block_border_color="#1e1e2e",
|
| 851 |
+
block_label_text_color="#64748b",
|
| 852 |
+
input_background_fill="#0a0a0f",
|
| 853 |
+
button_primary_background_fill="#5b21b6",
|
| 854 |
+
button_primary_background_fill_hover="#6d28d9",
|
| 855 |
+
button_primary_text_color="#ffffff",
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
with gr.Blocks(theme=THEME, title="Rhodawk AI — Code Review Monster") as demo:
|
| 859 |
+
|
| 860 |
+
gr.HTML("""
|
| 861 |
+
<div style="padding:20px 0 4px 0; border-bottom:1px solid #1e1e2e; margin-bottom:16px;">
|
| 862 |
+
<div style="display:flex; align-items:center; gap:14px;">
|
| 863 |
+
<span style="font-size:2.2rem;">🦅</span>
|
| 864 |
+
<div>
|
| 865 |
+
<h1 style="margin:0; font-size:1.5rem; font-weight:800; color:#f1f5f9; letter-spacing:-0.03em;">
|
| 866 |
+
Rhodawk AI <span style="color:#7c3aed; font-size:0.9rem; font-weight:600; margin-left:8px;">v3.0</span>
|
| 867 |
+
</h1>
|
| 868 |
+
<p style="margin:2px 0 0; font-size:0.8rem; color:#475569; letter-spacing:0.02em;">
|
| 869 |
+
AUTONOMOUS DEVSECOPS CONTROL PLANE ·
|
| 870 |
+
CLOSED VERIFICATION LOOP ·
|
| 871 |
+
ADVERSARIAL LLM REVIEW ·
|
| 872 |
+
DATA FLYWHEEL ·
|
| 873 |
+
SUPPLY CHAIN GATE
|
| 874 |
+
</p>
|
| 875 |
+
</div>
|
| 876 |
+
</div>
|
| 877 |
+
</div>
|
| 878 |
+
""")
|
| 879 |
+
|
| 880 |
+
with gr.Tabs():
|
| 881 |
+
|
| 882 |
+
# ── TAB 1: LIVE OPERATIONS ──────────────────────────────
|
| 883 |
+
with gr.Tab("⚡ Live Operations"):
|
| 884 |
+
with gr.Row():
|
| 885 |
+
stat_status = gr.Textbox(label="System Status", interactive=False, scale=3)
|
| 886 |
+
stat_total = gr.Number(label="Tests Scanned", interactive=False)
|
| 887 |
+
stat_done = gr.Number(label="Verified Green", interactive=False)
|
| 888 |
+
stat_prs = gr.Number(label="PRs Generated", interactive=False)
|
| 889 |
+
stat_failed = gr.Number(label="Failed", interactive=False)
|
| 890 |
+
stat_sast = gr.Number(label="SAST Blocked", interactive=False)
|
| 891 |
+
|
| 892 |
+
with gr.Row():
|
| 893 |
+
btn_audit = gr.Button("🚀 Trigger Full Healing Audit", variant="primary", scale=3)
|
| 894 |
+
btn_reset = gr.Button("🗑 Reset Queue", variant="secondary", scale=1)
|
| 895 |
+
|
| 896 |
+
trigger_out = gr.Textbox(label="", interactive=False, show_label=False)
|
| 897 |
+
live_logs = gr.TextArea(label="Live Agent Execution Log", lines=26, interactive=False)
|
| 898 |
+
|
| 899 |
+
btn_audit.click(trigger_audit_fn, outputs=trigger_out)
|
| 900 |
+
btn_reset.click(reset_queue, outputs=trigger_out)
|
| 901 |
+
|
| 902 |
+
# ── TAB 2: JOB QUEUE ───────────────────────────────────
|
| 903 |
+
with gr.Tab("📋 Job Queue"):
|
| 904 |
+
gr.Markdown("### Namespaced job store — per (tenant, repo, test)")
|
| 905 |
+
job_table = gr.Dataframe(
|
| 906 |
+
headers=["Test Path", "Status", "PR URL", "Model", "Updated At"],
|
| 907 |
+
datatype=["str", "str", "str", "str", "str"], interactive=False, wrap=True,
|
| 908 |
+
)
|
| 909 |
+
gr.Button("🔄 Refresh", variant="secondary").click(get_job_table, outputs=job_table)
|
| 910 |
+
|
| 911 |
+
# ── TAB 3: AUDIT TRAIL ─────────────────────────────────
|
| 912 |
+
with gr.Tab("🔒 Audit Trail"):
|
| 913 |
+
gr.Markdown(
|
| 914 |
+
"### SHA-256 chained audit log — every AI action is cryptographically linked\n"
|
| 915 |
+
"Covers: dispatch → SAST scan → supply chain → adversarial review → PR submission"
|
| 916 |
+
)
|
| 917 |
+
chain_status = gr.Textbox(label="Chain Integrity Status", interactive=False)
|
| 918 |
+
audit_log = gr.TextArea(label="Events (latest first)", lines=20, interactive=False)
|
| 919 |
+
gr.Button("🔍 Verify Chain", variant="secondary").click(
|
| 920 |
+
fn=lambda: (get_chain_integrity_display(), get_audit_display()),
|
| 921 |
+
outputs=[chain_status, audit_log]
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
# ── TAB 4: TRAINING DATA & FLYWHEEL ───────────────────
|
| 925 |
+
with gr.Tab("🧠 Data Flywheel"):
|
| 926 |
+
gr.Markdown(
|
| 927 |
+
"### Proprietary training data pipeline\n"
|
| 928 |
+
"Every `(failure, fix, adversarial_verdict, test_result)` tuple is stored. "
|
| 929 |
+
"This is the compounding advantage — the system gets smarter with every run. "
|
| 930 |
+
"Export as HuggingFace-compatible JSONL for model fine-tuning."
|
| 931 |
+
)
|
| 932 |
+
stats_display = gr.TextArea(label="Flywheel Statistics", lines=12, interactive=False)
|
| 933 |
+
training_export = gr.TextArea(label="Training Data Export (JSONL)", lines=14, interactive=False)
|
| 934 |
+
|
| 935 |
+
with gr.Row():
|
| 936 |
+
gr.Button("📊 Refresh Stats", variant="secondary").click(get_training_stats_display, outputs=stats_display)
|
| 937 |
+
gr.Button("⬇ Export JSONL", variant="secondary").click(get_training_export, outputs=training_export)
|
| 938 |
+
|
| 939 |
+
# ── TAB 5: WEBHOOKS ────────────────────────────────────
|
| 940 |
+
with gr.Tab("🔗 Webhooks"):
|
| 941 |
+
gr.Markdown(f"""
|
| 942 |
+
### Event-Driven Trigger Server (Port 7861)
|
| 943 |
+
|
| 944 |
+
Rhodawk accepts real-time events from GitHub, CI systems, and any HTTP client.
|
| 945 |
+
Configure your GitHub repo to send webhooks here and every push/failure triggers an autonomous healing job.
|
| 946 |
+
|
| 947 |
+
**Endpoints:**
|
| 948 |
+
```
|
| 949 |
+
POST /webhook/github — GitHub push/check_run events (HMAC-SHA256 validated)
|
| 950 |
+
POST /webhook/ci — Generic CI failure: {{"repo": "owner/repo", "test_path": "tests/..."}}
|
| 951 |
+
POST /webhook/trigger — Manual trigger
|
| 952 |
+
GET /webhook/health — Liveness probe
|
| 953 |
+
GET /webhook/queue — Current job status (JSON)
|
| 954 |
+
```
|
| 955 |
+
|
| 956 |
+
**GitHub Setup:**
|
| 957 |
+
1. Go to your repo → Settings → Webhooks → Add webhook
|
| 958 |
+
2. URL: `https://your-space.hf.space:7861/webhook/github`
|
| 959 |
+
3. Secret: set `RHODAWK_WEBHOOK_SECRET` in Space secrets
|
| 960 |
+
4. Events: push, check_run, status
|
| 961 |
+
""")
|
| 962 |
+
webhook_log = gr.TextArea(label="Webhook Event Log", lines=15, interactive=False)
|
| 963 |
+
gr.Button("🔄 Refresh", variant="secondary").click(get_webhook_log_display, outputs=webhook_log)
|
| 964 |
+
|
| 965 |
+
with gr.Tab("⚔️ Red Team"):
|
| 966 |
+
gr.Markdown(
|
| 967 |
+
"### Autonomous Red Team CEGIS\n"
|
| 968 |
+
"When all tests are green, Rhodawk attacks the repo with generated property-based tests "
|
| 969 |
+
"and hands reproducible zero-days back to Blue Team for patching."
|
| 970 |
+
)
|
| 971 |
+
red_team_box = gr.TextArea(label="Red Team Stats & Logs", lines=22, interactive=False)
|
| 972 |
+
gr.Button("🔄 Refresh Red Team Stats", variant="secondary").click(get_red_team_display, outputs=red_team_box)
|
| 973 |
+
|
| 974 |
+
with gr.Tab("🧪 SWE-bench"):
|
| 975 |
+
gr.Markdown("### SWE-bench Verified Evaluation")
|
| 976 |
+
with gr.Row():
|
| 977 |
+
swebench_count = gr.Number(label="Max instances", value=25, precision=0)
|
| 978 |
+
swebench_start = gr.Button("Start Evaluation", variant="primary")
|
| 979 |
+
swebench_refresh = gr.Button("Refresh Report", variant="secondary")
|
| 980 |
+
swebench_status = gr.Textbox(label="Status", interactive=False)
|
| 981 |
+
swebench_report = gr.TextArea(label="SWE-bench Report", lines=24, interactive=False)
|
| 982 |
+
swebench_start.click(trigger_swebench_eval, inputs=swebench_count, outputs=swebench_status)
|
| 983 |
+
swebench_refresh.click(get_swebench_display, outputs=swebench_report)
|
| 984 |
+
|
| 985 |
+
# ── TAB 8: SYSTEM / ARCHITECTURE ──────────────────────
|
| 986 |
+
with gr.Tab("ℹ️ Architecture"):
|
| 987 |
+
compliance_out = gr.Textbox(label="Compliance Export", interactive=False)
|
| 988 |
+
gr.Button("Export SOC 2 Evidence Summary", variant="secondary").click(
|
| 989 |
+
export_compliance_display, outputs=compliance_out
|
| 990 |
+
)
|
| 991 |
+
gr.Markdown(f"""
|
| 992 |
+
### Rhodawk AI v4.0 — Capability Stack
|
| 993 |
+
|
| 994 |
+
**Tenant:** `{TENANT_ID}` | **Target:** `{GITHUB_REPO}` | **Model:** `{MODEL}`
|
| 995 |
+
|
| 996 |
+
---
|
| 997 |
+
|
| 998 |
+
| Layer | Technology | What it does |
|
| 999 |
+
|---|---|---|
|
| 1000 |
+
| AI Agent | Aider + OpenRouter/Qwen | Autonomous patch generation |
|
| 1001 |
+
| MCP Tools | fetch-docs, github-manager | Documentation + PR creation |
|
| 1002 |
+
| **Verification Loop** | pytest re-run per attempt | **Closes the loop — tests the fix before PR** |
|
| 1003 |
+
| **Adversarial Review** | Second LLM (red team) | **Every diff reviewed by a hostile model** |
|
| 1004 |
+
| **Memory Engine** | Embeddings + SQLite | **Cross-repo semantic retrieval of similar fixes** |
|
| 1005 |
+
| **Supply Chain Gate** | pip-audit + typosquatting + PyPI metadata | **Catches malicious dependencies in AI diffs** |
|
| 1006 |
+
| SAST Gate | bandit + semgrep + secret/injection patterns | Pre-PR security scanning |
|
| 1007 |
+
| Audit Trail | SHA-256 JSONL chain | SOC 2 / ISO 27001 evidence |
|
| 1008 |
+
| Training Store | SQLite (failure→fix→outcome) | Fine-tuning dataset accumulation |
|
| 1009 |
+
| Webhook Server | HTTP on :7861 + HMAC + rate limit | Event-driven GitHub/CI triggers |
|
| 1010 |
+
| Worker Pool | ThreadPoolExecutor | Parallel audit execution |
|
| 1011 |
+
| SWE-bench Harness | SWE-bench Verified | pass@1 benchmarking reports |
|
| 1012 |
+
| Notifications | Telegram + Slack | Multi-channel alerting |
|
| 1013 |
+
| Virtualenv | uv | Blazing-fast isolated Python env |
|
| 1014 |
+
|
| 1015 |
+
---
|
| 1016 |
+
|
| 1017 |
+
### What no competitor has (combined)
|
| 1018 |
+
|
| 1019 |
+
1. **Closed verification loop** — generates fix, re-runs tests, retries with new context up to {MAX_RETRIES}x
|
| 1020 |
+
2. **Adversarial LLM review** — autonomous red-team pass on every AI-generated diff before PR
|
| 1021 |
+
3. **Fix memory flywheel** — TF-IDF retrieval of similar past fixes injected as few-shot examples
|
| 1022 |
+
4. **Supply chain attack detection** — typosquatting + CVE scan on AI-added packages
|
| 1023 |
+
5. **Event-driven webhook triggers** — real-time CI/CD participant, not a manual tool
|
| 1024 |
+
6. **Structured training data pipeline** — every run accumulates fine-tuning signal
|
| 1025 |
+
7. **Red Team CEGIS** — all-green repos are attacked, fuzzed, and patched autonomously
|
| 1026 |
+
|
| 1027 |
+
---
|
| 1028 |
+
|
| 1029 |
+
### Roadmap
|
| 1030 |
+
- Firecracker microVMs for per-job execution isolation
|
| 1031 |
+
- GitHub App rollout across all enterprise tenants
|
| 1032 |
+
- Multi-model consensus (3 models, pick majority agreement)
|
| 1033 |
+
- Fine-tuned model trained on proprietary failure→fix dataset
|
| 1034 |
+
- Distributed job queue (Postgres + worker pool)
|
| 1035 |
+
""")
|
| 1036 |
+
|
| 1037 |
+
# ── AUTO-REFRESH ───────────────────���────────────────────────
|
| 1038 |
+
timer = gr.Timer(3)
|
| 1039 |
+
timer.tick(get_live_logs, outputs=live_logs)
|
| 1040 |
+
timer.tick(get_metrics_row, outputs=[stat_status, stat_total, stat_done, stat_prs, stat_failed, stat_sast])
|
| 1041 |
+
|
| 1042 |
+
demo.load(get_live_logs, outputs=live_logs)
|
| 1043 |
+
demo.load(get_metrics_row, outputs=[stat_status, stat_total, stat_done, stat_prs, stat_failed, stat_sast])
|
| 1044 |
+
demo.load(get_job_table, outputs=job_table)
|
| 1045 |
+
demo.load(get_audit_display, outputs=audit_log)
|
| 1046 |
+
demo.load(get_chain_integrity_display, outputs=chain_status)
|
| 1047 |
+
demo.load(get_training_stats_display, outputs=stats_display)
|
| 1048 |
+
demo.load(get_webhook_log_display, outputs=webhook_log)
|
| 1049 |
+
demo.load(get_red_team_display, outputs=red_team_box)
|
| 1050 |
+
demo.load(get_swebench_display, outputs=swebench_report)
|
| 1051 |
+
|
| 1052 |
+
|
| 1053 |
+
if __name__ == "__main__":
|
| 1054 |
+
ui_log(f"Rhodawk AI v3.0 starting — Tenant: {TENANT_ID} | Model: {MODEL}")
|
| 1055 |
+
ui_log("Starting webhook server on port 7861...")
|
| 1056 |
+
start_webhook_server()
|
| 1057 |
+
ui_log("Webhook server running. Launching dashboard...")
|
| 1058 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, share=False, show_error=True)
|
audit_logger.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Immutable Audit Trail Engine
|
| 3 |
+
==========================================
|
| 4 |
+
Every AI action is appended to an append-only JSONL file with SHA-256 chaining.
|
| 5 |
+
Each entry references the hash of the previous entry, creating a tamper-evident
|
| 6 |
+
chain of custody for every line of AI-generated code. Required for SOC 2 / ISO 27001.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
AUDIT_LOG_PATH = "/data/audit_trail.jsonl"
|
| 17 |
+
_audit_write_lock = threading.Lock()
|
| 18 |
+
|
| 19 |
+
_last_hash: Optional[str] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _compute_hash(entry: dict) -> str:
|
| 23 |
+
canonical = json.dumps(entry, sort_keys=True, separators=(",", ":"))
|
| 24 |
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _get_last_hash() -> str:
|
| 28 |
+
global _last_hash
|
| 29 |
+
if _last_hash:
|
| 30 |
+
return _last_hash
|
| 31 |
+
if not os.path.exists(AUDIT_LOG_PATH):
|
| 32 |
+
return "GENESIS"
|
| 33 |
+
try:
|
| 34 |
+
with open(AUDIT_LOG_PATH, "rb") as f:
|
| 35 |
+
lines = f.read().splitlines()
|
| 36 |
+
if not lines:
|
| 37 |
+
return "GENESIS"
|
| 38 |
+
last_line = lines[-1].decode("utf-8").strip()
|
| 39 |
+
if not last_line:
|
| 40 |
+
return "GENESIS"
|
| 41 |
+
last_entry = json.loads(last_line)
|
| 42 |
+
_last_hash = last_entry.get("entry_hash", "GENESIS")
|
| 43 |
+
return _last_hash
|
| 44 |
+
except Exception:
|
| 45 |
+
return "GENESIS"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def log_audit_event(
|
| 49 |
+
event_type: str,
|
| 50 |
+
job_id: str,
|
| 51 |
+
repo: str,
|
| 52 |
+
model: str,
|
| 53 |
+
details: dict,
|
| 54 |
+
outcome: str = "PENDING",
|
| 55 |
+
) -> str:
|
| 56 |
+
"""
|
| 57 |
+
Append an audit event to the immutable JSONL chain.
|
| 58 |
+
Returns the entry hash for cross-referencing.
|
| 59 |
+
"""
|
| 60 |
+
global _last_hash
|
| 61 |
+
|
| 62 |
+
with _audit_write_lock:
|
| 63 |
+
prev_hash = _get_last_hash()
|
| 64 |
+
|
| 65 |
+
entry = {
|
| 66 |
+
"schema_version": "1.0",
|
| 67 |
+
"timestamp_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 68 |
+
"unix_ts": time.time(),
|
| 69 |
+
"event_type": event_type,
|
| 70 |
+
"job_id": job_id,
|
| 71 |
+
"repo": repo,
|
| 72 |
+
"model_version": model,
|
| 73 |
+
"outcome": outcome,
|
| 74 |
+
"details": details,
|
| 75 |
+
"prev_hash": prev_hash,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
entry_hash = _compute_hash(entry)
|
| 79 |
+
entry["entry_hash"] = entry_hash
|
| 80 |
+
|
| 81 |
+
os.makedirs(os.path.dirname(AUDIT_LOG_PATH), exist_ok=True)
|
| 82 |
+
with open(AUDIT_LOG_PATH, "a") as f:
|
| 83 |
+
f.write(json.dumps(entry) + "\n")
|
| 84 |
+
|
| 85 |
+
_last_hash = entry_hash
|
| 86 |
+
return entry_hash
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def read_audit_trail(limit: int = 50) -> list[dict]:
|
| 90 |
+
"""Return the last N audit events for dashboard display."""
|
| 91 |
+
if not os.path.exists(AUDIT_LOG_PATH):
|
| 92 |
+
return []
|
| 93 |
+
events = []
|
| 94 |
+
try:
|
| 95 |
+
with open(AUDIT_LOG_PATH, "r") as f:
|
| 96 |
+
for line in f:
|
| 97 |
+
line = line.strip()
|
| 98 |
+
if line:
|
| 99 |
+
try:
|
| 100 |
+
events.append(json.loads(line))
|
| 101 |
+
except json.JSONDecodeError:
|
| 102 |
+
pass
|
| 103 |
+
except OSError:
|
| 104 |
+
return []
|
| 105 |
+
return events[-limit:]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def verify_chain_integrity() -> tuple[bool, str]:
|
| 109 |
+
"""
|
| 110 |
+
Walk the entire audit chain and verify each entry's hash.
|
| 111 |
+
Returns (is_valid, summary_message).
|
| 112 |
+
Used for compliance attestation.
|
| 113 |
+
"""
|
| 114 |
+
if not os.path.exists(AUDIT_LOG_PATH):
|
| 115 |
+
return True, "No audit log yet — chain is clean."
|
| 116 |
+
|
| 117 |
+
events = []
|
| 118 |
+
with open(AUDIT_LOG_PATH, "r") as f:
|
| 119 |
+
for line in f:
|
| 120 |
+
line = line.strip()
|
| 121 |
+
if line:
|
| 122 |
+
events.append(json.loads(line))
|
| 123 |
+
|
| 124 |
+
if not events:
|
| 125 |
+
return True, "Empty log — chain is clean."
|
| 126 |
+
|
| 127 |
+
for i, entry in enumerate(events):
|
| 128 |
+
stored_hash = entry.pop("entry_hash", None)
|
| 129 |
+
computed = _compute_hash(entry)
|
| 130 |
+
entry["entry_hash"] = stored_hash
|
| 131 |
+
|
| 132 |
+
if computed != stored_hash:
|
| 133 |
+
return False, f"CHAIN BROKEN at entry {i} (event: {entry.get('event_type')}). Possible tampering detected."
|
| 134 |
+
|
| 135 |
+
if i > 0:
|
| 136 |
+
expected_prev = events[i - 1]["entry_hash"]
|
| 137 |
+
if entry["prev_hash"] != expected_prev:
|
| 138 |
+
return False, f"HASH CHAIN BROKEN between entries {i-1} and {i}."
|
| 139 |
+
|
| 140 |
+
return True, f"Chain VERIFIED — {len(events)} entries, all hashes valid."
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def export_compliance_report(output_path: str = "/data/rhodawk_soc2_audit_summary.md") -> str:
|
| 144 |
+
events = read_audit_trail(limit=100000)
|
| 145 |
+
valid, integrity_msg = verify_chain_integrity()
|
| 146 |
+
by_type: dict[str, int] = {}
|
| 147 |
+
by_outcome: dict[str, int] = {}
|
| 148 |
+
repos: dict[str, int] = {}
|
| 149 |
+
for event in events:
|
| 150 |
+
by_type[event.get("event_type", "UNKNOWN")] = by_type.get(event.get("event_type", "UNKNOWN"), 0) + 1
|
| 151 |
+
by_outcome[event.get("outcome", "UNKNOWN")] = by_outcome.get(event.get("outcome", "UNKNOWN"), 0) + 1
|
| 152 |
+
repos[event.get("repo", "unknown")] = repos.get(event.get("repo", "unknown"), 0) + 1
|
| 153 |
+
|
| 154 |
+
report = [
|
| 155 |
+
"# Rhodawk AI SOC 2 Audit Evidence Summary",
|
| 156 |
+
"",
|
| 157 |
+
f"Generated: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
|
| 158 |
+
f"Chain integrity: {'VERIFIED' if valid else 'COMPROMISED'}",
|
| 159 |
+
f"Integrity detail: {integrity_msg}",
|
| 160 |
+
f"Total audit events: {len(events)}",
|
| 161 |
+
"",
|
| 162 |
+
"## Event Types",
|
| 163 |
+
"",
|
| 164 |
+
*[f"- {name}: {count}" for name, count in sorted(by_type.items())],
|
| 165 |
+
"",
|
| 166 |
+
"## Outcomes",
|
| 167 |
+
"",
|
| 168 |
+
*[f"- {name}: {count}" for name, count in sorted(by_outcome.items())],
|
| 169 |
+
"",
|
| 170 |
+
"## Repository Coverage",
|
| 171 |
+
"",
|
| 172 |
+
*[f"- {name}: {count} event(s)" for name, count in sorted(repos.items())],
|
| 173 |
+
"",
|
| 174 |
+
"## Latest Evidence Entries",
|
| 175 |
+
"",
|
| 176 |
+
]
|
| 177 |
+
for event in events[-25:]:
|
| 178 |
+
report.append(
|
| 179 |
+
f"- `{event.get('timestamp_utc')}` `{event.get('event_type')}` "
|
| 180 |
+
f"`{event.get('outcome')}` hash `{event.get('entry_hash', '')[:16]}`"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
| 184 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 185 |
+
f.write("\n".join(report))
|
| 186 |
+
return output_path
|
embedding_memory.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Embedding-Based Memory Engine v2
|
| 3 |
+
==============================================
|
| 4 |
+
Cross-repository semantic retrieval using sentence-transformers embeddings.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import sqlite3
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from training_store import DB_PATH
|
| 15 |
+
|
| 16 |
+
EMBEDDING_DB_PATH = os.getenv("RHODAWK_EMBEDDING_DB", "/data/embedding_memory.db")
|
| 17 |
+
MODEL_NAME = os.getenv("RHODAWK_EMBEDDING_MODEL", "all-MiniLM-L6-v2")
|
| 18 |
+
_MODEL = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_model():
|
| 22 |
+
global _MODEL
|
| 23 |
+
if _MODEL is None:
|
| 24 |
+
from sentence_transformers import SentenceTransformer
|
| 25 |
+
_MODEL = SentenceTransformer(MODEL_NAME)
|
| 26 |
+
return _MODEL
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _normalize_failure(failure_output: str) -> str:
|
| 30 |
+
text = re.sub(r'File "[^"]+", line \d+', "File <path>, line <n>", failure_output)
|
| 31 |
+
text = re.sub(r"/[\w./-]+", "<path>", text)
|
| 32 |
+
text = re.sub(r"\b\d+\b", "<num>", text)
|
| 33 |
+
return text[:4000]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def embed_failure(failure_output: str) -> np.ndarray:
|
| 37 |
+
normalized = _normalize_failure(failure_output)
|
| 38 |
+
return _get_model().encode(normalized, normalize_embeddings=True)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _ensure_schema() -> None:
|
| 42 |
+
os.makedirs(os.path.dirname(EMBEDDING_DB_PATH), exist_ok=True)
|
| 43 |
+
with sqlite3.connect(EMBEDDING_DB_PATH) as conn:
|
| 44 |
+
conn.execute("""
|
| 45 |
+
CREATE TABLE IF NOT EXISTS fix_embeddings (
|
| 46 |
+
failure_signature TEXT PRIMARY KEY,
|
| 47 |
+
embedding BLOB NOT NULL,
|
| 48 |
+
fix_diff TEXT NOT NULL,
|
| 49 |
+
success_rate TEXT NOT NULL,
|
| 50 |
+
sample_failure TEXT NOT NULL,
|
| 51 |
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
| 52 |
+
)
|
| 53 |
+
""")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def rebuild_embedding_index(limit: int = 1000) -> int:
|
| 57 |
+
_ensure_schema()
|
| 58 |
+
with sqlite3.connect(DB_PATH) as source:
|
| 59 |
+
source.row_factory = sqlite3.Row
|
| 60 |
+
rows = source.execute("""
|
| 61 |
+
SELECT fp.failure_signature, fp.fix_diff, fp.success_count, fp.attempt_count,
|
| 62 |
+
fa.failure_output as sample_failure
|
| 63 |
+
FROM fix_patterns fp
|
| 64 |
+
LEFT JOIN fix_attempts fa ON fa.failure_signature = fp.failure_signature
|
| 65 |
+
AND fa.success_signal = 1
|
| 66 |
+
WHERE fp.success_count > 0
|
| 67 |
+
ORDER BY fp.success_count DESC
|
| 68 |
+
LIMIT ?
|
| 69 |
+
""", (limit,)).fetchall()
|
| 70 |
+
|
| 71 |
+
with sqlite3.connect(EMBEDDING_DB_PATH) as target:
|
| 72 |
+
for row in rows:
|
| 73 |
+
sample = row["sample_failure"] or row["failure_signature"]
|
| 74 |
+
emb = embed_failure(sample).astype(np.float32).tobytes()
|
| 75 |
+
attempts = row["attempt_count"] or 1
|
| 76 |
+
success_rate = f"{(row['success_count'] / attempts * 100):.0f}%"
|
| 77 |
+
target.execute("""
|
| 78 |
+
INSERT INTO fix_embeddings (failure_signature, embedding, fix_diff, success_rate, sample_failure)
|
| 79 |
+
VALUES (?, ?, ?, ?, ?)
|
| 80 |
+
ON CONFLICT(failure_signature) DO UPDATE SET
|
| 81 |
+
embedding=excluded.embedding,
|
| 82 |
+
fix_diff=excluded.fix_diff,
|
| 83 |
+
success_rate=excluded.success_rate,
|
| 84 |
+
sample_failure=excluded.sample_failure,
|
| 85 |
+
updated_at=CURRENT_TIMESTAMP
|
| 86 |
+
""", (row["failure_signature"], emb, row["fix_diff"], success_rate, sample))
|
| 87 |
+
return len(rows)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def retrieve_similar_fixes_v2(
|
| 91 |
+
failure_output: str,
|
| 92 |
+
top_k: int = 5,
|
| 93 |
+
min_similarity: float = 0.75,
|
| 94 |
+
) -> list[dict]:
|
| 95 |
+
_ensure_schema()
|
| 96 |
+
query_vec = embed_failure(failure_output).astype(np.float32)
|
| 97 |
+
with sqlite3.connect(EMBEDDING_DB_PATH) as conn:
|
| 98 |
+
conn.row_factory = sqlite3.Row
|
| 99 |
+
rows = conn.execute("SELECT failure_signature, embedding, fix_diff, success_rate FROM fix_embeddings").fetchall()
|
| 100 |
+
|
| 101 |
+
results = []
|
| 102 |
+
for row in rows:
|
| 103 |
+
vec = np.frombuffer(row["embedding"], dtype=np.float32)
|
| 104 |
+
if vec.size != query_vec.size:
|
| 105 |
+
continue
|
| 106 |
+
sim = float(np.dot(query_vec, vec))
|
| 107 |
+
if sim >= min_similarity:
|
| 108 |
+
results.append({
|
| 109 |
+
"failure_signature": row["failure_signature"],
|
| 110 |
+
"fix_diff": row["fix_diff"],
|
| 111 |
+
"success_rate": row["success_rate"],
|
| 112 |
+
"similarity": round(sim, 3),
|
| 113 |
+
})
|
| 114 |
+
results.sort(key=lambda item: item["similarity"], reverse=True)
|
| 115 |
+
return results[:top_k]
|
github_app.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — GitHub App Authentication
|
| 3 |
+
=======================================
|
| 4 |
+
Short-lived installation tokens for enterprise multi-repo access.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
import jwt
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_installation_token(repo: str) -> str:
|
| 15 |
+
app_id = os.getenv("RHODAWK_APP_ID")
|
| 16 |
+
private_key = os.getenv("RHODAWK_APP_PRIVATE_KEY", "").replace("\\n", "\n")
|
| 17 |
+
if not app_id or not private_key:
|
| 18 |
+
raise EnvironmentError("RHODAWK_APP_ID and RHODAWK_APP_PRIVATE_KEY are required")
|
| 19 |
+
|
| 20 |
+
now = int(time.time())
|
| 21 |
+
payload = {"iat": now - 60, "exp": now + 600, "iss": app_id}
|
| 22 |
+
jwt_token = jwt.encode(payload, private_key, algorithm="RS256")
|
| 23 |
+
headers = {
|
| 24 |
+
"Authorization": f"Bearer {jwt_token}",
|
| 25 |
+
"Accept": "application/vnd.github+json",
|
| 26 |
+
"X-GitHub-API-Version": "2022-11-28",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
owner, repo_name = repo.split("/", 1)
|
| 30 |
+
resp = requests.get(
|
| 31 |
+
f"https://api.github.com/repos/{owner}/{repo_name}/installation",
|
| 32 |
+
headers=headers,
|
| 33 |
+
timeout=15,
|
| 34 |
+
)
|
| 35 |
+
resp.raise_for_status()
|
| 36 |
+
installation_id = resp.json()["id"]
|
| 37 |
+
|
| 38 |
+
resp = requests.post(
|
| 39 |
+
f"https://api.github.com/app/installations/{installation_id}/access_tokens",
|
| 40 |
+
headers=headers,
|
| 41 |
+
timeout=15,
|
| 42 |
+
)
|
| 43 |
+
resp.raise_for_status()
|
| 44 |
+
return resp.json()["token"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_github_token(repo: str) -> str:
|
| 48 |
+
if os.getenv("RHODAWK_APP_ID") and os.getenv("RHODAWK_APP_PRIVATE_KEY"):
|
| 49 |
+
return get_installation_token(repo)
|
| 50 |
+
token = os.getenv("GITHUB_TOKEN", "")
|
| 51 |
+
if not token:
|
| 52 |
+
raise EnvironmentError("No GitHub App credentials or GITHUB_TOKEN configured")
|
| 53 |
+
return token
|
job_queue.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Namespaced Job Queue
|
| 3 |
+
===================================
|
| 4 |
+
Replaces the flat single-tenant STATE_FILE with a proper namespaced job store.
|
| 5 |
+
Each job is keyed by (tenant_id, repo, test_path) — ready for multi-tenant SaaS.
|
| 6 |
+
State is persisted as atomic JSON writes. Future path: swap backing store to PostgreSQL.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
from enum import Enum
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
QUEUE_DIR = "/data/jobs"
|
| 18 |
+
_queue_lock = threading.Lock()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class JobStatus(Enum):
|
| 22 |
+
PENDING = "PENDING"
|
| 23 |
+
RUNNING = "RUNNING"
|
| 24 |
+
SAST_BLOCKED = "SAST_BLOCKED"
|
| 25 |
+
DONE = "DONE"
|
| 26 |
+
FAILED = "FAILED"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _job_id(tenant_id: str, repo: str, test_path: str) -> str:
|
| 30 |
+
raw = f"{tenant_id}::{repo}::{test_path}"
|
| 31 |
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _job_path(job_id: str) -> str:
|
| 35 |
+
os.makedirs(QUEUE_DIR, exist_ok=True)
|
| 36 |
+
return os.path.join(QUEUE_DIR, f"{job_id}.json")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def upsert_job(
|
| 40 |
+
tenant_id: str,
|
| 41 |
+
repo: str,
|
| 42 |
+
test_path: str,
|
| 43 |
+
status: JobStatus,
|
| 44 |
+
detail: str = "",
|
| 45 |
+
pr_url: Optional[str] = None,
|
| 46 |
+
sast_findings: Optional[list] = None,
|
| 47 |
+
model_version: Optional[str] = None,
|
| 48 |
+
prompt_hash: Optional[str] = None,
|
| 49 |
+
) -> str:
|
| 50 |
+
job_id = _job_id(tenant_id, repo, test_path)
|
| 51 |
+
path = _job_path(job_id)
|
| 52 |
+
|
| 53 |
+
with _queue_lock:
|
| 54 |
+
existing = {}
|
| 55 |
+
if os.path.exists(path):
|
| 56 |
+
try:
|
| 57 |
+
with open(path) as f:
|
| 58 |
+
existing = json.load(f)
|
| 59 |
+
except Exception:
|
| 60 |
+
existing = {}
|
| 61 |
+
|
| 62 |
+
job = {
|
| 63 |
+
**existing,
|
| 64 |
+
"job_id": job_id,
|
| 65 |
+
"tenant_id": tenant_id,
|
| 66 |
+
"repo": repo,
|
| 67 |
+
"test_path": test_path,
|
| 68 |
+
"status": status.value,
|
| 69 |
+
"detail": detail,
|
| 70 |
+
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
if "created_at" not in job:
|
| 74 |
+
job["created_at"] = job["updated_at"]
|
| 75 |
+
if pr_url is not None:
|
| 76 |
+
job["pr_url"] = pr_url
|
| 77 |
+
if sast_findings is not None:
|
| 78 |
+
job["sast_findings_count"] = len(sast_findings)
|
| 79 |
+
if model_version is not None:
|
| 80 |
+
job["model_version"] = model_version
|
| 81 |
+
if prompt_hash is not None:
|
| 82 |
+
job["prompt_hash"] = prompt_hash
|
| 83 |
+
|
| 84 |
+
tmp_path = path + ".tmp"
|
| 85 |
+
with open(tmp_path, "w") as f:
|
| 86 |
+
json.dump(job, f, indent=2)
|
| 87 |
+
os.replace(tmp_path, path)
|
| 88 |
+
|
| 89 |
+
return job_id
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def get_job(tenant_id: str, repo: str, test_path: str) -> Optional[dict]:
|
| 93 |
+
job_id = _job_id(tenant_id, repo, test_path)
|
| 94 |
+
path = _job_path(job_id)
|
| 95 |
+
if not os.path.exists(path):
|
| 96 |
+
return None
|
| 97 |
+
try:
|
| 98 |
+
with open(path) as f:
|
| 99 |
+
return json.load(f)
|
| 100 |
+
except Exception:
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def get_job_status_enum(tenant_id: str, repo: str, test_path: str) -> Optional[JobStatus]:
|
| 105 |
+
job = get_job(tenant_id, repo, test_path)
|
| 106 |
+
if not job:
|
| 107 |
+
return None
|
| 108 |
+
try:
|
| 109 |
+
return JobStatus(job["status"])
|
| 110 |
+
except ValueError:
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def list_all_jobs() -> list[dict]:
|
| 115 |
+
if not os.path.exists(QUEUE_DIR):
|
| 116 |
+
return []
|
| 117 |
+
jobs = []
|
| 118 |
+
for fname in sorted(os.listdir(QUEUE_DIR)):
|
| 119 |
+
if not fname.endswith(".json"):
|
| 120 |
+
continue
|
| 121 |
+
try:
|
| 122 |
+
with open(os.path.join(QUEUE_DIR, fname)) as f:
|
| 123 |
+
jobs.append(json.load(f))
|
| 124 |
+
except Exception:
|
| 125 |
+
pass
|
| 126 |
+
return sorted(jobs, key=lambda j: j.get("updated_at", ""), reverse=True)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def get_metrics() -> dict:
|
| 130 |
+
jobs = list_all_jobs()
|
| 131 |
+
return {
|
| 132 |
+
"total": len(jobs),
|
| 133 |
+
"done": sum(1 for j in jobs if j["status"] == "DONE"),
|
| 134 |
+
"failed": sum(1 for j in jobs if j["status"] == "FAILED"),
|
| 135 |
+
"running": sum(1 for j in jobs if j["status"] == "RUNNING"),
|
| 136 |
+
"sast_blocked": sum(1 for j in jobs if j["status"] == "SAST_BLOCKED"),
|
| 137 |
+
"prs_created": sum(1 for j in jobs if j.get("pr_url")),
|
| 138 |
+
}
|
mcp_config.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": [
|
| 3 |
+
"TEMPLATE ONLY — contains NO secrets.",
|
| 4 |
+
"Actual runtime config is written to /tmp/mcp_runtime.json at startup.",
|
| 5 |
+
"FETCH_ALLOWED_DOMAINS prevents SSRF against internal services.",
|
| 6 |
+
"GITHUB_PERSONAL_ACCESS_TOKEN is injected from env at runtime — never committed.",
|
| 7 |
+
"NOTE: @modelcontextprotocol/server-fetch does not exist on npm.",
|
| 8 |
+
" The fetch MCP server is a Python package (mcp-server-fetch on PyPI).",
|
| 9 |
+
" It is installed via uv in the Dockerfile and invoked with `uvx mcp-server-fetch`."
|
| 10 |
+
],
|
| 11 |
+
"mcpServers": {
|
| 12 |
+
"fetch-docs": {
|
| 13 |
+
"command": "uvx",
|
| 14 |
+
"args": ["mcp-server-fetch"],
|
| 15 |
+
"env": {
|
| 16 |
+
"FETCH_ALLOWED_DOMAINS": "docs.python.org,pypi.org,docs.github.com,packaging.python.org,peps.python.org"
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
"github-manager": {
|
| 20 |
+
"command": "npx",
|
| 21 |
+
"args": ["-y", "@modelcontextprotocol/server-github"],
|
| 22 |
+
"env": {
|
| 23 |
+
"GITHUB_PERSONAL_ACCESS_TOKEN": "__INJECTED_BY_APP_AT_RUNTIME__"
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
memory_engine.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Fix Memory Engine (Data Flywheel)
|
| 3 |
+
===============================================
|
| 4 |
+
Retrieves semantically similar past successful fixes and injects them as
|
| 5 |
+
few-shot examples into the prompt for new failures.
|
| 6 |
+
|
| 7 |
+
This is the compounding advantage: the more repos Rhodawk heals, the better
|
| 8 |
+
it gets at healing new repos. After 500 examples, fix accuracy on similar
|
| 9 |
+
failures improves measurably. After 5,000 — you fine-tune the model on it.
|
| 10 |
+
|
| 11 |
+
Implementation: TF-IDF based similarity on failure signatures.
|
| 12 |
+
No external embedding API required. Runs entirely on-device.
|
| 13 |
+
Designed to be swapped out for a vector database (Pinecone/Qdrant) at scale.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import hashlib
|
| 17 |
+
import re
|
| 18 |
+
import sqlite3
|
| 19 |
+
from collections import Counter
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
from training_store import DB_PATH
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _tokenize(text: str) -> list[str]:
|
| 26 |
+
text = text.lower()
|
| 27 |
+
text = re.sub(r"[^\w\s]", " ", text)
|
| 28 |
+
tokens = text.split()
|
| 29 |
+
stopwords = {"the", "a", "an", "is", "in", "at", "of", "and", "or", "for", "with",
|
| 30 |
+
"line", "file", "test", "error", "assert", "none", "true", "false",
|
| 31 |
+
"self", "return", "import", "from", "def", "class"}
|
| 32 |
+
return [t for t in tokens if len(t) > 2 and t not in stopwords]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _tf_idf_similarity(query_tokens: list[str], doc_tokens: list[str], corpus_df: dict, corpus_size: int) -> float:
|
| 36 |
+
import math
|
| 37 |
+
|
| 38 |
+
def tf(tokens: list[str], term: str) -> float:
|
| 39 |
+
count = tokens.count(term)
|
| 40 |
+
return count / len(tokens) if tokens else 0
|
| 41 |
+
|
| 42 |
+
def idf(term: str) -> float:
|
| 43 |
+
df = corpus_df.get(term, 0)
|
| 44 |
+
return math.log((corpus_size + 1) / (df + 1)) + 1
|
| 45 |
+
|
| 46 |
+
query_set = set(query_tokens)
|
| 47 |
+
doc_set = set(doc_tokens)
|
| 48 |
+
all_terms = query_set | doc_set
|
| 49 |
+
|
| 50 |
+
query_vec = {t: tf(query_tokens, t) * idf(t) for t in all_terms}
|
| 51 |
+
doc_vec = {t: tf(doc_tokens, t) * idf(t) for t in all_terms}
|
| 52 |
+
|
| 53 |
+
dot = sum(query_vec.get(t, 0) * doc_vec.get(t, 0) for t in all_terms)
|
| 54 |
+
q_norm = sum(v**2 for v in query_vec.values()) ** 0.5
|
| 55 |
+
d_norm = sum(v**2 for v in doc_vec.values()) ** 0.5
|
| 56 |
+
|
| 57 |
+
if q_norm == 0 or d_norm == 0:
|
| 58 |
+
return 0.0
|
| 59 |
+
return dot / (q_norm * d_norm)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def retrieve_similar_fixes(failure_output: str, top_k: int = 3, min_similarity: float = 0.15) -> list[dict]:
|
| 63 |
+
"""
|
| 64 |
+
Retrieve the most similar successful past fixes for a given failure output.
|
| 65 |
+
Returns list of dicts with keys: failure_signature, fix_diff, success_rate, similarity
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
conn = sqlite3.connect(DB_PATH, timeout=5)
|
| 69 |
+
conn.row_factory = sqlite3.Row
|
| 70 |
+
|
| 71 |
+
# Get all successful patterns
|
| 72 |
+
rows = conn.execute("""
|
| 73 |
+
SELECT fp.failure_signature, fp.fix_diff, fp.success_count, fp.attempt_count,
|
| 74 |
+
fa.failure_output as sample_failure
|
| 75 |
+
FROM fix_patterns fp
|
| 76 |
+
LEFT JOIN fix_attempts fa ON fa.failure_signature = fp.failure_signature
|
| 77 |
+
AND fa.success_signal = 1
|
| 78 |
+
WHERE fp.success_count > 0
|
| 79 |
+
ORDER BY fp.success_count DESC
|
| 80 |
+
LIMIT 200
|
| 81 |
+
""").fetchall()
|
| 82 |
+
|
| 83 |
+
conn.close()
|
| 84 |
+
|
| 85 |
+
if not rows:
|
| 86 |
+
return []
|
| 87 |
+
|
| 88 |
+
query_tokens = _tokenize(failure_output)
|
| 89 |
+
|
| 90 |
+
# Build corpus document frequency
|
| 91 |
+
corpus_docs = [_tokenize(r["sample_failure"] or r["failure_signature"]) for r in rows]
|
| 92 |
+
corpus_df: dict[str, int] = Counter()
|
| 93 |
+
for doc in corpus_docs:
|
| 94 |
+
for term in set(doc):
|
| 95 |
+
corpus_df[term] += 1
|
| 96 |
+
|
| 97 |
+
results = []
|
| 98 |
+
for i, row in enumerate(rows):
|
| 99 |
+
doc_tokens = corpus_docs[i]
|
| 100 |
+
sim = _tf_idf_similarity(query_tokens, doc_tokens, corpus_df, len(rows))
|
| 101 |
+
|
| 102 |
+
if sim >= min_similarity:
|
| 103 |
+
total = row["attempt_count"] or 1
|
| 104 |
+
success_rate = f"{(row['success_count'] / total * 100):.0f}%"
|
| 105 |
+
results.append({
|
| 106 |
+
"failure_signature": row["failure_signature"],
|
| 107 |
+
"fix_diff": row["fix_diff"],
|
| 108 |
+
"success_rate": success_rate,
|
| 109 |
+
"similarity": round(sim, 3),
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
results.sort(key=lambda x: x["similarity"], reverse=True)
|
| 113 |
+
return results[:top_k]
|
| 114 |
+
|
| 115 |
+
except Exception:
|
| 116 |
+
return []
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def record_fix_outcome(failure_output: str, context: str, fix_diff: str, success: bool):
|
| 120 |
+
"""Called after each fix attempt to update the memory store."""
|
| 121 |
+
from training_store import record_pattern
|
| 122 |
+
context_hash = hashlib.sha256(context.encode()).hexdigest()[:16]
|
| 123 |
+
record_pattern(failure_output, context_hash, fix_diff, success)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def get_memory_stats() -> dict:
|
| 127 |
+
try:
|
| 128 |
+
conn = sqlite3.connect(DB_PATH, timeout=5)
|
| 129 |
+
total = conn.execute("SELECT COUNT(*) FROM fix_patterns").fetchone()[0]
|
| 130 |
+
successful = conn.execute("SELECT COUNT(*) FROM fix_patterns WHERE success_count > 0").fetchone()[0]
|
| 131 |
+
conn.close()
|
| 132 |
+
return {"patterns_stored": total, "successful_patterns": successful}
|
| 133 |
+
except Exception:
|
| 134 |
+
return {"patterns_stored": 0, "successful_patterns": 0}
|
notifier.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Multi-Channel Notification Engine
|
| 3 |
+
================================================
|
| 4 |
+
Fire-and-forget notifications across Telegram (and extensible to Slack/PagerDuty).
|
| 5 |
+
All dispatches use tenacity retry logic and never block the audit loop.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import threading
|
| 10 |
+
import requests
|
| 11 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 12 |
+
|
| 13 |
+
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
| 14 |
+
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
|
| 15 |
+
SLACK_WEBHOOK_URL = os.getenv("SLACK_WEBHOOK_URL")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
| 19 |
+
def _post_telegram(payload: dict):
|
| 20 |
+
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
| 21 |
+
resp = requests.post(url, json=payload, timeout=10)
|
| 22 |
+
resp.raise_for_status()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
| 26 |
+
def _post_slack(payload: dict):
|
| 27 |
+
resp = requests.post(SLACK_WEBHOOK_URL, json=payload, timeout=10)
|
| 28 |
+
resp.raise_for_status()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _dispatch(message: str, level: str = "INFO"):
|
| 32 |
+
if TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
|
| 33 |
+
try:
|
| 34 |
+
_post_telegram({
|
| 35 |
+
"chat_id": TELEGRAM_CHAT_ID,
|
| 36 |
+
"text": message,
|
| 37 |
+
"parse_mode": "Markdown",
|
| 38 |
+
"disable_web_page_preview": True,
|
| 39 |
+
})
|
| 40 |
+
except Exception:
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
if SLACK_WEBHOOK_URL:
|
| 44 |
+
color_map = {"INFO": "#36a64f", "WARN": "#ffa500", "ERROR": "#ff0000", "CRITICAL": "#8b0000"}
|
| 45 |
+
try:
|
| 46 |
+
_post_slack({
|
| 47 |
+
"attachments": [{
|
| 48 |
+
"color": color_map.get(level, "#36a64f"),
|
| 49 |
+
"text": message.replace("*", ""),
|
| 50 |
+
"mrkdwn_in": ["text"],
|
| 51 |
+
}]
|
| 52 |
+
})
|
| 53 |
+
except Exception:
|
| 54 |
+
pass
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def notify(message: str, level: str = "INFO"):
|
| 58 |
+
"""Non-blocking dispatch. Spawns a daemon thread — never blocks audit loop."""
|
| 59 |
+
threading.Thread(target=_dispatch, args=(message, level), daemon=True).start()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def notify_audit_start(repo: str):
|
| 63 |
+
notify(f"🚀 *Rhodawk AI*\n\nAutonomous audit initiated on `{repo}`.", "INFO")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def notify_test_failed(test_path: str):
|
| 67 |
+
notify(f"⚠️ *Test Failed*\n`{test_path}`\nDispatching Aider agent...", "WARN")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def notify_sast_blocked(test_path: str, reason: str):
|
| 71 |
+
notify(f"🛡️ *SAST Gate BLOCKED PR*\n`{test_path}`\nReason: `{reason}`\nHuman review required.", "CRITICAL")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def notify_pr_created(test_path: str, pr_url: str):
|
| 75 |
+
notify(f"✅ *Auto-Heal PR Generated*\n`{test_path}`\n[View PR]({pr_url})\nAwaiting human review.", "INFO")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def notify_patch_failed(test_path: str):
|
| 79 |
+
notify(f"🔴 *Patch Failed*\n`{test_path}`\nAider returned non-zero exit.", "ERROR")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def notify_audit_complete(metrics: dict):
|
| 83 |
+
notify(
|
| 84 |
+
f"🎯 *Audit Complete*\n"
|
| 85 |
+
f"Scanned: `{metrics['total']}` | Green: `{metrics['done']}` | "
|
| 86 |
+
f"PRs: `{metrics['prs_created']}` | SAST Blocked: `{metrics['sast_blocked']}`",
|
| 87 |
+
"INFO",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def notify_chain_integrity(valid: bool, summary: str):
|
| 92 |
+
if valid:
|
| 93 |
+
notify(f"🔒 *Audit Chain Verified*\n{summary}", "INFO")
|
| 94 |
+
else:
|
| 95 |
+
notify(f"🚨 *CHAIN INTEGRITY VIOLATION*\n{summary}", "CRITICAL")
|
red_team_fuzzer.py
ADDED
|
@@ -0,0 +1,1503 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Autonomous Red Team Fuzzing Engine (CEGIS)
|
| 3 |
+
========================================================
|
| 4 |
+
The Zero-Day Discovery Machine. No competitor has this.
|
| 5 |
+
|
| 6 |
+
What this does:
|
| 7 |
+
When the Blue Team audit loop encounters a "Green" repository (all tests passing),
|
| 8 |
+
this engine takes over. It autonomously ATTACKS the codebase — discovering
|
| 9 |
+
mathematical invariants, synthesizing Property-Based Tests, and fuzzing them
|
| 10 |
+
to exhaustion to find the minimal crashing counter-example (the zero-day payload).
|
| 11 |
+
The crash is then handed to the Blue Team verification_loop.py for autonomous patching.
|
| 12 |
+
|
| 13 |
+
Architecture — CEGIS (Counter-Example Guided Inductive Synthesis):
|
| 14 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 15 |
+
│ RED TEAM ENGINE (This File) │
|
| 16 |
+
│ │
|
| 17 |
+
│ 1. MCP Universal Analyzer │
|
| 18 |
+
│ └── Parse AST → score complexity → rank attack targets │
|
| 19 |
+
│ │
|
| 20 |
+
│ 2. Red Team LLM (The Attacker) │
|
| 21 |
+
│ └── Adversarial prompt → generate Hypothesis PBT │
|
| 22 |
+
│ targeting: overflows, race conditions, invariant breaks │
|
| 23 |
+
│ │
|
| 24 |
+
│ 3. Deterministic Fuzzing Loop │
|
| 25 |
+
│ └── Execute PBT via subprocess → aggressive randomization │
|
| 26 |
+
│ → extract minimal falsifying counter-example │
|
| 27 |
+
│ │
|
| 28 |
+
│ 4. CEGIS Re-attack (if no crash found) │
|
| 29 |
+
│ └── Inject "survived inputs" back to LLM → demand harder │
|
| 30 |
+
│ invariant → repeat up to MAX_CEGIS_ROUNDS │
|
| 31 |
+
│ │
|
| 32 |
+
│ 5. Handoff to Blue Team │
|
| 33 |
+
│ └── Package crash payload → inject into verification_loop │
|
| 34 |
+
│ as a synthetic failing pytest → Blue Team patches it │
|
| 35 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 36 |
+
|
| 37 |
+
Invariant classes targeted:
|
| 38 |
+
- Mathematical: commutativity, associativity, idempotency, monotonicity
|
| 39 |
+
- Boundary: integer overflow (sys.maxsize, 2^63-1, -1, 0), empty sequences
|
| 40 |
+
- Roundtrip: encode→decode, serialize→deserialize, compress→decompress
|
| 41 |
+
- Concurrency: race conditions via threading + shared state mutation
|
| 42 |
+
- Type coercion: implicit conversions that cause precision loss or exceptions
|
| 43 |
+
- State machine: functions that should be pure but carry hidden mutable state
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
import ast
|
| 47 |
+
import hashlib
|
| 48 |
+
import json
|
| 49 |
+
import os
|
| 50 |
+
import re
|
| 51 |
+
import signal
|
| 52 |
+
import subprocess
|
| 53 |
+
import sys
|
| 54 |
+
import tempfile
|
| 55 |
+
import textwrap
|
| 56 |
+
import threading
|
| 57 |
+
import time
|
| 58 |
+
from dataclasses import dataclass, field
|
| 59 |
+
from pathlib import Path
|
| 60 |
+
from typing import Callable, Optional
|
| 61 |
+
|
| 62 |
+
import requests
|
| 63 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 64 |
+
|
| 65 |
+
# ──────────────────────────────────────────────────────────────
|
| 66 |
+
# CONFIGURATION & SECRETS
|
| 67 |
+
# ──────────────────────────────────────────────────────────────
|
| 68 |
+
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
|
| 69 |
+
RED_TEAM_MODEL = os.getenv(
|
| 70 |
+
"RHODAWK_RED_TEAM_MODEL",
|
| 71 |
+
"openrouter/qwen/qwen-2.5-coder-32b-instruct:free",
|
| 72 |
+
)
|
| 73 |
+
RED_TEAM_MODEL_STRONG = os.getenv(
|
| 74 |
+
"RHODAWK_RED_TEAM_MODEL_STRONG",
|
| 75 |
+
"openrouter/anthropic/claude-3-5-sonnet",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
PERSISTENT_DIR = "/data"
|
| 79 |
+
RED_TEAM_DIR = f"{PERSISTENT_DIR}/red_team"
|
| 80 |
+
FUZZ_VENV_DIR = f"{PERSISTENT_DIR}/fuzz_venv"
|
| 81 |
+
|
| 82 |
+
MAX_CEGIS_ROUNDS = int(os.getenv("RHODAWK_CEGIS_ROUNDS", "4"))
|
| 83 |
+
FUZZ_MAX_EXAMPLES = int(os.getenv("RHODAWK_FUZZ_EXAMPLES", "50000"))
|
| 84 |
+
FUZZ_TIMEOUT_SECONDS = int(os.getenv("RHODAWK_FUZZ_TIMEOUT", "180"))
|
| 85 |
+
MAX_TARGETS_PER_RUN = int(os.getenv("RHODAWK_MAX_TARGETS", "8"))
|
| 86 |
+
MIN_COMPLEXITY_SCORE = float(os.getenv("RHODAWK_MIN_COMPLEXITY", "2.0"))
|
| 87 |
+
|
| 88 |
+
# ──────────────────────────────────────────────────────────────
|
| 89 |
+
# LOGGING — mirrors app.py ui_log pattern
|
| 90 |
+
# ────────────────────────────────────────────────────────���─────
|
| 91 |
+
_rt_log_lock = threading.Lock()
|
| 92 |
+
_rt_logs: list[str] = []
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def rte_log(message: str, level: str = "INFO") -> None:
|
| 96 |
+
ts = time.strftime("%H:%M:%S")
|
| 97 |
+
icons = {
|
| 98 |
+
"ATTACK": "⚔️",
|
| 99 |
+
"CRASH": "💥",
|
| 100 |
+
"FUZZ": "🎯",
|
| 101 |
+
"AST": "🔬",
|
| 102 |
+
"CEGIS": "🔁",
|
| 103 |
+
"HAND": "🤝",
|
| 104 |
+
"OK": "✅",
|
| 105 |
+
"FAIL": "❌",
|
| 106 |
+
"WARN": "⚠",
|
| 107 |
+
"INFO": " ",
|
| 108 |
+
}
|
| 109 |
+
line = f"[{ts}] {icons.get(level, ' ')} [RED-TEAM] {message}"
|
| 110 |
+
print(line, flush=True)
|
| 111 |
+
with _rt_log_lock:
|
| 112 |
+
_rt_logs.append(line)
|
| 113 |
+
if len(_rt_logs) > 500:
|
| 114 |
+
_rt_logs.pop(0)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def get_red_team_logs(n: int = 100) -> str:
|
| 118 |
+
with _rt_log_lock:
|
| 119 |
+
return "\n".join(_rt_logs[-n:])
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ──────────────────────────────────────────────────────────────
|
| 123 |
+
# DATA STRUCTURES
|
| 124 |
+
# ──────────────────────────────────────────────────────────────
|
| 125 |
+
|
| 126 |
+
@dataclass
|
| 127 |
+
class ASTFunctionProfile:
|
| 128 |
+
"""Rich profile of a function extracted from its AST node."""
|
| 129 |
+
module_path: str # Relative path: src/utils.py
|
| 130 |
+
function_name: str
|
| 131 |
+
lineno: int
|
| 132 |
+
source_code: str # Raw source of the function
|
| 133 |
+
signature: str # def func(x: int, y: str) -> bool
|
| 134 |
+
arg_types: dict[str, str] # {"x": "int", "y": "str"}
|
| 135 |
+
return_type: str
|
| 136 |
+
complexity_score: float # Cyclomatic complexity (radon)
|
| 137 |
+
has_numeric_ops: bool # Contains arithmetic that could overflow
|
| 138 |
+
has_loops: bool
|
| 139 |
+
has_recursion: bool
|
| 140 |
+
has_state_mutation: bool # Mutates mutable args (list, dict)
|
| 141 |
+
has_exception_handling: bool
|
| 142 |
+
docstring: str
|
| 143 |
+
calls_made: list[str] # Other functions this calls
|
| 144 |
+
ast_summary: str # Compact JSON summary for LLM
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@dataclass
|
| 148 |
+
class FuzzTarget:
|
| 149 |
+
"""A ranked attack target selected by the MCP Universal Analyzer."""
|
| 150 |
+
profile: ASTFunctionProfile
|
| 151 |
+
attack_priority: float # 0.0–1.0 composite score
|
| 152 |
+
invariant_classes: list[str] # Suggested: ["overflow", "roundtrip", ...]
|
| 153 |
+
attack_rationale: str # Why this function is interesting
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@dataclass
|
| 157 |
+
class GeneratedPBT:
|
| 158 |
+
"""A Property-Based Test synthesized by the Red Team LLM."""
|
| 159 |
+
test_code: str # Full Python test file content
|
| 160 |
+
test_function_name: str # e.g., test_add_commutativity
|
| 161 |
+
invariant_description: str
|
| 162 |
+
hypothesis_strategy: str # e.g., "st.integers(min_value=-2**63)"
|
| 163 |
+
cegis_round: int
|
| 164 |
+
prompt_hash: str
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@dataclass
|
| 168 |
+
class CrashPayload:
|
| 169 |
+
"""
|
| 170 |
+
The zero-day package handed to the Blue Team.
|
| 171 |
+
Contains everything needed to reproduce and patch the vulnerability.
|
| 172 |
+
"""
|
| 173 |
+
target: FuzzTarget
|
| 174 |
+
pbt: GeneratedPBT
|
| 175 |
+
falsifying_example: str # Exact inputs that crashed: "x=9223372036854775807, y=-1"
|
| 176 |
+
crash_output: str # Full hypothesis failure output
|
| 177 |
+
crash_type: str # "overflow" | "exception" | "assertion" | "timeout"
|
| 178 |
+
crash_hash: str # SHA-256 of falsifying_example + crash_output
|
| 179 |
+
synthetic_test_path: str # Path to the written failing test file
|
| 180 |
+
source_file_path: str # Target source file to patch
|
| 181 |
+
discovered_at: str
|
| 182 |
+
cegis_rounds_taken: int
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@dataclass
|
| 186 |
+
class RedTeamResult:
|
| 187 |
+
"""Final result of a full CEGIS red-team run on a repository."""
|
| 188 |
+
repo_dir: str
|
| 189 |
+
targets_analyzed: int
|
| 190 |
+
crashes_found: list[CrashPayload] = field(default_factory=list)
|
| 191 |
+
targets_survived: list[str] = field(default_factory=list)
|
| 192 |
+
total_fuzz_examples: int = 0
|
| 193 |
+
duration_seconds: float = 0.0
|
| 194 |
+
cegis_rounds: int = 0
|
| 195 |
+
handoff_results: list[dict] = field(default_factory=list)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ──────────────────────────────────────────────────────────────
|
| 199 |
+
# SECTION 1: MCP UNIVERSAL ANALYZER
|
| 200 |
+
# Python AST scanner — zero external deps beyond stdlib + radon
|
| 201 |
+
# ──────────────────────────────────────────────────────────────
|
| 202 |
+
|
| 203 |
+
def _compute_cyclomatic_complexity(source: str) -> float:
|
| 204 |
+
"""
|
| 205 |
+
Compute cyclomatic complexity using radon if available,
|
| 206 |
+
falling back to a branch-counting heuristic.
|
| 207 |
+
"""
|
| 208 |
+
try:
|
| 209 |
+
from radon.complexity import cc_visit
|
| 210 |
+
results = cc_visit(source)
|
| 211 |
+
if results:
|
| 212 |
+
return float(max(r.complexity for r in results))
|
| 213 |
+
return 1.0
|
| 214 |
+
except Exception:
|
| 215 |
+
pass
|
| 216 |
+
|
| 217 |
+
# Fallback: count decision points
|
| 218 |
+
branch_keywords = {
|
| 219 |
+
"if", "elif", "for", "while", "except", "with",
|
| 220 |
+
"and", "or", "not", "assert",
|
| 221 |
+
}
|
| 222 |
+
score = 1.0
|
| 223 |
+
try:
|
| 224 |
+
tree = ast.parse(source)
|
| 225 |
+
for node in ast.walk(tree):
|
| 226 |
+
if isinstance(node, (ast.If, ast.For, ast.While, ast.ExceptHandler,
|
| 227 |
+
ast.With, ast.AsyncFor, ast.AsyncWith)):
|
| 228 |
+
score += 1.0
|
| 229 |
+
elif isinstance(node, ast.BoolOp):
|
| 230 |
+
score += len(node.values) - 1
|
| 231 |
+
except SyntaxError:
|
| 232 |
+
pass
|
| 233 |
+
return score
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _extract_arg_types(func_node: ast.FunctionDef) -> dict[str, str]:
|
| 237 |
+
"""Extract argument names and their type annotations as strings."""
|
| 238 |
+
result: dict[str, str] = {}
|
| 239 |
+
for arg in func_node.args.args:
|
| 240 |
+
name = arg.arg
|
| 241 |
+
if arg.annotation:
|
| 242 |
+
try:
|
| 243 |
+
result[name] = ast.unparse(arg.annotation)
|
| 244 |
+
except Exception:
|
| 245 |
+
result[name] = "Any"
|
| 246 |
+
else:
|
| 247 |
+
result[name] = "Any"
|
| 248 |
+
return result
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _extract_return_type(func_node: ast.FunctionDef) -> str:
|
| 252 |
+
if func_node.returns:
|
| 253 |
+
try:
|
| 254 |
+
return ast.unparse(func_node.returns)
|
| 255 |
+
except Exception:
|
| 256 |
+
return "Any"
|
| 257 |
+
return "Any"
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _has_numeric_operations(func_node: ast.FunctionDef) -> bool:
|
| 261 |
+
numeric_ops = (
|
| 262 |
+
ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv,
|
| 263 |
+
ast.Mod, ast.Pow, ast.LShift, ast.RShift, ast.BitAnd,
|
| 264 |
+
ast.BitOr, ast.BitXor,
|
| 265 |
+
)
|
| 266 |
+
for node in ast.walk(func_node):
|
| 267 |
+
if isinstance(node, ast.BinOp) and isinstance(node.op, numeric_ops):
|
| 268 |
+
return True
|
| 269 |
+
return False
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _has_recursion(func_node: ast.FunctionDef) -> bool:
|
| 273 |
+
fn_name = func_node.name
|
| 274 |
+
for node in ast.walk(func_node):
|
| 275 |
+
if isinstance(node, ast.Call):
|
| 276 |
+
if isinstance(node.func, ast.Name) and node.func.id == fn_name:
|
| 277 |
+
return True
|
| 278 |
+
if isinstance(node.func, ast.Attribute) and node.func.attr == fn_name:
|
| 279 |
+
return True
|
| 280 |
+
return False
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _has_state_mutation(func_node: ast.FunctionDef) -> bool:
|
| 284 |
+
mutable_methods = {
|
| 285 |
+
"append", "extend", "insert", "remove", "pop", "clear",
|
| 286 |
+
"update", "setdefault", "sort", "reverse",
|
| 287 |
+
}
|
| 288 |
+
for node in ast.walk(func_node):
|
| 289 |
+
if isinstance(node, ast.Call):
|
| 290 |
+
if (isinstance(node.func, ast.Attribute) and
|
| 291 |
+
node.func.attr in mutable_methods):
|
| 292 |
+
return True
|
| 293 |
+
if isinstance(node, ast.Assign):
|
| 294 |
+
for t in node.targets:
|
| 295 |
+
if isinstance(t, ast.Subscript):
|
| 296 |
+
return True
|
| 297 |
+
return False
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _extract_calls(func_node: ast.FunctionDef) -> list[str]:
|
| 301 |
+
calls = []
|
| 302 |
+
for node in ast.walk(func_node):
|
| 303 |
+
if isinstance(node, ast.Call):
|
| 304 |
+
if isinstance(node.func, ast.Name):
|
| 305 |
+
calls.append(node.func.id)
|
| 306 |
+
elif isinstance(node.func, ast.Attribute):
|
| 307 |
+
calls.append(f"{ast.unparse(node.func.value) if hasattr(ast, 'unparse') else '?'}.{node.func.attr}")
|
| 308 |
+
return list(set(calls[:20]))
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _build_ast_summary(profile: "ASTFunctionProfile") -> str:
|
| 312 |
+
"""
|
| 313 |
+
Compact JSON representation of the function for the LLM.
|
| 314 |
+
Keeps token count low while conveying full semantic richness.
|
| 315 |
+
"""
|
| 316 |
+
return json.dumps({
|
| 317 |
+
"fn": profile.function_name,
|
| 318 |
+
"sig": profile.signature,
|
| 319 |
+
"args": profile.arg_types,
|
| 320 |
+
"returns": profile.return_type,
|
| 321 |
+
"complexity": profile.complexity_score,
|
| 322 |
+
"numeric_ops": profile.has_numeric_ops,
|
| 323 |
+
"recursive": profile.has_recursion,
|
| 324 |
+
"mutates_args": profile.has_state_mutation,
|
| 325 |
+
"loops": profile.has_loops,
|
| 326 |
+
"calls": profile.calls_made[:10],
|
| 327 |
+
"docstring": profile.docstring[:200] if profile.docstring else "",
|
| 328 |
+
"source_preview": profile.source_code[:600],
|
| 329 |
+
}, indent=None, separators=(",", ":"))
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _score_attack_priority(p: ASTFunctionProfile) -> tuple[float, list[str], str]:
|
| 333 |
+
"""
|
| 334 |
+
Compute composite attack priority and determine which invariant
|
| 335 |
+
classes are most likely to produce a crash.
|
| 336 |
+
Returns: (score, invariant_classes, rationale)
|
| 337 |
+
"""
|
| 338 |
+
score = 0.0
|
| 339 |
+
classes: list[str] = []
|
| 340 |
+
rationale_parts: list[str] = []
|
| 341 |
+
|
| 342 |
+
# Complexity: high CC = more edge cases
|
| 343 |
+
if p.complexity_score >= 10:
|
| 344 |
+
score += 0.35
|
| 345 |
+
rationale_parts.append(f"high cyclomatic complexity ({p.complexity_score:.1f})")
|
| 346 |
+
elif p.complexity_score >= 5:
|
| 347 |
+
score += 0.20
|
| 348 |
+
elif p.complexity_score >= 3:
|
| 349 |
+
score += 0.10
|
| 350 |
+
|
| 351 |
+
# Numeric operations are prime overflow targets
|
| 352 |
+
if p.has_numeric_ops:
|
| 353 |
+
score += 0.25
|
| 354 |
+
classes.append("integer_overflow")
|
| 355 |
+
classes.append("boundary_value")
|
| 356 |
+
rationale_parts.append("arithmetic operations (overflow/underflow risk)")
|
| 357 |
+
|
| 358 |
+
# Recursion = stack overflow + incorrect base cases
|
| 359 |
+
if p.has_recursion:
|
| 360 |
+
score += 0.20
|
| 361 |
+
classes.append("recursion_depth")
|
| 362 |
+
classes.append("base_case_invariant")
|
| 363 |
+
rationale_parts.append("recursive structure (stack overflow / incorrect base case)")
|
| 364 |
+
|
| 365 |
+
# Mutable argument mutation = aliasing bugs
|
| 366 |
+
if p.has_state_mutation:
|
| 367 |
+
score += 0.15
|
| 368 |
+
classes.append("aliasing_mutation")
|
| 369 |
+
classes.append("idempotency")
|
| 370 |
+
rationale_parts.append("mutates mutable arguments (aliasing / idempotency risk)")
|
| 371 |
+
|
| 372 |
+
# Typed args: roundtrip testing possible
|
| 373 |
+
typed_count = sum(1 for v in p.arg_types.values() if v not in ("Any", ""))
|
| 374 |
+
if typed_count > 0:
|
| 375 |
+
score += min(0.10, typed_count * 0.03)
|
| 376 |
+
if p.return_type not in ("None", "Any", ""):
|
| 377 |
+
classes.append("roundtrip")
|
| 378 |
+
classes.append("commutativity")
|
| 379 |
+
|
| 380 |
+
# Exception handling = swallowing exceptions silently
|
| 381 |
+
if p.has_exception_handling:
|
| 382 |
+
score += 0.10
|
| 383 |
+
classes.append("exception_swallowing")
|
| 384 |
+
rationale_parts.append("exception handling (may swallow bugs silently)")
|
| 385 |
+
|
| 386 |
+
# Loops are iteration boundary targets
|
| 387 |
+
if p.has_loops:
|
| 388 |
+
score += 0.05
|
| 389 |
+
classes.append("loop_boundary")
|
| 390 |
+
|
| 391 |
+
# Deduplicate classes, maintain priority order
|
| 392 |
+
seen: set[str] = set()
|
| 393 |
+
unique_classes = [c for c in classes if not (c in seen or seen.add(c))]
|
| 394 |
+
if not unique_classes:
|
| 395 |
+
unique_classes = ["property_invariant", "boundary_value"]
|
| 396 |
+
|
| 397 |
+
rationale = "; ".join(rationale_parts) if rationale_parts else "general-purpose invariant analysis"
|
| 398 |
+
return min(score, 1.0), unique_classes, rationale
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def analyze_repository_ast(repo_dir: str) -> list[FuzzTarget]:
|
| 402 |
+
"""
|
| 403 |
+
Walk all Python source files in the repo, extract function-level AST
|
| 404 |
+
profiles, score each for attack priority, and return the top targets
|
| 405 |
+
ranked by score.
|
| 406 |
+
|
| 407 |
+
Only targets functions in src/ lib/ or top-level .py files (not tests).
|
| 408 |
+
"""
|
| 409 |
+
rte_log(f"Scanning AST of repository: {repo_dir}", "AST")
|
| 410 |
+
|
| 411 |
+
targets: list[FuzzTarget] = []
|
| 412 |
+
source_dirs = ["src", "lib", "core", "app", "utils", "engine", "api"]
|
| 413 |
+
|
| 414 |
+
candidate_files: list[Path] = []
|
| 415 |
+
repo_path = Path(repo_dir)
|
| 416 |
+
|
| 417 |
+
# Collect candidate source files (exclude tests)
|
| 418 |
+
for py_file in repo_path.rglob("*.py"):
|
| 419 |
+
rel = py_file.relative_to(repo_path)
|
| 420 |
+
parts = rel.parts
|
| 421 |
+
if any(p.startswith("test") or p in ("tests", ".git", "__pycache__",
|
| 422 |
+
"build", "dist", ".tox", "venv", ".venv") for p in parts):
|
| 423 |
+
continue
|
| 424 |
+
if rel.name.startswith("test_") or rel.name.startswith("conftest"):
|
| 425 |
+
continue
|
| 426 |
+
candidate_files.append(py_file)
|
| 427 |
+
|
| 428 |
+
rte_log(f"Found {len(candidate_files)} non-test Python file(s) to analyze", "AST")
|
| 429 |
+
|
| 430 |
+
for py_file in candidate_files:
|
| 431 |
+
rel_path = str(py_file.relative_to(repo_path))
|
| 432 |
+
try:
|
| 433 |
+
source = py_file.read_text(encoding="utf-8", errors="replace")
|
| 434 |
+
except OSError:
|
| 435 |
+
continue
|
| 436 |
+
|
| 437 |
+
try:
|
| 438 |
+
tree = ast.parse(source, filename=str(py_file))
|
| 439 |
+
except SyntaxError as e:
|
| 440 |
+
rte_log(f"SyntaxError in {rel_path}: {e}", "WARN")
|
| 441 |
+
continue
|
| 442 |
+
|
| 443 |
+
# Extract module-level source lines for function slicing
|
| 444 |
+
source_lines = source.splitlines()
|
| 445 |
+
|
| 446 |
+
for node in ast.walk(tree):
|
| 447 |
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 448 |
+
continue
|
| 449 |
+
# Skip private/dunder/tiny functions
|
| 450 |
+
name = node.name
|
| 451 |
+
if name.startswith("__") and name.endswith("__"):
|
| 452 |
+
continue
|
| 453 |
+
if not node.args.args and not node.args.vararg:
|
| 454 |
+
continue # No arguments = nothing to fuzz
|
| 455 |
+
|
| 456 |
+
# Extract function source
|
| 457 |
+
try:
|
| 458 |
+
fn_start = node.lineno - 1
|
| 459 |
+
fn_end = node.end_lineno if hasattr(node, "end_lineno") else fn_start + 30
|
| 460 |
+
fn_source = "\n".join(source_lines[fn_start:fn_end])
|
| 461 |
+
except Exception:
|
| 462 |
+
fn_source = ""
|
| 463 |
+
|
| 464 |
+
if len(fn_source.strip()) < 20:
|
| 465 |
+
continue
|
| 466 |
+
|
| 467 |
+
# Build signature string
|
| 468 |
+
try:
|
| 469 |
+
sig = ast.unparse(node) if hasattr(ast, "unparse") else name
|
| 470 |
+
sig = sig.split("\n")[0].rstrip(":")
|
| 471 |
+
if len(sig) > 200:
|
| 472 |
+
sig = sig[:200] + "..."
|
| 473 |
+
except Exception:
|
| 474 |
+
sig = f"def {name}(...)"
|
| 475 |
+
|
| 476 |
+
# Extract docstring
|
| 477 |
+
docstring = ast.get_docstring(node) or ""
|
| 478 |
+
|
| 479 |
+
# Extract arg types and return type
|
| 480 |
+
arg_types = _extract_arg_types(node)
|
| 481 |
+
return_type = _extract_return_type(node)
|
| 482 |
+
|
| 483 |
+
# Compute complexity on the function slice only
|
| 484 |
+
complexity = _compute_cyclomatic_complexity(fn_source)
|
| 485 |
+
|
| 486 |
+
# Feature flags
|
| 487 |
+
has_loops = any(
|
| 488 |
+
isinstance(n, (ast.For, ast.While, ast.AsyncFor))
|
| 489 |
+
for n in ast.walk(node)
|
| 490 |
+
)
|
| 491 |
+
has_except = any(
|
| 492 |
+
isinstance(n, ast.ExceptHandler)
|
| 493 |
+
for n in ast.walk(node)
|
| 494 |
+
)
|
| 495 |
+
calls = _extract_calls(node)
|
| 496 |
+
|
| 497 |
+
profile = ASTFunctionProfile(
|
| 498 |
+
module_path=rel_path,
|
| 499 |
+
function_name=name,
|
| 500 |
+
lineno=node.lineno,
|
| 501 |
+
source_code=fn_source,
|
| 502 |
+
signature=sig,
|
| 503 |
+
arg_types=arg_types,
|
| 504 |
+
return_type=return_type,
|
| 505 |
+
complexity_score=complexity,
|
| 506 |
+
has_numeric_ops=_has_numeric_operations(node),
|
| 507 |
+
has_loops=has_loops,
|
| 508 |
+
has_recursion=_has_recursion(node),
|
| 509 |
+
has_state_mutation=_has_state_mutation(node),
|
| 510 |
+
has_exception_handling=has_except,
|
| 511 |
+
docstring=docstring,
|
| 512 |
+
calls_made=calls,
|
| 513 |
+
ast_summary="", # computed below
|
| 514 |
+
)
|
| 515 |
+
profile.ast_summary = _build_ast_summary(profile)
|
| 516 |
+
|
| 517 |
+
priority, invariant_classes, rationale = _score_attack_priority(profile)
|
| 518 |
+
|
| 519 |
+
if priority < MIN_COMPLEXITY_SCORE / 10.0:
|
| 520 |
+
continue
|
| 521 |
+
|
| 522 |
+
targets.append(FuzzTarget(
|
| 523 |
+
profile=profile,
|
| 524 |
+
attack_priority=priority,
|
| 525 |
+
invariant_classes=invariant_classes,
|
| 526 |
+
attack_rationale=rationale,
|
| 527 |
+
))
|
| 528 |
+
|
| 529 |
+
# Sort by priority descending
|
| 530 |
+
targets.sort(key=lambda t: t.attack_priority, reverse=True)
|
| 531 |
+
|
| 532 |
+
rte_log(
|
| 533 |
+
f"AST analysis complete: {len(targets)} attack targets ranked. "
|
| 534 |
+
f"Top target: {targets[0].profile.function_name if targets else 'none'} "
|
| 535 |
+
f"(score={targets[0].attack_priority:.3f})" if targets else "No targets found.",
|
| 536 |
+
"AST"
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
return targets[:MAX_TARGETS_PER_RUN]
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
# ──────────────────────────────────────────────────────────────
|
| 543 |
+
# SECTION 2: RED TEAM LLM PROMPT (THE ATTACKER)
|
| 544 |
+
# ──────────────────────────────────────────────────────────────
|
| 545 |
+
|
| 546 |
+
_RED_TEAM_SYSTEM_PROMPT = """You are an elite adversarial security researcher specializing in automated vulnerability discovery through Property-Based Testing and formal methods. Your role is The Attacker.
|
| 547 |
+
|
| 548 |
+
You are given the AST profile and source code of a Python function that is currently passing all its test suite. Your mission is to write a Hypothesis property-based test that BREAKS this function by finding a mathematical invariant it violates.
|
| 549 |
+
|
| 550 |
+
YOU MUST TARGET ONE OF THESE INVARIANT CLASSES:
|
| 551 |
+
1. INTEGER OVERFLOW: Test with sys.maxsize, -sys.maxsize, 2**63-1, 2**31-1, -1, 0 as boundary inputs
|
| 552 |
+
2. COMMUTATIVITY: f(a, b) == f(b, a) for all valid (a, b)
|
| 553 |
+
3. ASSOCIATIVITY: f(f(a,b),c) == f(a,f(b,c)) for all valid (a, b, c)
|
| 554 |
+
4. IDEMPOTENCY: f(f(x)) == f(x) — calling twice gives same result as once
|
| 555 |
+
5. ROUNDTRIP: decode(encode(x)) == x — or any encode/decode symmetry
|
| 556 |
+
6. MONOTONICITY: a <= b implies f(a) <= f(b) — for ordered inputs
|
| 557 |
+
7. BOUNDARY / EMPTY INPUTS: empty strings, empty lists, None where not expected, 0-length inputs
|
| 558 |
+
8. TYPE COERCION: Python implicit int→float conversion causing precision loss
|
| 559 |
+
9. BASE CASE INVARIANT: f(0) == expected, f(1) == expected (for recursive functions)
|
| 560 |
+
10. ALIASING: f(x, x) must not corrupt x when x is a mutable object
|
| 561 |
+
|
| 562 |
+
STRICT OUTPUT RULES:
|
| 563 |
+
- Output ONLY a valid Python test file. No explanation. No markdown. No ```python blocks.
|
| 564 |
+
- The test file must be directly executable with: python -m pytest <file> -v
|
| 565 |
+
- Import the target function using its exact module path
|
| 566 |
+
- Use: from hypothesis import given, settings, assume, HealthCheck
|
| 567 |
+
- Use: from hypothesis import strategies as st
|
| 568 |
+
- Use: @settings(max_examples=MAX_EXAMPLES, suppress_health_check=[HealthCheck.too_slow], deadline=None)
|
| 569 |
+
- MAX_EXAMPLES is already set in the settings decorator — use the constant 50000
|
| 570 |
+
- The test MUST raise an AssertionError or an unhandled exception when the invariant is violated
|
| 571 |
+
- DO NOT catch exceptions in the test body — let them propagate so hypothesis captures them
|
| 572 |
+
- Use assume() to filter out invalid inputs (e.g., assume(divisor != 0))
|
| 573 |
+
- Target the MOST LIKELY invariant class to produce a crash based on the AST profile
|
| 574 |
+
- Keep the test file under 80 lines
|
| 575 |
+
|
| 576 |
+
REMEMBER: Your goal is to find a REAL BUG. Not a contrived one. Study the source code and find a genuine mathematical invariant this function should satisfy but might violate on extreme inputs."""
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def _build_red_team_prompt(
|
| 580 |
+
target: FuzzTarget,
|
| 581 |
+
cegis_round: int,
|
| 582 |
+
survived_inputs: Optional[list[str]] = None,
|
| 583 |
+
) -> str:
|
| 584 |
+
"""
|
| 585 |
+
Build the adversarial LLM prompt. Each CEGIS round gets harder:
|
| 586 |
+
- Round 1: Fresh attack based on AST
|
| 587 |
+
- Round 2+: Inject survived inputs → demand deeper invariant
|
| 588 |
+
"""
|
| 589 |
+
p = target.profile
|
| 590 |
+
|
| 591 |
+
sections = [
|
| 592 |
+
f"=== ATTACK MISSION (CEGIS Round {cegis_round}/{MAX_CEGIS_ROUNDS}) ===\n",
|
| 593 |
+
f"TARGET FUNCTION: {p.function_name}",
|
| 594 |
+
f"MODULE: {p.module_path} (line {p.lineno})",
|
| 595 |
+
f"SIGNATURE: {p.signature}",
|
| 596 |
+
f"RETURN TYPE: {p.return_type}",
|
| 597 |
+
f"ATTACK RATIONALE: {target.attack_rationale}",
|
| 598 |
+
f"SUGGESTED INVARIANT CLASSES: {', '.join(target.invariant_classes)}",
|
| 599 |
+
f"\nAST PROFILE (JSON):\n{p.ast_summary}",
|
| 600 |
+
f"\nFULL FUNCTION SOURCE:\n```python\n{p.source_code[:2000]}\n```",
|
| 601 |
+
]
|
| 602 |
+
|
| 603 |
+
if cegis_round > 1 and survived_inputs:
|
| 604 |
+
sections.append(
|
| 605 |
+
f"\n=== CEGIS FEEDBACK — ROUND {cegis_round} ===\n"
|
| 606 |
+
f"Your previous property-based test failed to find a crash after {FUZZ_MAX_EXAMPLES:,} examples.\n"
|
| 607 |
+
f"The following inputs SURVIVED (did NOT crash the function):\n"
|
| 608 |
+
+ "\n".join(f" - {inp}" for inp in survived_inputs[:15])
|
| 609 |
+
+ "\n\nDO NOT repeat the same invariant. The function survived those inputs.\n"
|
| 610 |
+
"Attack a DIFFERENT invariant class. Go deeper. Try:\n"
|
| 611 |
+
" - Adversarial numeric boundaries (sys.maxsize - 1, -(2**63))\n"
|
| 612 |
+
" - Aliasing attacks: pass the same mutable object as multiple arguments\n"
|
| 613 |
+
" - Zero/empty/whitespace-only inputs combined with extreme length\n"
|
| 614 |
+
" - Unicode edge cases: null bytes, surrogate pairs, RTL text\n"
|
| 615 |
+
" - Type coercion: st.floats() combined with integer paths\n"
|
| 616 |
+
)
|
| 617 |
+
|
| 618 |
+
module_import = p.module_path.replace("/", ".").replace(".py", "").replace("\\", ".")
|
| 619 |
+
sections.append(
|
| 620 |
+
f"\n=== IMPORT INSTRUCTION ===\n"
|
| 621 |
+
f"Import the function as:\n"
|
| 622 |
+
f" from {module_import} import {p.function_name}\n"
|
| 623 |
+
f"OR use sys.path manipulation if the module path has non-package directories:\n"
|
| 624 |
+
f" import sys, os; sys.path.insert(0, os.path.dirname('<repo_dir>'))\n"
|
| 625 |
+
f" from {p.module_path.replace('/', '.').replace('.py', '')} import {p.function_name}\n"
|
| 626 |
+
f"\nNow write the property-based test file. Output ONLY valid Python. No markdown."
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
return "\n".join(sections)
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=3, max=15))
|
| 633 |
+
def _call_red_team_llm(system: str, user: str, model: str) -> str:
|
| 634 |
+
"""Call OpenRouter API — returns raw text response (the test code)."""
|
| 635 |
+
if not OPENROUTER_API_KEY:
|
| 636 |
+
raise RuntimeError("OPENROUTER_API_KEY not set — Red Team LLM unavailable")
|
| 637 |
+
|
| 638 |
+
headers = {
|
| 639 |
+
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 640 |
+
"Content-Type": "application/json",
|
| 641 |
+
"HTTP-Referer": "https://rhodawk.ai",
|
| 642 |
+
"X-Title": "Rhodawk AI Red Team Fuzzer",
|
| 643 |
+
}
|
| 644 |
+
payload = {
|
| 645 |
+
"model": model.replace("openrouter/", ""),
|
| 646 |
+
"messages": [
|
| 647 |
+
{"role": "system", "content": system},
|
| 648 |
+
{"role": "user", "content": user},
|
| 649 |
+
],
|
| 650 |
+
"temperature": 0.15,
|
| 651 |
+
"max_tokens": 2048,
|
| 652 |
+
}
|
| 653 |
+
resp = requests.post(
|
| 654 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 655 |
+
headers=headers,
|
| 656 |
+
json=payload,
|
| 657 |
+
timeout=90,
|
| 658 |
+
)
|
| 659 |
+
resp.raise_for_status()
|
| 660 |
+
content = resp.json()["choices"][0]["message"]["content"]
|
| 661 |
+
return content.strip()
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
def _clean_llm_test_output(raw: str) -> str:
|
| 665 |
+
"""
|
| 666 |
+
Strip markdown fences and extract raw Python from LLM response.
|
| 667 |
+
The LLM is instructed not to use markdown, but be defensive.
|
| 668 |
+
"""
|
| 669 |
+
# Remove ```python ... ``` blocks
|
| 670 |
+
raw = re.sub(r"```(?:python)?\s*\n?", "", raw)
|
| 671 |
+
raw = re.sub(r"```\s*$", "", raw, flags=re.MULTILINE)
|
| 672 |
+
# Remove leading/trailing commentary lines that aren't Python
|
| 673 |
+
lines = raw.splitlines()
|
| 674 |
+
code_lines = []
|
| 675 |
+
in_code = False
|
| 676 |
+
for line in lines:
|
| 677 |
+
stripped = line.strip()
|
| 678 |
+
if stripped.startswith("#") or stripped.startswith("import") or stripped.startswith("from") or stripped.startswith("def ") or stripped.startswith("@") or stripped.startswith(" ") or stripped == "" or in_code:
|
| 679 |
+
in_code = True
|
| 680 |
+
code_lines.append(line)
|
| 681 |
+
elif in_code:
|
| 682 |
+
code_lines.append(line)
|
| 683 |
+
return "\n".join(code_lines).strip()
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
def synthesize_pbt(
|
| 687 |
+
target: FuzzTarget,
|
| 688 |
+
cegis_round: int,
|
| 689 |
+
survived_inputs: Optional[list[str]] = None,
|
| 690 |
+
use_strong_model: bool = False,
|
| 691 |
+
) -> Optional[GeneratedPBT]:
|
| 692 |
+
"""
|
| 693 |
+
Dispatch the Red Team LLM to synthesize a Property-Based Test.
|
| 694 |
+
Returns a GeneratedPBT on success, None if LLM fails.
|
| 695 |
+
"""
|
| 696 |
+
model = RED_TEAM_MODEL_STRONG if use_strong_model else RED_TEAM_MODEL
|
| 697 |
+
rte_log(
|
| 698 |
+
f"Synthesizing PBT for {target.profile.function_name} "
|
| 699 |
+
f"(round {cegis_round}, model={model.split('/')[-1]})",
|
| 700 |
+
"ATTACK"
|
| 701 |
+
)
|
| 702 |
+
|
| 703 |
+
user_prompt = _build_red_team_prompt(target, cegis_round, survived_inputs)
|
| 704 |
+
prompt_hash = hashlib.sha256(user_prompt.encode()).hexdigest()[:16]
|
| 705 |
+
|
| 706 |
+
try:
|
| 707 |
+
raw_response = _call_red_team_llm(_RED_TEAM_SYSTEM_PROMPT, user_prompt, model)
|
| 708 |
+
except Exception as e:
|
| 709 |
+
rte_log(f"LLM call failed for {target.profile.function_name}: {e}", "WARN")
|
| 710 |
+
# Fallback to strong model on failure
|
| 711 |
+
if not use_strong_model:
|
| 712 |
+
try:
|
| 713 |
+
raw_response = _call_red_team_llm(
|
| 714 |
+
_RED_TEAM_SYSTEM_PROMPT, user_prompt, RED_TEAM_MODEL_STRONG
|
| 715 |
+
)
|
| 716 |
+
except Exception as e2:
|
| 717 |
+
rte_log(f"Strong model also failed: {e2}", "FAIL")
|
| 718 |
+
return None
|
| 719 |
+
else:
|
| 720 |
+
return None
|
| 721 |
+
|
| 722 |
+
test_code = _clean_llm_test_output(raw_response)
|
| 723 |
+
|
| 724 |
+
# Validate it looks like Python with hypothesis
|
| 725 |
+
if "from hypothesis" not in test_code and "import hypothesis" not in test_code:
|
| 726 |
+
rte_log(
|
| 727 |
+
f"LLM output for {target.profile.function_name} doesn't contain hypothesis imports — retrying",
|
| 728 |
+
"WARN"
|
| 729 |
+
)
|
| 730 |
+
return None
|
| 731 |
+
|
| 732 |
+
if "def test_" not in test_code:
|
| 733 |
+
rte_log(f"LLM output missing test function — retrying", "WARN")
|
| 734 |
+
return None
|
| 735 |
+
|
| 736 |
+
# Extract test function name
|
| 737 |
+
fn_match = re.search(r"def (test_\w+)\(", test_code)
|
| 738 |
+
test_fn_name = fn_match.group(1) if fn_match else "test_invariant"
|
| 739 |
+
|
| 740 |
+
# Extract invariant description from docstring or comment
|
| 741 |
+
inv_match = re.search(r'"""([^"]{10,200}?)"""', test_code)
|
| 742 |
+
if not inv_match:
|
| 743 |
+
inv_match = re.search(r"#\s*(.{10,120})", test_code)
|
| 744 |
+
invariant_desc = inv_match.group(1).strip() if inv_match else "property invariant"
|
| 745 |
+
|
| 746 |
+
# Extract hypothesis strategy
|
| 747 |
+
strategy_match = re.search(r"@given\((.{5,200}?)\)", test_code)
|
| 748 |
+
strategy = strategy_match.group(1) if strategy_match else "unknown"
|
| 749 |
+
|
| 750 |
+
rte_log(
|
| 751 |
+
f"PBT synthesized: {test_fn_name} | "
|
| 752 |
+
f"invariant: {invariant_desc[:60]} | strategy: {strategy[:60]}",
|
| 753 |
+
"ATTACK"
|
| 754 |
+
)
|
| 755 |
+
|
| 756 |
+
return GeneratedPBT(
|
| 757 |
+
test_code=test_code,
|
| 758 |
+
test_function_name=test_fn_name,
|
| 759 |
+
invariant_description=invariant_desc,
|
| 760 |
+
hypothesis_strategy=strategy,
|
| 761 |
+
cegis_round=cegis_round,
|
| 762 |
+
prompt_hash=prompt_hash,
|
| 763 |
+
)
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
# ──────────────────────────────────────────────────────────────
|
| 767 |
+
# SECTION 3: DETERMINISTIC FUZZING LOOP
|
| 768 |
+
# ──────────────────────────────────────────────────────────────
|
| 769 |
+
|
| 770 |
+
def _install_hypothesis_if_needed(pytest_bin: str, repo_dir: str) -> bool:
|
| 771 |
+
"""Ensure hypothesis is installed in the target venv."""
|
| 772 |
+
try:
|
| 773 |
+
check = subprocess.run(
|
| 774 |
+
[pytest_bin.replace("pytest", "python"), "-c", "import hypothesis"],
|
| 775 |
+
capture_output=True, timeout=15, cwd=repo_dir,
|
| 776 |
+
)
|
| 777 |
+
if check.returncode == 0:
|
| 778 |
+
return True
|
| 779 |
+
except Exception:
|
| 780 |
+
pass
|
| 781 |
+
|
| 782 |
+
rte_log("Installing hypothesis into target venv...", "FUZZ")
|
| 783 |
+
try:
|
| 784 |
+
pip_bin = pytest_bin.replace("pytest", "pip")
|
| 785 |
+
result = subprocess.run(
|
| 786 |
+
[pip_bin, "install", "hypothesis", "--quiet"],
|
| 787 |
+
capture_output=True, timeout=120, cwd=repo_dir,
|
| 788 |
+
)
|
| 789 |
+
return result.returncode == 0
|
| 790 |
+
except Exception as e:
|
| 791 |
+
rte_log(f"hypothesis install failed: {e}", "WARN")
|
| 792 |
+
return False
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
def _write_pbt_to_file(pbt: GeneratedPBT, target: FuzzTarget, repo_dir: str) -> str:
|
| 796 |
+
"""
|
| 797 |
+
Write the generated PBT to the red_team/ directory.
|
| 798 |
+
Injects the repo sys.path so the target module can be imported.
|
| 799 |
+
Returns absolute path to the test file.
|
| 800 |
+
"""
|
| 801 |
+
os.makedirs(RED_TEAM_DIR, exist_ok=True)
|
| 802 |
+
|
| 803 |
+
fn_safe = target.profile.function_name.replace("-", "_")
|
| 804 |
+
timestamp = int(time.time())
|
| 805 |
+
filename = f"rt_{fn_safe}_r{pbt.cegis_round}_{timestamp}.py"
|
| 806 |
+
filepath = os.path.join(RED_TEAM_DIR, filename)
|
| 807 |
+
|
| 808 |
+
sys_path_injection = textwrap.dedent(f"""
|
| 809 |
+
import sys
|
| 810 |
+
import os
|
| 811 |
+
# Inject repo root so target modules are importable
|
| 812 |
+
_REPO_DIR = {repr(repo_dir)}
|
| 813 |
+
if _REPO_DIR not in sys.path:
|
| 814 |
+
sys.path.insert(0, _REPO_DIR)
|
| 815 |
+
for _sub in ["src", "lib", "core", "app"]:
|
| 816 |
+
_p = os.path.join(_REPO_DIR, _sub)
|
| 817 |
+
if os.path.isdir(_p) and _p not in sys.path:
|
| 818 |
+
sys.path.insert(0, _p)
|
| 819 |
+
|
| 820 |
+
""").lstrip()
|
| 821 |
+
|
| 822 |
+
full_content = sys_path_injection + pbt.test_code
|
| 823 |
+
|
| 824 |
+
with open(filepath, "w", encoding="utf-8") as f:
|
| 825 |
+
f.write(full_content)
|
| 826 |
+
|
| 827 |
+
return filepath
|
| 828 |
+
|
| 829 |
+
|
| 830 |
+
def _extract_falsifying_example(output: str) -> str:
|
| 831 |
+
"""
|
| 832 |
+
Parse hypothesis output to extract the minimal falsifying example.
|
| 833 |
+
Hypothesis prints:
|
| 834 |
+
Falsifying example: test_func(x=42, y=-1)
|
| 835 |
+
or in newer versions:
|
| 836 |
+
Falsifying explicit example: ...
|
| 837 |
+
"""
|
| 838 |
+
patterns = [
|
| 839 |
+
r"Falsifying explicit example:\s*\w+\((.+?)\)",
|
| 840 |
+
r"Falsifying example:\s*\w+\((.+?)\)",
|
| 841 |
+
r"Falsifying example.*?:\s*(.+?)$",
|
| 842 |
+
r"AssertionError.*?:\s*(.+?)$",
|
| 843 |
+
]
|
| 844 |
+
for pat in patterns:
|
| 845 |
+
m = re.search(pat, output, re.MULTILINE | re.DOTALL)
|
| 846 |
+
if m:
|
| 847 |
+
return m.group(1).strip()[:500]
|
| 848 |
+
|
| 849 |
+
# Fallback: extract any exception line
|
| 850 |
+
for line in output.splitlines():
|
| 851 |
+
if "Error" in line or "assert" in line.lower():
|
| 852 |
+
return line.strip()[:300]
|
| 853 |
+
|
| 854 |
+
return "Unknown — see full crash output"
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
def _extract_crash_type(output: str) -> str:
|
| 858 |
+
"""Classify the type of crash from hypothesis output."""
|
| 859 |
+
if "OverflowError" in output:
|
| 860 |
+
return "integer_overflow"
|
| 861 |
+
if "RecursionError" in output:
|
| 862 |
+
return "recursion_depth"
|
| 863 |
+
if "AssertionError" in output:
|
| 864 |
+
return "assertion_violation"
|
| 865 |
+
if "ZeroDivisionError" in output:
|
| 866 |
+
return "division_by_zero"
|
| 867 |
+
if "IndexError" in output:
|
| 868 |
+
return "index_out_of_bounds"
|
| 869 |
+
if "TypeError" in output:
|
| 870 |
+
return "type_error"
|
| 871 |
+
if "ValueError" in output:
|
| 872 |
+
return "value_error"
|
| 873 |
+
if "MemoryError" in output or "MemoryError" in output:
|
| 874 |
+
return "memory_exhaustion"
|
| 875 |
+
if "FAILED" in output:
|
| 876 |
+
return "assertion"
|
| 877 |
+
return "unknown_exception"
|
| 878 |
+
|
| 879 |
+
|
| 880 |
+
def _extract_survived_inputs(output: str) -> list[str]:
|
| 881 |
+
"""
|
| 882 |
+
If the fuzz run succeeded (no crash), try to extract some of the
|
| 883 |
+
inputs that were tested so CEGIS can inform the next round.
|
| 884 |
+
"""
|
| 885 |
+
survived = []
|
| 886 |
+
for m in re.finditer(r"Trying example.*?\((.+?)\)", output):
|
| 887 |
+
survived.append(m.group(1)[:100])
|
| 888 |
+
# Also include any explicit example lines
|
| 889 |
+
for m in re.finditer(r"explicit example.*?\((.+?)\)", output, re.IGNORECASE):
|
| 890 |
+
survived.append(m.group(1)[:100])
|
| 891 |
+
return survived[:20]
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
def run_fuzzing_loop(
|
| 895 |
+
pbt: GeneratedPBT,
|
| 896 |
+
target: FuzzTarget,
|
| 897 |
+
repo_dir: str,
|
| 898 |
+
pytest_bin: str,
|
| 899 |
+
) -> tuple[bool, str, str]:
|
| 900 |
+
"""
|
| 901 |
+
Execute the generated PBT via subprocess with hypothesis aggressive settings.
|
| 902 |
+
|
| 903 |
+
Returns:
|
| 904 |
+
(crashed: bool, crash_output: str, falsifying_example: str)
|
| 905 |
+
|
| 906 |
+
Security: shell=False enforced, secrets stripped from env, SIGKILL on timeout.
|
| 907 |
+
"""
|
| 908 |
+
# Write test file
|
| 909 |
+
test_file = _write_pbt_to_file(pbt, target, repo_dir)
|
| 910 |
+
|
| 911 |
+
rte_log(
|
| 912 |
+
f"Fuzzing: {target.profile.function_name} | "
|
| 913 |
+
f"test={os.path.basename(test_file)} | "
|
| 914 |
+
f"max_examples={FUZZ_MAX_EXAMPLES:,} | timeout={FUZZ_TIMEOUT_SECONDS}s",
|
| 915 |
+
"FUZZ"
|
| 916 |
+
)
|
| 917 |
+
|
| 918 |
+
# Build environment — secrets stripped, hypothesis settings injected
|
| 919 |
+
env = os.environ.copy()
|
| 920 |
+
for secret_key in [
|
| 921 |
+
"OPENROUTER_API_KEY", "GITHUB_TOKEN", "GITHUB_PERSONAL_ACCESS_TOKEN",
|
| 922 |
+
"TELEGRAM_BOT_TOKEN", "SLACK_WEBHOOK_URL", "RHODAWK_WEBHOOK_SECRET",
|
| 923 |
+
]:
|
| 924 |
+
env.pop(secret_key, None)
|
| 925 |
+
|
| 926 |
+
# Hypothesis configuration via env (overrides @settings decorator)
|
| 927 |
+
env["HYPOTHESIS_MAX_EXAMPLES"] = str(FUZZ_MAX_EXAMPLES)
|
| 928 |
+
env["HYPOTHESIS_VERBOSITY"] = "verbose"
|
| 929 |
+
# Deterministic but varied seed per CEGIS round
|
| 930 |
+
seed = (hash(target.profile.function_name) + pbt.cegis_round * 7919) % (2**31)
|
| 931 |
+
env["HYPOTHESIS_SEED"] = str(abs(seed))
|
| 932 |
+
|
| 933 |
+
cmd = [
|
| 934 |
+
pytest_bin,
|
| 935 |
+
test_file,
|
| 936 |
+
"-v",
|
| 937 |
+
"--tb=long",
|
| 938 |
+
"--no-header",
|
| 939 |
+
f"--hypothesis-seed={abs(seed)}",
|
| 940 |
+
"-x", # Stop at first failure
|
| 941 |
+
]
|
| 942 |
+
|
| 943 |
+
proc = None
|
| 944 |
+
try:
|
| 945 |
+
proc = subprocess.Popen(
|
| 946 |
+
cmd,
|
| 947 |
+
shell=False,
|
| 948 |
+
cwd=repo_dir,
|
| 949 |
+
stdout=subprocess.PIPE,
|
| 950 |
+
stderr=subprocess.PIPE,
|
| 951 |
+
text=True,
|
| 952 |
+
env=env,
|
| 953 |
+
start_new_session=True,
|
| 954 |
+
)
|
| 955 |
+
try:
|
| 956 |
+
stdout, stderr = proc.communicate(timeout=FUZZ_TIMEOUT_SECONDS)
|
| 957 |
+
except subprocess.TimeoutExpired:
|
| 958 |
+
rte_log(
|
| 959 |
+
f"Fuzz timeout ({FUZZ_TIMEOUT_SECONDS}s) for {target.profile.function_name} — killing",
|
| 960 |
+
"WARN"
|
| 961 |
+
)
|
| 962 |
+
try:
|
| 963 |
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
| 964 |
+
except ProcessLookupError:
|
| 965 |
+
pass
|
| 966 |
+
proc.communicate()
|
| 967 |
+
return False, "TIMEOUT", "timeout"
|
| 968 |
+
|
| 969 |
+
combined = (stdout or "") + "\n" + (stderr or "")
|
| 970 |
+
crashed = proc.returncode != 0
|
| 971 |
+
|
| 972 |
+
if crashed:
|
| 973 |
+
falsifying = _extract_falsifying_example(combined)
|
| 974 |
+
crash_type = _extract_crash_type(combined)
|
| 975 |
+
rte_log(
|
| 976 |
+
f"CRASH FOUND in {target.profile.function_name}: {crash_type} | "
|
| 977 |
+
f"example: {falsifying[:80]}",
|
| 978 |
+
"CRASH"
|
| 979 |
+
)
|
| 980 |
+
return True, combined, falsifying
|
| 981 |
+
else:
|
| 982 |
+
rte_log(
|
| 983 |
+
f"No crash found in {target.profile.function_name} "
|
| 984 |
+
f"after {FUZZ_MAX_EXAMPLES:,} examples",
|
| 985 |
+
"FUZZ"
|
| 986 |
+
)
|
| 987 |
+
return False, combined, ""
|
| 988 |
+
|
| 989 |
+
except Exception as e:
|
| 990 |
+
rte_log(f"Fuzzing subprocess error for {target.profile.function_name}: {e}", "FAIL")
|
| 991 |
+
return False, str(e), ""
|
| 992 |
+
finally:
|
| 993 |
+
# Clean up test file if no crash (keep if crash for audit trail)
|
| 994 |
+
if proc and proc.returncode == 0:
|
| 995 |
+
try:
|
| 996 |
+
os.unlink(test_file)
|
| 997 |
+
except OSError:
|
| 998 |
+
pass
|
| 999 |
+
|
| 1000 |
+
|
| 1001 |
+
# ──────────────────────────────────────────────────────────────
|
| 1002 |
+
# SECTION 4: CEGIS HANDOFF (RED → BLUE TEAM)
|
| 1003 |
+
# ──────────────────────────────────────────────────────────────
|
| 1004 |
+
|
| 1005 |
+
def _build_synthetic_failing_test(crash: CrashPayload, repo_dir: str) -> str:
|
| 1006 |
+
"""
|
| 1007 |
+
Rewrite the PBT crash as a DETERMINISTIC pytest that always reproduces
|
| 1008 |
+
the crash using the exact falsifying example found by hypothesis.
|
| 1009 |
+
|
| 1010 |
+
This is what gets handed to the Blue Team — a concrete, reproducible
|
| 1011 |
+
failing test with the minimal crashing input baked in.
|
| 1012 |
+
"""
|
| 1013 |
+
p = crash.target.profile
|
| 1014 |
+
example = crash.falsifying_example
|
| 1015 |
+
|
| 1016 |
+
# Parse the falsifying example into argument assignments
|
| 1017 |
+
# hypothesis formats it as: x=42, y=-1, s='hello'
|
| 1018 |
+
arg_setup_lines = []
|
| 1019 |
+
example_clean = example.strip().rstrip(")")
|
| 1020 |
+
for part in re.split(r",\s*(?=[a-zA-Z_]\w*=)", example_clean):
|
| 1021 |
+
part = part.strip()
|
| 1022 |
+
if "=" in part:
|
| 1023 |
+
arg_setup_lines.append(f" {part}")
|
| 1024 |
+
|
| 1025 |
+
if not arg_setup_lines:
|
| 1026 |
+
# Fallback: use the raw example as a comment
|
| 1027 |
+
arg_setup_lines = [f" # Falsifying example: {example}"]
|
| 1028 |
+
arg_call = ", ".join(f"None" for _ in p.arg_types)
|
| 1029 |
+
else:
|
| 1030 |
+
arg_call = ", ".join(
|
| 1031 |
+
part.strip().split("=")[0] for part in arg_setup_lines if "=" in part
|
| 1032 |
+
)
|
| 1033 |
+
|
| 1034 |
+
module_import_path = p.module_path.replace("/", ".").replace(".py", "").replace("\\", ".")
|
| 1035 |
+
|
| 1036 |
+
test_content = textwrap.dedent(f"""
|
| 1037 |
+
\"\"\"
|
| 1038 |
+
Rhodawk AI — Synthetic Zero-Day Reproduction Test
|
| 1039 |
+
==================================================
|
| 1040 |
+
AUTO-GENERATED by Red Team Fuzzer (CEGIS Engine)
|
| 1041 |
+
DO NOT EDIT — this file is managed by Rhodawk AI.
|
| 1042 |
+
|
| 1043 |
+
Target: {p.function_name} in {p.module_path}
|
| 1044 |
+
Crash type: {crash.crash_type}
|
| 1045 |
+
Falsifying example: {crash.falsifying_example[:200]}
|
| 1046 |
+
Invariant violated: {crash.pbt.invariant_description[:200]}
|
| 1047 |
+
CEGIS rounds taken: {crash.cegis_rounds_taken}
|
| 1048 |
+
Crash hash: {crash.crash_hash}
|
| 1049 |
+
Discovered: {crash.discovered_at}
|
| 1050 |
+
\"\"\"
|
| 1051 |
+
|
| 1052 |
+
import sys
|
| 1053 |
+
import os
|
| 1054 |
+
import pytest
|
| 1055 |
+
|
| 1056 |
+
# Inject repo root for imports
|
| 1057 |
+
_REPO_DIR = {repr(repo_dir)}
|
| 1058 |
+
if _REPO_DIR not in sys.path:
|
| 1059 |
+
sys.path.insert(0, _REPO_DIR)
|
| 1060 |
+
for _sub in ["src", "lib", "core", "app"]:
|
| 1061 |
+
_p = os.path.join(_REPO_DIR, _sub)
|
| 1062 |
+
if os.path.isdir(_p) and _p not in sys.path:
|
| 1063 |
+
sys.path.insert(0, _p)
|
| 1064 |
+
|
| 1065 |
+
try:
|
| 1066 |
+
from {module_import_path} import {p.function_name}
|
| 1067 |
+
except ImportError as _e:
|
| 1068 |
+
pytest.skip(f"Cannot import target: {{_e}}")
|
| 1069 |
+
|
| 1070 |
+
|
| 1071 |
+
def test_rhodawk_zero_day_{p.function_name}_{crash.crash_hash[:8]}():
|
| 1072 |
+
\"\"\"
|
| 1073 |
+
Zero-day reproduction test synthesized by Rhodawk AI Red Team.
|
| 1074 |
+
Crash type: {crash.crash_type}
|
| 1075 |
+
Invariant: {crash.pbt.invariant_description[:120]}
|
| 1076 |
+
\"\"\"
|
| 1077 |
+
# Minimal falsifying example found by hypothesis after {FUZZ_MAX_EXAMPLES:,} iterations
|
| 1078 |
+
# CEGIS round {crash.cegis_rounds_taken} — this input crashes the function
|
| 1079 |
+
{chr(10).join(arg_setup_lines)}
|
| 1080 |
+
|
| 1081 |
+
# This call should NOT raise — if it does, the vulnerability is confirmed
|
| 1082 |
+
# Blue Team: fix the implementation so this assertion holds for all inputs
|
| 1083 |
+
try:
|
| 1084 |
+
result = {p.function_name}({arg_call})
|
| 1085 |
+
# If the crash was an assertion in the PBT, re-check the invariant
|
| 1086 |
+
# The Blue Team must make this deterministic test pass
|
| 1087 |
+
assert result is not None or result is None, (
|
| 1088 |
+
f"Function returned unexpected result: {{result!r}}"
|
| 1089 |
+
)
|
| 1090 |
+
except (OverflowError, RecursionError, ZeroDivisionError,
|
| 1091 |
+
IndexError, ValueError, TypeError) as e:
|
| 1092 |
+
pytest.fail(
|
| 1093 |
+
f"{{type(e).__name__}} raised for input ({arg_call}): {{e}}\\n"
|
| 1094 |
+
f"Crash type: {crash.crash_type}\\n"
|
| 1095 |
+
f"Original falsifying example: {crash.falsifying_example[:200]}"
|
| 1096 |
+
)
|
| 1097 |
+
""").lstrip()
|
| 1098 |
+
|
| 1099 |
+
return test_content
|
| 1100 |
+
|
| 1101 |
+
|
| 1102 |
+
def package_crash_for_blue_team(
|
| 1103 |
+
target: FuzzTarget,
|
| 1104 |
+
pbt: GeneratedPBT,
|
| 1105 |
+
crash_output: str,
|
| 1106 |
+
falsifying_example: str,
|
| 1107 |
+
cegis_rounds: int,
|
| 1108 |
+
repo_dir: str,
|
| 1109 |
+
) -> CrashPayload:
|
| 1110 |
+
"""
|
| 1111 |
+
Package all crash data into a CrashPayload and write the synthetic
|
| 1112 |
+
deterministic failing test to the red_team/ directory.
|
| 1113 |
+
"""
|
| 1114 |
+
crash_raw = falsifying_example + crash_output
|
| 1115 |
+
crash_hash = hashlib.sha256(crash_raw.encode()).hexdigest()[:16]
|
| 1116 |
+
crash_type = _extract_crash_type(crash_output)
|
| 1117 |
+
|
| 1118 |
+
# Write synthetic deterministic failing test
|
| 1119 |
+
fn_safe = target.profile.function_name.replace("-", "_")
|
| 1120 |
+
synthetic_filename = f"test_rt_zero_day_{fn_safe}_{crash_hash}.py"
|
| 1121 |
+
synthetic_path = os.path.join(RED_TEAM_DIR, synthetic_filename)
|
| 1122 |
+
|
| 1123 |
+
payload = CrashPayload(
|
| 1124 |
+
target=target,
|
| 1125 |
+
pbt=pbt,
|
| 1126 |
+
falsifying_example=falsifying_example,
|
| 1127 |
+
crash_output=crash_output,
|
| 1128 |
+
crash_type=crash_type,
|
| 1129 |
+
crash_hash=crash_hash,
|
| 1130 |
+
synthetic_test_path=synthetic_path,
|
| 1131 |
+
source_file_path=os.path.join(repo_dir, target.profile.module_path),
|
| 1132 |
+
discovered_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 1133 |
+
cegis_rounds_taken=cegis_rounds,
|
| 1134 |
+
)
|
| 1135 |
+
|
| 1136 |
+
synthetic_content = _build_synthetic_failing_test(payload, repo_dir)
|
| 1137 |
+
|
| 1138 |
+
os.makedirs(RED_TEAM_DIR, exist_ok=True)
|
| 1139 |
+
with open(synthetic_path, "w", encoding="utf-8") as f:
|
| 1140 |
+
f.write(synthetic_content)
|
| 1141 |
+
|
| 1142 |
+
rte_log(
|
| 1143 |
+
f"HANDOFF READY: {synthetic_filename} | "
|
| 1144 |
+
f"crash_type={crash_type} | hash={crash_hash}",
|
| 1145 |
+
"HAND"
|
| 1146 |
+
)
|
| 1147 |
+
|
| 1148 |
+
return payload
|
| 1149 |
+
|
| 1150 |
+
|
| 1151 |
+
def handoff_to_blue_team(
|
| 1152 |
+
crash: CrashPayload,
|
| 1153 |
+
repo_dir: str,
|
| 1154 |
+
pytest_bin: str,
|
| 1155 |
+
mcp_config_path: str,
|
| 1156 |
+
job_id: str,
|
| 1157 |
+
branch_name: str,
|
| 1158 |
+
blue_team_fn: Callable,
|
| 1159 |
+
) -> dict:
|
| 1160 |
+
"""
|
| 1161 |
+
Execute the CEGIS Handoff — pass the synthetic failing test to the
|
| 1162 |
+
Blue Team's process_failing_test() function for autonomous patching.
|
| 1163 |
+
|
| 1164 |
+
The Blue Team treats this exactly like a human-written failing test:
|
| 1165 |
+
it runs SAST, adversarial review, supply chain scan, and opens a PR.
|
| 1166 |
+
"""
|
| 1167 |
+
rte_log(
|
| 1168 |
+
f"CEGIS HANDOFF → Blue Team: {crash.target.profile.function_name} | "
|
| 1169 |
+
f"crash_type={crash.crash_type} | source={crash.source_file_path}",
|
| 1170 |
+
"HAND"
|
| 1171 |
+
)
|
| 1172 |
+
|
| 1173 |
+
# First verify the synthetic test actually fails (confirms reproducibility)
|
| 1174 |
+
env = os.environ.copy()
|
| 1175 |
+
for secret_key in ["OPENROUTER_API_KEY", "GITHUB_TOKEN", "GITHUB_PERSONAL_ACCESS_TOKEN"]:
|
| 1176 |
+
env.pop(secret_key, None)
|
| 1177 |
+
|
| 1178 |
+
verify_proc = subprocess.run(
|
| 1179 |
+
[pytest_bin, crash.synthetic_test_path, "-v", "--tb=short", "--no-header"],
|
| 1180 |
+
capture_output=True,
|
| 1181 |
+
text=True,
|
| 1182 |
+
timeout=60,
|
| 1183 |
+
cwd=repo_dir,
|
| 1184 |
+
env=env,
|
| 1185 |
+
shell=False,
|
| 1186 |
+
)
|
| 1187 |
+
initial_failure_output = (verify_proc.stdout or "") + "\n" + (verify_proc.stderr or "")
|
| 1188 |
+
|
| 1189 |
+
if verify_proc.returncode == 0:
|
| 1190 |
+
rte_log(
|
| 1191 |
+
f"WARNING: Synthetic test PASSED on first run — crash may not be deterministic. "
|
| 1192 |
+
f"Reporting anyway for human review.",
|
| 1193 |
+
"WARN"
|
| 1194 |
+
)
|
| 1195 |
+
initial_failure_output = (
|
| 1196 |
+
f"WARNING: Non-deterministic crash detected.\n"
|
| 1197 |
+
f"Original crash output:\n{crash.crash_output[:2000]}\n"
|
| 1198 |
+
f"Falsifying example: {crash.falsifying_example}"
|
| 1199 |
+
)
|
| 1200 |
+
|
| 1201 |
+
# Relative path of the synthetic test within repo context
|
| 1202 |
+
rel_synthetic_test = os.path.relpath(crash.synthetic_test_path, repo_dir)
|
| 1203 |
+
|
| 1204 |
+
rte_log(f"Dispatching Blue Team on: {rel_synthetic_test}", "HAND")
|
| 1205 |
+
|
| 1206 |
+
# Call the Blue Team process_failing_test function
|
| 1207 |
+
try:
|
| 1208 |
+
blue_result = blue_team_fn(
|
| 1209 |
+
test_path=rel_synthetic_test,
|
| 1210 |
+
initial_failure=initial_failure_output,
|
| 1211 |
+
pytest_bin=pytest_bin,
|
| 1212 |
+
mcp_config_path=mcp_config_path,
|
| 1213 |
+
job_id=job_id,
|
| 1214 |
+
branch_name=branch_name,
|
| 1215 |
+
)
|
| 1216 |
+
|
| 1217 |
+
handoff_result = {
|
| 1218 |
+
"crash_hash": crash.crash_hash,
|
| 1219 |
+
"crash_type": crash.crash_type,
|
| 1220 |
+
"target_function": crash.target.profile.function_name,
|
| 1221 |
+
"source_file": crash.target.profile.module_path,
|
| 1222 |
+
"synthetic_test": rel_synthetic_test,
|
| 1223 |
+
"blue_team_success": blue_result.success,
|
| 1224 |
+
"blue_team_attempts": blue_result.total_attempts,
|
| 1225 |
+
"failure_reason": blue_result.failure_reason,
|
| 1226 |
+
"cegis_rounds": crash.cegis_rounds_taken,
|
| 1227 |
+
"falsifying_example": crash.falsifying_example[:300],
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
status = "PATCHED" if blue_result.success else "UNRESOLVED"
|
| 1231 |
+
rte_log(
|
| 1232 |
+
f"Blue Team result: {status} | "
|
| 1233 |
+
f"attempts={blue_result.total_attempts} | "
|
| 1234 |
+
f"crash={crash.crash_type} | fn={crash.target.profile.function_name}",
|
| 1235 |
+
"OK" if blue_result.success else "FAIL"
|
| 1236 |
+
)
|
| 1237 |
+
|
| 1238 |
+
return handoff_result
|
| 1239 |
+
|
| 1240 |
+
except Exception as e:
|
| 1241 |
+
rte_log(f"Blue Team handoff exception: {e}", "FAIL")
|
| 1242 |
+
return {
|
| 1243 |
+
"crash_hash": crash.crash_hash,
|
| 1244 |
+
"crash_type": crash.crash_type,
|
| 1245 |
+
"target_function": crash.target.profile.function_name,
|
| 1246 |
+
"blue_team_success": False,
|
| 1247 |
+
"failure_reason": str(e),
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
+
|
| 1251 |
+
# ──────────────────────────────────────────────────────────────
|
| 1252 |
+
# SECTION 5: MAIN CEGIS ORCHESTRATOR
|
| 1253 |
+
# ──────────────────────────────────────────────────────────────
|
| 1254 |
+
|
| 1255 |
+
def run_red_team_cegis(
|
| 1256 |
+
repo_dir: str,
|
| 1257 |
+
pytest_bin: str,
|
| 1258 |
+
mcp_config_path: str,
|
| 1259 |
+
blue_team_fn: Callable,
|
| 1260 |
+
tenant_id: str = "default",
|
| 1261 |
+
log_audit_fn: Optional[Callable] = None,
|
| 1262 |
+
notify_fn: Optional[Callable] = None,
|
| 1263 |
+
) -> RedTeamResult:
|
| 1264 |
+
"""
|
| 1265 |
+
Full CEGIS Red Team orchestration loop.
|
| 1266 |
+
|
| 1267 |
+
Called by app.py when all tests in a repository are GREEN.
|
| 1268 |
+
Returns a RedTeamResult summarizing all crashes found and Blue Team outcomes.
|
| 1269 |
+
|
| 1270 |
+
Args:
|
| 1271 |
+
repo_dir: Absolute path to the cloned target repository
|
| 1272 |
+
pytest_bin: Path to pytest binary in the isolated venv
|
| 1273 |
+
mcp_config_path: Path to MCP runtime config for Blue Team Aider
|
| 1274 |
+
blue_team_fn: process_failing_test() from app.py (Blue Team entry point)
|
| 1275 |
+
tenant_id: Namespace for job queue
|
| 1276 |
+
log_audit_fn: log_audit_event() from audit_logger.py
|
| 1277 |
+
notify_fn: notify() from notifier.py
|
| 1278 |
+
"""
|
| 1279 |
+
start_time = time.time()
|
| 1280 |
+
rte_log("═" * 70, "INFO")
|
| 1281 |
+
rte_log(f"RED TEAM CEGIS ENGINE ACTIVATED — repo: {repo_dir}", "ATTACK")
|
| 1282 |
+
rte_log(f"Config: max_targets={MAX_TARGETS_PER_RUN} | max_examples={FUZZ_MAX_EXAMPLES:,} | cegis_rounds={MAX_CEGIS_ROUNDS}", "INFO")
|
| 1283 |
+
|
| 1284 |
+
result = RedTeamResult(repo_dir=repo_dir, targets_analyzed=0)
|
| 1285 |
+
|
| 1286 |
+
# Ensure hypothesis is available
|
| 1287 |
+
if not _install_hypothesis_if_needed(pytest_bin, repo_dir):
|
| 1288 |
+
rte_log("hypothesis not available — Red Team cannot run without it", "FAIL")
|
| 1289 |
+
return result
|
| 1290 |
+
|
| 1291 |
+
# Step 1: AST Analysis — find attack targets
|
| 1292 |
+
try:
|
| 1293 |
+
targets = analyze_repository_ast(repo_dir)
|
| 1294 |
+
except Exception as e:
|
| 1295 |
+
rte_log(f"AST analysis crashed: {e}", "FAIL")
|
| 1296 |
+
return result
|
| 1297 |
+
|
| 1298 |
+
result.targets_analyzed = len(targets)
|
| 1299 |
+
|
| 1300 |
+
if not targets:
|
| 1301 |
+
rte_log("No suitable attack targets found in repository", "WARN")
|
| 1302 |
+
return result
|
| 1303 |
+
|
| 1304 |
+
if log_audit_fn:
|
| 1305 |
+
log_audit_fn("RED_TEAM_START", "red_team_engine", repo_dir, RED_TEAM_MODEL, {
|
| 1306 |
+
"targets_found": len(targets),
|
| 1307 |
+
"top_target": targets[0].profile.function_name if targets else "none",
|
| 1308 |
+
"top_priority": targets[0].attack_priority if targets else 0,
|
| 1309 |
+
}, "STARTED")
|
| 1310 |
+
|
| 1311 |
+
if notify_fn:
|
| 1312 |
+
notify_fn(
|
| 1313 |
+
f"⚔️ *Red Team CEGIS Activated*\n"
|
| 1314 |
+
f"All tests GREEN — attacking {len(targets)} high-value function(s).\n"
|
| 1315 |
+
f"Top target: `{targets[0].profile.function_name}` "
|
| 1316 |
+
f"(priority={targets[0].attack_priority:.2f})"
|
| 1317 |
+
)
|
| 1318 |
+
|
| 1319 |
+
# Step 2: CEGIS attack loop for each target
|
| 1320 |
+
for target_idx, target in enumerate(targets):
|
| 1321 |
+
rte_log(
|
| 1322 |
+
f"Target {target_idx+1}/{len(targets)}: {target.profile.function_name} "
|
| 1323 |
+
f"| priority={target.attack_priority:.3f} "
|
| 1324 |
+
f"| invariants={','.join(target.invariant_classes[:3])}",
|
| 1325 |
+
"ATTACK"
|
| 1326 |
+
)
|
| 1327 |
+
|
| 1328 |
+
survived_inputs: list[str] = []
|
| 1329 |
+
crash_found = False
|
| 1330 |
+
|
| 1331 |
+
for cegis_round in range(1, MAX_CEGIS_ROUNDS + 1):
|
| 1332 |
+
rte_log(f"CEGIS round {cegis_round}/{MAX_CEGIS_ROUNDS} for {target.profile.function_name}", "CEGIS")
|
| 1333 |
+
|
| 1334 |
+
# Synthesize PBT — use strong model on final rounds
|
| 1335 |
+
use_strong = (cegis_round >= MAX_CEGIS_ROUNDS - 1)
|
| 1336 |
+
pbt = synthesize_pbt(target, cegis_round, survived_inputs, use_strong)
|
| 1337 |
+
|
| 1338 |
+
if pbt is None:
|
| 1339 |
+
rte_log(f"PBT synthesis failed for {target.profile.function_name} round {cegis_round}", "WARN")
|
| 1340 |
+
break
|
| 1341 |
+
|
| 1342 |
+
result.total_fuzz_examples += FUZZ_MAX_EXAMPLES
|
| 1343 |
+
result.cegis_rounds += 1
|
| 1344 |
+
|
| 1345 |
+
# Run the fuzzer
|
| 1346 |
+
crashed, crash_output, falsifying_example = run_fuzzing_loop(
|
| 1347 |
+
pbt, target, repo_dir, pytest_bin
|
| 1348 |
+
)
|
| 1349 |
+
|
| 1350 |
+
if crashed and falsifying_example != "timeout":
|
| 1351 |
+
# CRASH FOUND — package and hand off to Blue Team
|
| 1352 |
+
crash_payload = package_crash_for_blue_team(
|
| 1353 |
+
target=target,
|
| 1354 |
+
pbt=pbt,
|
| 1355 |
+
crash_output=crash_output,
|
| 1356 |
+
falsifying_example=falsifying_example,
|
| 1357 |
+
cegis_rounds=cegis_round,
|
| 1358 |
+
repo_dir=repo_dir,
|
| 1359 |
+
)
|
| 1360 |
+
result.crashes_found.append(crash_payload)
|
| 1361 |
+
|
| 1362 |
+
if log_audit_fn:
|
| 1363 |
+
log_audit_fn("RED_TEAM_CRASH", "red_team_engine", repo_dir, RED_TEAM_MODEL, {
|
| 1364 |
+
"function": target.profile.function_name,
|
| 1365 |
+
"module": target.profile.module_path,
|
| 1366 |
+
"crash_type": crash_payload.crash_type,
|
| 1367 |
+
"crash_hash": crash_payload.crash_hash,
|
| 1368 |
+
"cegis_round": cegis_round,
|
| 1369 |
+
"falsifying_example": falsifying_example[:200],
|
| 1370 |
+
"invariant": pbt.invariant_description[:100],
|
| 1371 |
+
}, "CRASH_FOUND")
|
| 1372 |
+
|
| 1373 |
+
if notify_fn:
|
| 1374 |
+
notify_fn(
|
| 1375 |
+
f"💥 *Zero-Day Discovered*\n"
|
| 1376 |
+
f"Function: `{target.profile.function_name}` in `{target.profile.module_path}`\n"
|
| 1377 |
+
f"Crash type: `{crash_payload.crash_type}`\n"
|
| 1378 |
+
f"Input: `{falsifying_example[:150]}`\n"
|
| 1379 |
+
f"Handing to Blue Team for autonomous patching..."
|
| 1380 |
+
)
|
| 1381 |
+
|
| 1382 |
+
# CEGIS HANDOFF — dispatch Blue Team
|
| 1383 |
+
job_id_rt = hashlib.sha256(
|
| 1384 |
+
f"{repo_dir}:{target.profile.function_name}:{crash_payload.crash_hash}".encode()
|
| 1385 |
+
).hexdigest()[:16]
|
| 1386 |
+
branch_name_rt = (
|
| 1387 |
+
f"rhodawk/red-team/{target.profile.function_name.replace('_','-')}"
|
| 1388 |
+
f"-{crash_payload.crash_hash}"
|
| 1389 |
+
)
|
| 1390 |
+
|
| 1391 |
+
handoff_result = handoff_to_blue_team(
|
| 1392 |
+
crash=crash_payload,
|
| 1393 |
+
repo_dir=repo_dir,
|
| 1394 |
+
pytest_bin=pytest_bin,
|
| 1395 |
+
mcp_config_path=mcp_config_path,
|
| 1396 |
+
job_id=job_id_rt,
|
| 1397 |
+
branch_name=branch_name_rt,
|
| 1398 |
+
blue_team_fn=blue_team_fn,
|
| 1399 |
+
)
|
| 1400 |
+
result.handoff_results.append(handoff_result)
|
| 1401 |
+
|
| 1402 |
+
if log_audit_fn:
|
| 1403 |
+
log_audit_fn("RED_TEAM_HANDOFF", "red_team_engine", repo_dir, RED_TEAM_MODEL, {
|
| 1404 |
+
**handoff_result,
|
| 1405 |
+
}, "PATCHED" if handoff_result.get("blue_team_success") else "UNRESOLVED")
|
| 1406 |
+
|
| 1407 |
+
crash_found = True
|
| 1408 |
+
break
|
| 1409 |
+
|
| 1410 |
+
else:
|
| 1411 |
+
# No crash — feed survived inputs back for next CEGIS round
|
| 1412 |
+
new_survived = _extract_survived_inputs(crash_output)
|
| 1413 |
+
survived_inputs.extend(new_survived)
|
| 1414 |
+
survived_inputs = survived_inputs[:30]
|
| 1415 |
+
rte_log(
|
| 1416 |
+
f"Survived {cegis_round}/{MAX_CEGIS_ROUNDS} — "
|
| 1417 |
+
f"re-attacking with {len(survived_inputs)} survived input patterns",
|
| 1418 |
+
"CEGIS"
|
| 1419 |
+
)
|
| 1420 |
+
|
| 1421 |
+
if not crash_found:
|
| 1422 |
+
result.targets_survived.append(
|
| 1423 |
+
f"{target.profile.function_name} ({target.profile.module_path})"
|
| 1424 |
+
)
|
| 1425 |
+
rte_log(
|
| 1426 |
+
f"Target SURVIVED all {MAX_CEGIS_ROUNDS} CEGIS rounds: {target.profile.function_name}",
|
| 1427 |
+
"OK"
|
| 1428 |
+
)
|
| 1429 |
+
|
| 1430 |
+
# Summary
|
| 1431 |
+
result.duration_seconds = time.time() - start_time
|
| 1432 |
+
rte_log("═" * 70, "INFO")
|
| 1433 |
+
rte_log(
|
| 1434 |
+
f"RED TEAM COMPLETE — "
|
| 1435 |
+
f"targets={result.targets_analyzed} | "
|
| 1436 |
+
f"crashes={len(result.crashes_found)} | "
|
| 1437 |
+
f"survived={len(result.targets_survived)} | "
|
| 1438 |
+
f"fuzz_examples={result.total_fuzz_examples:,} | "
|
| 1439 |
+
f"duration={result.duration_seconds:.1f}s",
|
| 1440 |
+
"CRASH" if result.crashes_found else "OK"
|
| 1441 |
+
)
|
| 1442 |
+
|
| 1443 |
+
if log_audit_fn:
|
| 1444 |
+
log_audit_fn("RED_TEAM_COMPLETE", "red_team_engine", repo_dir, RED_TEAM_MODEL, {
|
| 1445 |
+
"targets_analyzed": result.targets_analyzed,
|
| 1446 |
+
"crashes_found": len(result.crashes_found),
|
| 1447 |
+
"targets_survived": len(result.targets_survived),
|
| 1448 |
+
"total_fuzz_examples": result.total_fuzz_examples,
|
| 1449 |
+
"duration_seconds": round(result.duration_seconds, 2),
|
| 1450 |
+
"cegis_rounds": result.cegis_rounds,
|
| 1451 |
+
"crash_hashes": [c.crash_hash for c in result.crashes_found],
|
| 1452 |
+
}, "COMPLETE")
|
| 1453 |
+
|
| 1454 |
+
if notify_fn:
|
| 1455 |
+
if result.crashes_found:
|
| 1456 |
+
notify_fn(
|
| 1457 |
+
f"⚔️ *Red Team Report*\n"
|
| 1458 |
+
f"Found {len(result.crashes_found)} zero-day(s) in GREEN repo!\n"
|
| 1459 |
+
f"Fuzz examples: {result.total_fuzz_examples:,} | "
|
| 1460 |
+
f"Duration: {result.duration_seconds:.0f}s\n"
|
| 1461 |
+
f"All crashes dispatched to Blue Team for autonomous patching."
|
| 1462 |
+
)
|
| 1463 |
+
else:
|
| 1464 |
+
notify_fn(
|
| 1465 |
+
f"✅ *Red Team: Repo Survived*\n"
|
| 1466 |
+
f"Attacked {result.targets_analyzed} function(s), "
|
| 1467 |
+
f"{result.total_fuzz_examples:,} fuzz examples — no crashes found.\n"
|
| 1468 |
+
f"Repository is HARDENED."
|
| 1469 |
+
)
|
| 1470 |
+
|
| 1471 |
+
return result
|
| 1472 |
+
|
| 1473 |
+
|
| 1474 |
+
# ──────────────────────────────────────────────────────────────
|
| 1475 |
+
# STATS & DASHBOARD HELPERS (called by app.py dashboard)
|
| 1476 |
+
# ──────────────────────────────────────────────────────────────
|
| 1477 |
+
|
| 1478 |
+
def get_red_team_stats() -> dict:
|
| 1479 |
+
"""Return summary statistics for the dashboard."""
|
| 1480 |
+
try:
|
| 1481 |
+
crash_files = [
|
| 1482 |
+
f for f in os.listdir(RED_TEAM_DIR)
|
| 1483 |
+
if f.startswith("test_rt_zero_day_") and f.endswith(".py")
|
| 1484 |
+
] if os.path.isdir(RED_TEAM_DIR) else []
|
| 1485 |
+
|
| 1486 |
+
pbt_files = [
|
| 1487 |
+
f for f in os.listdir(RED_TEAM_DIR)
|
| 1488 |
+
if f.startswith("rt_") and f.endswith(".py")
|
| 1489 |
+
] if os.path.isdir(RED_TEAM_DIR) else []
|
| 1490 |
+
|
| 1491 |
+
return {
|
| 1492 |
+
"zero_days_discovered": len(crash_files),
|
| 1493 |
+
"pbts_generated": len(pbt_files),
|
| 1494 |
+
"red_team_dir": RED_TEAM_DIR,
|
| 1495 |
+
"recent_logs": get_red_team_logs(20),
|
| 1496 |
+
}
|
| 1497 |
+
except Exception:
|
| 1498 |
+
return {
|
| 1499 |
+
"zero_days_discovered": 0,
|
| 1500 |
+
"pbts_generated": 0,
|
| 1501 |
+
"red_team_dir": RED_TEAM_DIR,
|
| 1502 |
+
"recent_logs": "",
|
| 1503 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
requests
|
| 2 |
+
pytest
|
| 3 |
+
gitpython
|
| 4 |
+
gradio>=4.44.0,<5.0.0
|
| 5 |
+
jinja2>=3.1.4
|
| 6 |
+
aider-chat
|
| 7 |
+
ruff
|
| 8 |
+
tenacity
|
| 9 |
+
bandit[toml]
|
| 10 |
+
pip-audit
|
| 11 |
+
radon
|
| 12 |
+
hypothesis[cli]>=6.100.0
|
| 13 |
+
semgrep>=1.45.0
|
| 14 |
+
sentence-transformers>=2.7.0
|
| 15 |
+
sqlite-vec>=0.1.1
|
| 16 |
+
pygithub>=2.3.0
|
| 17 |
+
PyJWT>=2.8.0
|
| 18 |
+
datasets>=2.19.0
|
| 19 |
+
numpy>=1.26.0
|
| 20 |
+
psycopg2-binary>=2.9.9
|
sast_gate.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Pre-PR SAST + Secret Detection Gate
|
| 3 |
+
==================================================
|
| 4 |
+
Every AI-generated diff passes through this gate BEFORE a PR is opened.
|
| 5 |
+
The gate runs:
|
| 6 |
+
1. Bandit — Python SAST for known vulnerability patterns (SQLi, exec, pickle, etc.)
|
| 7 |
+
2. Secret pattern scanning — detects hardcoded credentials, API keys, tokens
|
| 8 |
+
3. Dangerous import detection — flags os.system, eval, __import__, pickle.loads
|
| 9 |
+
|
| 10 |
+
This is the control plane that stops a hallucinating LLM from shipping a vulnerability.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
+
import subprocess
|
| 16 |
+
import tempfile
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ──────────────────────────────────────────────
|
| 22 |
+
# Secret patterns — compiled once at module load
|
| 23 |
+
# ──────────────────────────────────────────────
|
| 24 |
+
_SECRET_PATTERNS = [
|
| 25 |
+
(re.compile(r'(?i)(api[_\-]?key|apikey|secret[_\-]?key|access[_\-]?token|auth[_\-]?token)\s*=\s*["\'][a-z0-9\-_]{16,}["\']'), "Hardcoded API key / token"),
|
| 26 |
+
(re.compile(r'(?i)(password|passwd|pwd)\s*=\s*["\'][^"\']{6,}["\']'), "Hardcoded password"),
|
| 27 |
+
(re.compile(r'(?i)github[_\-]?(token|pat)\s*=\s*["\']gh[pousr]_[a-z0-9]{36,}["\']'), "Hardcoded GitHub PAT"),
|
| 28 |
+
(re.compile(r'(?i)hf_[a-z0-9]{30,}', re.IGNORECASE), "HuggingFace token"),
|
| 29 |
+
(re.compile(r'sk-[a-zA-Z0-9]{32,}'), "OpenAI / OpenRouter API key"),
|
| 30 |
+
(re.compile(r'(?i)(aws[_\-]?access[_\-]?key|AKIA)[A-Z0-9]{16,}'), "AWS access key"),
|
| 31 |
+
(re.compile(r'(?i)aws[_\-]?session[_\-]?token\s*=\s*["\'][A-Za-z0-9/+=]{80,}["\']'), "AWS session token"),
|
| 32 |
+
(re.compile(r'"type"\s*:\s*"service_account"[\s\S]{0,2000}"private_key"\s*:', re.IGNORECASE), "GCP service account JSON"),
|
| 33 |
+
(re.compile(r'-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----'), "Private key in source"),
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
_DANGEROUS_PATTERNS = [
|
| 37 |
+
(re.compile(r'\bos\.system\s*\('), "os.system() call — use subprocess with shell=False"),
|
| 38 |
+
(re.compile(r'\beval\s*\('), "eval() call — arbitrary code execution risk"),
|
| 39 |
+
(re.compile(r'\bexec\s*\('), "exec() call — arbitrary code execution risk"),
|
| 40 |
+
(re.compile(r'\b__import__\s*\('), "__import__() call — dynamic import risk"),
|
| 41 |
+
(re.compile(r'\bpickle\.loads?\s*\('), "pickle.load() — deserialization attack risk"),
|
| 42 |
+
(re.compile(r'\bsubprocess\.call\s*\(.*shell\s*=\s*True'), "subprocess with shell=True — injection risk"),
|
| 43 |
+
(re.compile(r'\bsubprocess\.run\s*\(.*shell\s*=\s*True'), "subprocess.run with shell=True — injection risk"),
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
_INJECTION_PATTERNS = [
|
| 47 |
+
(re.compile(r'f["\'].*SELECT.*\{.*\}.*FROM', re.IGNORECASE), "SQL injection via f-string"),
|
| 48 |
+
(re.compile(r'["\'].*\+.*["\'].*WHERE', re.IGNORECASE), "SQL injection via concatenation"),
|
| 49 |
+
(re.compile(r'\.format\(.*\).*WHERE', re.IGNORECASE), "SQL injection via .format()"),
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class SastFinding:
|
| 55 |
+
severity: str
|
| 56 |
+
category: str
|
| 57 |
+
line_number: int
|
| 58 |
+
line_content: str
|
| 59 |
+
description: str
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class SastReport:
|
| 64 |
+
passed: bool
|
| 65 |
+
findings: list[SastFinding] = field(default_factory=list)
|
| 66 |
+
bandit_output: str = ""
|
| 67 |
+
semgrep_output: str = ""
|
| 68 |
+
blocked_reason: Optional[str] = None
|
| 69 |
+
|
| 70 |
+
def summary(self) -> str:
|
| 71 |
+
if self.passed:
|
| 72 |
+
return f"SAST GATE PASSED — {len(self.findings)} informational findings."
|
| 73 |
+
return f"SAST GATE BLOCKED — {self.blocked_reason} | {len(self.findings)} findings."
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _scan_diff_for_secrets(diff_text: str) -> list[SastFinding]:
|
| 77 |
+
findings = []
|
| 78 |
+
for i, line in enumerate(diff_text.splitlines(), 1):
|
| 79 |
+
if not line.startswith("+"):
|
| 80 |
+
continue
|
| 81 |
+
clean_line = line[1:]
|
| 82 |
+
|
| 83 |
+
for pattern, description in _SECRET_PATTERNS:
|
| 84 |
+
if pattern.search(clean_line):
|
| 85 |
+
findings.append(SastFinding(
|
| 86 |
+
severity="CRITICAL",
|
| 87 |
+
category="SECRET_EXPOSURE",
|
| 88 |
+
line_number=i,
|
| 89 |
+
line_content=clean_line[:120],
|
| 90 |
+
description=description,
|
| 91 |
+
))
|
| 92 |
+
|
| 93 |
+
for pattern, description in _DANGEROUS_PATTERNS:
|
| 94 |
+
if pattern.search(clean_line):
|
| 95 |
+
findings.append(SastFinding(
|
| 96 |
+
severity="HIGH",
|
| 97 |
+
category="DANGEROUS_PATTERN",
|
| 98 |
+
line_number=i,
|
| 99 |
+
line_content=clean_line[:120],
|
| 100 |
+
description=description,
|
| 101 |
+
))
|
| 102 |
+
for pattern, description in _INJECTION_PATTERNS:
|
| 103 |
+
if pattern.search(clean_line):
|
| 104 |
+
findings.append(SastFinding(
|
| 105 |
+
severity="HIGH",
|
| 106 |
+
category="INJECTION_RISK",
|
| 107 |
+
line_number=i,
|
| 108 |
+
line_content=clean_line[:120],
|
| 109 |
+
description=description,
|
| 110 |
+
))
|
| 111 |
+
return findings
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _run_bandit_on_file(file_path: str) -> str:
|
| 115 |
+
try:
|
| 116 |
+
result = subprocess.run(
|
| 117 |
+
["bandit", "-r", file_path, "-f", "text", "-ll"],
|
| 118 |
+
capture_output=True,
|
| 119 |
+
text=True,
|
| 120 |
+
timeout=60,
|
| 121 |
+
shell=False,
|
| 122 |
+
)
|
| 123 |
+
return result.stdout + result.stderr
|
| 124 |
+
except FileNotFoundError:
|
| 125 |
+
return "[bandit not installed — skipped]"
|
| 126 |
+
except subprocess.TimeoutExpired:
|
| 127 |
+
return "[bandit timed out]"
|
| 128 |
+
except Exception as e:
|
| 129 |
+
return f"[bandit error: {e}]"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _run_semgrep_on_file(file_path: str) -> str:
|
| 133 |
+
try:
|
| 134 |
+
result = subprocess.run(
|
| 135 |
+
["semgrep", "--config", "p/ci", "--quiet", "--error", file_path],
|
| 136 |
+
capture_output=True,
|
| 137 |
+
text=True,
|
| 138 |
+
timeout=90,
|
| 139 |
+
shell=False,
|
| 140 |
+
)
|
| 141 |
+
return result.stdout + result.stderr
|
| 142 |
+
except FileNotFoundError:
|
| 143 |
+
return "[semgrep not installed — skipped]"
|
| 144 |
+
except subprocess.TimeoutExpired:
|
| 145 |
+
return "[semgrep timed out]"
|
| 146 |
+
except Exception as e:
|
| 147 |
+
return f"[semgrep error: {e}]"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def run_sast_gate(diff_text: str, changed_files: list[str], repo_dir: str) -> SastReport:
|
| 151 |
+
"""
|
| 152 |
+
Run the full SAST gate on an AI-generated diff.
|
| 153 |
+
Returns SastReport — if passed=False, the PR must NOT be opened.
|
| 154 |
+
"""
|
| 155 |
+
all_findings: list[SastFinding] = []
|
| 156 |
+
|
| 157 |
+
pattern_findings = _scan_diff_for_secrets(diff_text)
|
| 158 |
+
all_findings.extend(pattern_findings)
|
| 159 |
+
|
| 160 |
+
bandit_combined = ""
|
| 161 |
+
semgrep_combined = ""
|
| 162 |
+
for rel_path in changed_files:
|
| 163 |
+
if not rel_path.endswith(".py"):
|
| 164 |
+
continue
|
| 165 |
+
abs_path = os.path.join(repo_dir, rel_path)
|
| 166 |
+
if os.path.exists(abs_path):
|
| 167 |
+
bandit_out = _run_bandit_on_file(abs_path)
|
| 168 |
+
bandit_combined += f"\n--- {rel_path} ---\n{bandit_out}"
|
| 169 |
+
semgrep_out = _run_semgrep_on_file(abs_path)
|
| 170 |
+
semgrep_combined += f"\n--- {rel_path} ---\n{semgrep_out}"
|
| 171 |
+
|
| 172 |
+
critical_findings = [f for f in all_findings if f.severity == "CRITICAL"]
|
| 173 |
+
high_findings = [f for f in all_findings if f.severity == "HIGH"]
|
| 174 |
+
|
| 175 |
+
if critical_findings:
|
| 176 |
+
blocked_reason = f"CRITICAL: {critical_findings[0].description}"
|
| 177 |
+
return SastReport(
|
| 178 |
+
passed=False,
|
| 179 |
+
findings=all_findings,
|
| 180 |
+
bandit_output=bandit_combined,
|
| 181 |
+
semgrep_output=semgrep_combined,
|
| 182 |
+
blocked_reason=blocked_reason,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
if len(high_findings) >= 3:
|
| 186 |
+
blocked_reason = f"{len(high_findings)} HIGH severity findings exceed threshold"
|
| 187 |
+
return SastReport(
|
| 188 |
+
passed=False,
|
| 189 |
+
findings=all_findings,
|
| 190 |
+
bandit_output=bandit_combined,
|
| 191 |
+
semgrep_output=semgrep_combined,
|
| 192 |
+
blocked_reason=blocked_reason,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
return SastReport(
|
| 196 |
+
passed=True,
|
| 197 |
+
findings=all_findings,
|
| 198 |
+
bandit_output=bandit_combined,
|
| 199 |
+
semgrep_output=semgrep_combined,
|
| 200 |
+
)
|
supply_chain.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Supply Chain Security Gate
|
| 3 |
+
========================================
|
| 4 |
+
Every AI-generated diff that touches requirements.txt or any import statement
|
| 5 |
+
passes through this gate before a PR is opened.
|
| 6 |
+
|
| 7 |
+
Capabilities:
|
| 8 |
+
1. pip-audit — CVE scanning against OSV/PyPA advisory database
|
| 9 |
+
2. Typosquatting detection — 50+ known typosquatting patterns vs PyPI top packages
|
| 10 |
+
3. New dependency analysis — flags packages added by the AI that weren't in original
|
| 11 |
+
4. Package metadata validation — checks for packages with no public source repo (red flag)
|
| 12 |
+
|
| 13 |
+
This catches supply chain attacks where an LLM hallucinates a plausible-sounding
|
| 14 |
+
package name that happens to be a malicious clone.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
import requests
|
| 19 |
+
import subprocess
|
| 20 |
+
from dataclasses import dataclass, field
|
| 21 |
+
from datetime import datetime, timezone
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Top PyPI packages that are commonly typosquatted
|
| 26 |
+
_KNOWN_PACKAGES = {
|
| 27 |
+
"requests", "numpy", "pandas", "flask", "django", "fastapi", "sqlalchemy",
|
| 28 |
+
"boto3", "pytest", "setuptools", "pip", "wheel", "cryptography", "pillow",
|
| 29 |
+
"scipy", "matplotlib", "tensorflow", "torch", "scikit-learn", "pydantic",
|
| 30 |
+
"click", "rich", "httpx", "aiohttp", "celery", "redis", "pymongo", "psycopg2",
|
| 31 |
+
"uvicorn", "gunicorn", "twisted", "paramiko", "fabric", "ansible", "docker",
|
| 32 |
+
"kubernetes", "airflow", "prefect", "dask", "ray", "transformers", "openai",
|
| 33 |
+
"anthropic", "langchain", "gradio", "streamlit", "beautifulsoup4", "lxml",
|
| 34 |
+
"selenium", "playwright", "scrapy", "arrow", "pendulum", "pyyaml", "toml",
|
| 35 |
+
"dotenv", "python-dotenv", "jwt", "pyjwt", "bcrypt", "passlib", "itsdangerous",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
# Levenshtein distance threshold for typosquatting detection
|
| 39 |
+
_TYPO_THRESHOLD = 2
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _levenshtein(s1: str, s2: str) -> int:
|
| 43 |
+
if len(s1) < len(s2):
|
| 44 |
+
return _levenshtein(s2, s1)
|
| 45 |
+
if len(s2) == 0:
|
| 46 |
+
return len(s1)
|
| 47 |
+
prev = list(range(len(s2) + 1))
|
| 48 |
+
for i, c1 in enumerate(s1):
|
| 49 |
+
curr = [i + 1]
|
| 50 |
+
for j, c2 in enumerate(s2):
|
| 51 |
+
curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (c1 != c2)))
|
| 52 |
+
prev = curr
|
| 53 |
+
return prev[-1]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _extract_new_packages(diff_text: str, original_requirements: str = "") -> list[str]:
|
| 57 |
+
"""Extract package names added by the AI diff to requirements.txt"""
|
| 58 |
+
added = []
|
| 59 |
+
in_requirements_block = False
|
| 60 |
+
|
| 61 |
+
for line in diff_text.splitlines():
|
| 62 |
+
if "requirements.txt" in line:
|
| 63 |
+
in_requirements_block = True
|
| 64 |
+
if in_requirements_block and line.startswith("+") and not line.startswith("+++"):
|
| 65 |
+
pkg_line = line[1:].strip()
|
| 66 |
+
if pkg_line and not pkg_line.startswith("#"):
|
| 67 |
+
pkg_name = re.split(r"[>=<!~\[]", pkg_line)[0].strip().lower()
|
| 68 |
+
if pkg_name and pkg_name not in original_requirements.lower():
|
| 69 |
+
added.append(pkg_name)
|
| 70 |
+
|
| 71 |
+
return added
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _check_typosquatting(package_name: str) -> Optional[str]:
|
| 75 |
+
"""Check if a package name looks like a typosquat of a known package."""
|
| 76 |
+
pkg = package_name.lower().replace("-", "").replace("_", "")
|
| 77 |
+
|
| 78 |
+
for known in _KNOWN_PACKAGES:
|
| 79 |
+
known_clean = known.lower().replace("-", "").replace("_", "")
|
| 80 |
+
if pkg == known_clean:
|
| 81 |
+
return None # exact match — it's fine
|
| 82 |
+
dist = _levenshtein(pkg, known_clean)
|
| 83 |
+
if 0 < dist <= _TYPO_THRESHOLD and len(pkg) > 3:
|
| 84 |
+
return f"'{package_name}' is {dist} edit(s) from known package '{known}' — possible typosquat"
|
| 85 |
+
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _run_pip_audit(packages: list[str]) -> list[dict]:
|
| 90 |
+
"""Run pip-audit against a list of package names to check for CVEs."""
|
| 91 |
+
if not packages:
|
| 92 |
+
return []
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
result = subprocess.run(
|
| 96 |
+
["pip-audit", "--requirement", "/dev/stdin", "--format=json", "--no-deps"],
|
| 97 |
+
input="\n".join(packages),
|
| 98 |
+
capture_output=True,
|
| 99 |
+
text=True,
|
| 100 |
+
timeout=60,
|
| 101 |
+
shell=False,
|
| 102 |
+
)
|
| 103 |
+
if result.returncode == 0:
|
| 104 |
+
import json
|
| 105 |
+
data = json.loads(result.stdout)
|
| 106 |
+
vulns = []
|
| 107 |
+
for dep in data.get("dependencies", []):
|
| 108 |
+
for vuln in dep.get("vulns", []):
|
| 109 |
+
vulns.append({
|
| 110 |
+
"package": dep["name"],
|
| 111 |
+
"version": dep.get("version", "unknown"),
|
| 112 |
+
"vuln_id": vuln.get("id", ""),
|
| 113 |
+
"description": vuln.get("description", "")[:200],
|
| 114 |
+
"severity": vuln.get("aliases", ["UNKNOWN"])[0] if vuln.get("aliases") else "UNKNOWN",
|
| 115 |
+
})
|
| 116 |
+
return vulns
|
| 117 |
+
except FileNotFoundError:
|
| 118 |
+
pass # pip-audit not installed
|
| 119 |
+
except Exception:
|
| 120 |
+
pass
|
| 121 |
+
|
| 122 |
+
return []
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _check_import_additions(diff_text: str) -> list[str]:
|
| 126 |
+
"""Detect new import statements added by the AI."""
|
| 127 |
+
new_imports = []
|
| 128 |
+
for line in diff_text.splitlines():
|
| 129 |
+
if line.startswith("+") and not line.startswith("+++"):
|
| 130 |
+
clean = line[1:].strip()
|
| 131 |
+
if clean.startswith("import ") or clean.startswith("from "):
|
| 132 |
+
# Extract module name
|
| 133 |
+
match = re.match(r"(?:from|import)\s+(\w+)", clean)
|
| 134 |
+
if match:
|
| 135 |
+
new_imports.append(match.group(1))
|
| 136 |
+
return list(set(new_imports))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _check_package_metadata(package_name: str) -> Optional[str]:
|
| 140 |
+
try:
|
| 141 |
+
resp = requests.get(f"https://pypi.org/pypi/{package_name}/json", timeout=10)
|
| 142 |
+
if resp.status_code == 404:
|
| 143 |
+
return f"Package '{package_name}' not found on PyPI — likely hallucinated"
|
| 144 |
+
resp.raise_for_status()
|
| 145 |
+
data = resp.json()
|
| 146 |
+
info = data.get("info", {})
|
| 147 |
+
urls = info.get("project_urls") or {}
|
| 148 |
+
source_url = urls.get("Source") or urls.get("Source Code") or urls.get("Repository") or info.get("home_page")
|
| 149 |
+
if not source_url:
|
| 150 |
+
return f"Package '{package_name}' has no source repository — possible malicious package"
|
| 151 |
+
upload_times = []
|
| 152 |
+
for releases in (data.get("releases") or {}).values():
|
| 153 |
+
for release in releases:
|
| 154 |
+
ts = release.get("upload_time_iso_8601")
|
| 155 |
+
if ts:
|
| 156 |
+
try:
|
| 157 |
+
upload_times.append(datetime.fromisoformat(ts.replace("Z", "+00:00")))
|
| 158 |
+
except ValueError:
|
| 159 |
+
pass
|
| 160 |
+
if upload_times:
|
| 161 |
+
first_upload = min(upload_times)
|
| 162 |
+
age_days = (datetime.now(timezone.utc) - first_upload).days
|
| 163 |
+
if age_days < 30:
|
| 164 |
+
return f"Package '{package_name}' is only {age_days} day(s) old — suspiciously new dependency"
|
| 165 |
+
except Exception:
|
| 166 |
+
return None
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@dataclass
|
| 171 |
+
class SupplyChainReport:
|
| 172 |
+
passed: bool
|
| 173 |
+
new_packages: list[str] = field(default_factory=list)
|
| 174 |
+
typosquat_findings: list[str] = field(default_factory=list)
|
| 175 |
+
cve_findings: list[dict] = field(default_factory=list)
|
| 176 |
+
new_imports: list[str] = field(default_factory=list)
|
| 177 |
+
metadata_findings: list[str] = field(default_factory=list)
|
| 178 |
+
blocked_reason: Optional[str] = None
|
| 179 |
+
|
| 180 |
+
def summary(self) -> str:
|
| 181 |
+
if self.passed:
|
| 182 |
+
return f"Supply chain OK. Checked {len(self.new_packages)} new package(s)."
|
| 183 |
+
return f"Supply chain BLOCKED: {self.blocked_reason}"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def run_supply_chain_gate(diff_text: str, repo_dir: str = "") -> SupplyChainReport:
|
| 187 |
+
"""
|
| 188 |
+
Run the full supply chain gate on an AI-generated diff.
|
| 189 |
+
Returns SupplyChainReport. If passed=False, the PR must NOT be opened.
|
| 190 |
+
"""
|
| 191 |
+
import os
|
| 192 |
+
original_reqs = ""
|
| 193 |
+
req_path = os.path.join(repo_dir, "requirements.txt") if repo_dir else ""
|
| 194 |
+
if req_path and os.path.exists(req_path):
|
| 195 |
+
try:
|
| 196 |
+
with open(req_path) as f:
|
| 197 |
+
original_reqs = f.read()
|
| 198 |
+
except OSError:
|
| 199 |
+
pass
|
| 200 |
+
|
| 201 |
+
new_packages = _extract_new_packages(diff_text, original_reqs)
|
| 202 |
+
new_imports = _check_import_additions(diff_text)
|
| 203 |
+
|
| 204 |
+
typosquat_findings = []
|
| 205 |
+
for pkg in new_packages:
|
| 206 |
+
finding = _check_typosquatting(pkg)
|
| 207 |
+
if finding:
|
| 208 |
+
typosquat_findings.append(finding)
|
| 209 |
+
for imp in new_imports:
|
| 210 |
+
finding = _check_typosquatting(imp)
|
| 211 |
+
if finding:
|
| 212 |
+
typosquat_findings.append(f"[import] {finding}")
|
| 213 |
+
|
| 214 |
+
if typosquat_findings:
|
| 215 |
+
return SupplyChainReport(
|
| 216 |
+
passed=False,
|
| 217 |
+
new_packages=new_packages,
|
| 218 |
+
typosquat_findings=typosquat_findings,
|
| 219 |
+
new_imports=new_imports,
|
| 220 |
+
blocked_reason=f"Typosquatting detected: {typosquat_findings[0]}",
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
cve_findings = _run_pip_audit(new_packages)
|
| 224 |
+
critical_cves = [c for c in cve_findings if "CVE" in c.get("vuln_id", "")]
|
| 225 |
+
|
| 226 |
+
metadata_findings = []
|
| 227 |
+
for pkg in new_packages:
|
| 228 |
+
finding = _check_package_metadata(pkg)
|
| 229 |
+
if finding:
|
| 230 |
+
metadata_findings.append(finding)
|
| 231 |
+
|
| 232 |
+
if metadata_findings:
|
| 233 |
+
return SupplyChainReport(
|
| 234 |
+
passed=False,
|
| 235 |
+
new_packages=new_packages,
|
| 236 |
+
cve_findings=cve_findings,
|
| 237 |
+
new_imports=new_imports,
|
| 238 |
+
metadata_findings=metadata_findings,
|
| 239 |
+
blocked_reason=f"Package metadata risk: {metadata_findings[0]}",
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
if critical_cves:
|
| 243 |
+
return SupplyChainReport(
|
| 244 |
+
passed=False,
|
| 245 |
+
new_packages=new_packages,
|
| 246 |
+
cve_findings=cve_findings,
|
| 247 |
+
new_imports=new_imports,
|
| 248 |
+
metadata_findings=metadata_findings,
|
| 249 |
+
blocked_reason=f"CVE found in added package '{critical_cves[0]['package']}': {critical_cves[0]['vuln_id']}",
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
return SupplyChainReport(
|
| 253 |
+
passed=True,
|
| 254 |
+
new_packages=new_packages,
|
| 255 |
+
cve_findings=cve_findings,
|
| 256 |
+
new_imports=new_imports,
|
| 257 |
+
metadata_findings=metadata_findings,
|
| 258 |
+
)
|
swebench_harness.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — SWE-bench Verified Evaluation Harness
|
| 3 |
+
===================================================
|
| 4 |
+
Runs Rhodawk-compatible evaluations against SWE-bench Verified and writes
|
| 5 |
+
machine-readable plus investor-ready reports.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import subprocess
|
| 12 |
+
import time
|
| 13 |
+
from dataclasses import dataclass, asdict
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
SWEBENCH_DATASET = "princeton-nlp/SWE-bench_Verified"
|
| 17 |
+
RESULTS_PATH = "/data/swebench_results.json"
|
| 18 |
+
REPORT_PATH = "/data/swebench_report.md"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class SwebenchOutcome:
|
| 23 |
+
instance_id: str
|
| 24 |
+
repo: str
|
| 25 |
+
resolved: bool
|
| 26 |
+
duration_seconds: float
|
| 27 |
+
error: str = ""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def evaluate_single_instance(instance: dict[str, Any]) -> SwebenchOutcome:
|
| 31 |
+
start = time.time()
|
| 32 |
+
instance_id = instance.get("instance_id", "unknown")
|
| 33 |
+
repo = instance.get("repo", "unknown")
|
| 34 |
+
try:
|
| 35 |
+
command = os.getenv("RHODAWK_SWEBENCH_COMMAND")
|
| 36 |
+
if not command:
|
| 37 |
+
return SwebenchOutcome(
|
| 38 |
+
instance_id=instance_id,
|
| 39 |
+
repo=repo,
|
| 40 |
+
resolved=False,
|
| 41 |
+
duration_seconds=time.time() - start,
|
| 42 |
+
error="RHODAWK_SWEBENCH_COMMAND is not configured",
|
| 43 |
+
)
|
| 44 |
+
payload = json.dumps(instance)
|
| 45 |
+
proc = subprocess.run(
|
| 46 |
+
command.split(),
|
| 47 |
+
input=payload,
|
| 48 |
+
capture_output=True,
|
| 49 |
+
text=True,
|
| 50 |
+
timeout=int(os.getenv("RHODAWK_SWEBENCH_TIMEOUT", "1800")),
|
| 51 |
+
shell=False,
|
| 52 |
+
)
|
| 53 |
+
resolved = proc.returncode == 0
|
| 54 |
+
return SwebenchOutcome(
|
| 55 |
+
instance_id=instance_id,
|
| 56 |
+
repo=repo,
|
| 57 |
+
resolved=resolved,
|
| 58 |
+
duration_seconds=time.time() - start,
|
| 59 |
+
error="" if resolved else (proc.stderr or proc.stdout)[-1000:],
|
| 60 |
+
)
|
| 61 |
+
except Exception as e:
|
| 62 |
+
return SwebenchOutcome(
|
| 63 |
+
instance_id=instance_id,
|
| 64 |
+
repo=repo,
|
| 65 |
+
resolved=False,
|
| 66 |
+
duration_seconds=time.time() - start,
|
| 67 |
+
error=str(e),
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def run_swebench_eval(max_instances: int = 100, split: str = "test") -> dict:
|
| 72 |
+
from datasets import load_dataset
|
| 73 |
+
|
| 74 |
+
dataset = load_dataset(SWEBENCH_DATASET, split=split)
|
| 75 |
+
instances = list(dataset)[:max_instances]
|
| 76 |
+
outcomes = [evaluate_single_instance(inst) for inst in instances]
|
| 77 |
+
resolved = sum(1 for outcome in outcomes if outcome.resolved)
|
| 78 |
+
total = len(outcomes) or 1
|
| 79 |
+
result = {
|
| 80 |
+
"pass_at_1": resolved / total,
|
| 81 |
+
"resolved": resolved,
|
| 82 |
+
"total": len(outcomes),
|
| 83 |
+
"split": split,
|
| 84 |
+
"dataset": SWEBENCH_DATASET,
|
| 85 |
+
"results": [asdict(outcome) for outcome in outcomes],
|
| 86 |
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 87 |
+
}
|
| 88 |
+
write_reports(result)
|
| 89 |
+
return result
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def write_reports(result: dict) -> None:
|
| 93 |
+
os.makedirs(os.path.dirname(RESULTS_PATH), exist_ok=True)
|
| 94 |
+
with open(RESULTS_PATH, "w", encoding="utf-8") as f:
|
| 95 |
+
json.dump(result, f, indent=2)
|
| 96 |
+
|
| 97 |
+
pass_pct = result["pass_at_1"] * 100
|
| 98 |
+
report = [
|
| 99 |
+
"# Rhodawk AI SWE-bench Verified Report",
|
| 100 |
+
"",
|
| 101 |
+
f"- Dataset: `{result['dataset']}`",
|
| 102 |
+
f"- Split: `{result['split']}`",
|
| 103 |
+
f"- Total instances: {result['total']}",
|
| 104 |
+
f"- Resolved: {result['resolved']}",
|
| 105 |
+
f"- pass@1: {pass_pct:.1f}%",
|
| 106 |
+
f"- Generated: {result['generated_at']}",
|
| 107 |
+
"",
|
| 108 |
+
"## Instance Outcomes",
|
| 109 |
+
"",
|
| 110 |
+
]
|
| 111 |
+
for outcome in result["results"]:
|
| 112 |
+
status = "RESOLVED" if outcome["resolved"] else "FAILED"
|
| 113 |
+
report.append(f"- `{outcome['instance_id']}` ({outcome['repo']}): {status}")
|
| 114 |
+
with open(REPORT_PATH, "w", encoding="utf-8") as f:
|
| 115 |
+
f.write("\n".join(report))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def main() -> None:
|
| 119 |
+
parser = argparse.ArgumentParser()
|
| 120 |
+
parser.add_argument("--split", default="test")
|
| 121 |
+
parser.add_argument("--max-instances", type=int, default=100)
|
| 122 |
+
args = parser.parse_args()
|
| 123 |
+
result = run_swebench_eval(max_instances=args.max_instances, split=args.split)
|
| 124 |
+
print(json.dumps({k: result[k] for k in ("pass_at_1", "resolved", "total")}, indent=2))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
main()
|
training_store.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Training Data Pipeline
|
| 3 |
+
=====================================
|
| 4 |
+
Every fix attempt is recorded in SQLite. This is the data flywheel.
|
| 5 |
+
|
| 6 |
+
Schema captures the complete chain:
|
| 7 |
+
failure → model → prompt → diff → SAST → adversarial verdict → test result → human outcome
|
| 8 |
+
|
| 9 |
+
After N examples, this becomes a proprietary fine-tuning dataset that no
|
| 10 |
+
competitor can replicate — because it's trained on YOUR codebase's failure patterns.
|
| 11 |
+
|
| 12 |
+
Export API produces HuggingFace-compatible JSONL for direct model fine-tuning.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import hashlib
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import sqlite3
|
| 19 |
+
import time
|
| 20 |
+
from contextlib import contextmanager
|
| 21 |
+
from typing import Optional
|
| 22 |
+
|
| 23 |
+
DB_PATH = "/data/training_store.db"
|
| 24 |
+
DB_BACKEND = os.getenv("DB_BACKEND", "sqlite").lower()
|
| 25 |
+
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@contextmanager
|
| 29 |
+
def _get_conn():
|
| 30 |
+
if DB_BACKEND == "postgres":
|
| 31 |
+
import psycopg2
|
| 32 |
+
from psycopg2.extras import DictCursor
|
| 33 |
+
|
| 34 |
+
class PgConn:
|
| 35 |
+
def __init__(self, conn):
|
| 36 |
+
self.conn = conn
|
| 37 |
+
|
| 38 |
+
def execute(self, sql: str, params: tuple = ()):
|
| 39 |
+
pg_sql = sql.replace("?", "%s")
|
| 40 |
+
if "INSERT INTO fix_attempts" in pg_sql and "RETURNING id" not in pg_sql:
|
| 41 |
+
pg_sql = pg_sql.rstrip().rstrip(";") + " RETURNING id"
|
| 42 |
+
cur = self.conn.cursor(cursor_factory=DictCursor)
|
| 43 |
+
cur.execute(pg_sql, params)
|
| 44 |
+
return cur
|
| 45 |
+
|
| 46 |
+
def executescript(self, script: str):
|
| 47 |
+
cur = self.conn.cursor()
|
| 48 |
+
cur.execute(script)
|
| 49 |
+
return cur
|
| 50 |
+
|
| 51 |
+
def commit(self):
|
| 52 |
+
self.conn.commit()
|
| 53 |
+
|
| 54 |
+
def rollback(self):
|
| 55 |
+
self.conn.rollback()
|
| 56 |
+
|
| 57 |
+
def close(self):
|
| 58 |
+
self.conn.close()
|
| 59 |
+
|
| 60 |
+
conn = PgConn(psycopg2.connect(DATABASE_URL))
|
| 61 |
+
try:
|
| 62 |
+
yield conn
|
| 63 |
+
conn.commit()
|
| 64 |
+
except Exception:
|
| 65 |
+
conn.rollback()
|
| 66 |
+
raise
|
| 67 |
+
finally:
|
| 68 |
+
conn.close()
|
| 69 |
+
return
|
| 70 |
+
|
| 71 |
+
conn = sqlite3.connect(DB_PATH, timeout=10)
|
| 72 |
+
conn.row_factory = sqlite3.Row
|
| 73 |
+
conn.execute("PRAGMA journal_mode=WAL")
|
| 74 |
+
conn.execute("PRAGMA foreign_keys=ON")
|
| 75 |
+
try:
|
| 76 |
+
yield conn
|
| 77 |
+
conn.commit()
|
| 78 |
+
except Exception:
|
| 79 |
+
conn.rollback()
|
| 80 |
+
raise
|
| 81 |
+
finally:
|
| 82 |
+
conn.close()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def initialize_store():
|
| 86 |
+
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
| 87 |
+
if DB_BACKEND == "postgres":
|
| 88 |
+
initialize_postgres_store()
|
| 89 |
+
return
|
| 90 |
+
with _get_conn() as conn:
|
| 91 |
+
conn.executescript("""
|
| 92 |
+
CREATE TABLE IF NOT EXISTS fix_attempts (
|
| 93 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 94 |
+
created_at TEXT NOT NULL,
|
| 95 |
+
tenant_id TEXT NOT NULL,
|
| 96 |
+
repo TEXT NOT NULL,
|
| 97 |
+
test_path TEXT NOT NULL,
|
| 98 |
+
failure_signature TEXT NOT NULL,
|
| 99 |
+
failure_output TEXT NOT NULL,
|
| 100 |
+
model_version TEXT NOT NULL,
|
| 101 |
+
adversary_model TEXT,
|
| 102 |
+
prompt_hash TEXT NOT NULL,
|
| 103 |
+
attempt_number INTEGER DEFAULT 1,
|
| 104 |
+
diff_produced TEXT,
|
| 105 |
+
sast_passed INTEGER,
|
| 106 |
+
sast_findings_count INTEGER DEFAULT 0,
|
| 107 |
+
adversarial_verdict TEXT,
|
| 108 |
+
adversarial_issues TEXT,
|
| 109 |
+
adversarial_summary TEXT,
|
| 110 |
+
test_passed_after INTEGER,
|
| 111 |
+
pr_url TEXT,
|
| 112 |
+
human_merged INTEGER,
|
| 113 |
+
human_merged_at TEXT,
|
| 114 |
+
success_signal INTEGER GENERATED ALWAYS AS (
|
| 115 |
+
CASE WHEN test_passed_after = 1 AND sast_passed = 1
|
| 116 |
+
AND (adversarial_verdict IS NULL OR adversarial_verdict != 'REJECT')
|
| 117 |
+
THEN 1 ELSE 0 END
|
| 118 |
+
) STORED
|
| 119 |
+
);
|
| 120 |
+
|
| 121 |
+
CREATE TABLE IF NOT EXISTS fix_patterns (
|
| 122 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 123 |
+
failure_signature TEXT NOT NULL,
|
| 124 |
+
context_hash TEXT NOT NULL,
|
| 125 |
+
fix_diff TEXT NOT NULL,
|
| 126 |
+
success_count INTEGER DEFAULT 0,
|
| 127 |
+
attempt_count INTEGER DEFAULT 0,
|
| 128 |
+
last_seen TEXT NOT NULL,
|
| 129 |
+
UNIQUE(failure_signature, context_hash)
|
| 130 |
+
);
|
| 131 |
+
|
| 132 |
+
CREATE INDEX IF NOT EXISTS idx_fix_attempts_repo ON fix_attempts(repo, test_path);
|
| 133 |
+
CREATE INDEX IF NOT EXISTS idx_fix_attempts_success ON fix_attempts(success_signal);
|
| 134 |
+
CREATE INDEX IF NOT EXISTS idx_fix_patterns_sig ON fix_patterns(failure_signature);
|
| 135 |
+
""")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def initialize_postgres_store():
|
| 139 |
+
if not DATABASE_URL:
|
| 140 |
+
raise EnvironmentError("DB_BACKEND=postgres requires DATABASE_URL")
|
| 141 |
+
import psycopg2
|
| 142 |
+
with psycopg2.connect(DATABASE_URL) as conn:
|
| 143 |
+
with conn.cursor() as cur:
|
| 144 |
+
cur.execute("""
|
| 145 |
+
CREATE TABLE IF NOT EXISTS fix_attempts (
|
| 146 |
+
id BIGSERIAL PRIMARY KEY,
|
| 147 |
+
created_at TEXT NOT NULL,
|
| 148 |
+
tenant_id TEXT NOT NULL,
|
| 149 |
+
repo TEXT NOT NULL,
|
| 150 |
+
test_path TEXT NOT NULL,
|
| 151 |
+
failure_signature TEXT NOT NULL,
|
| 152 |
+
failure_output TEXT NOT NULL,
|
| 153 |
+
model_version TEXT NOT NULL,
|
| 154 |
+
adversary_model TEXT,
|
| 155 |
+
prompt_hash TEXT NOT NULL,
|
| 156 |
+
attempt_number INTEGER DEFAULT 1,
|
| 157 |
+
diff_produced TEXT,
|
| 158 |
+
sast_passed INTEGER,
|
| 159 |
+
sast_findings_count INTEGER DEFAULT 0,
|
| 160 |
+
adversarial_verdict TEXT,
|
| 161 |
+
adversarial_issues TEXT,
|
| 162 |
+
adversarial_summary TEXT,
|
| 163 |
+
test_passed_after INTEGER,
|
| 164 |
+
pr_url TEXT,
|
| 165 |
+
human_merged INTEGER,
|
| 166 |
+
human_merged_at TEXT,
|
| 167 |
+
success_signal INTEGER DEFAULT 0
|
| 168 |
+
);
|
| 169 |
+
CREATE TABLE IF NOT EXISTS fix_patterns (
|
| 170 |
+
id BIGSERIAL PRIMARY KEY,
|
| 171 |
+
failure_signature TEXT NOT NULL,
|
| 172 |
+
context_hash TEXT NOT NULL,
|
| 173 |
+
fix_diff TEXT NOT NULL,
|
| 174 |
+
success_count INTEGER DEFAULT 0,
|
| 175 |
+
attempt_count INTEGER DEFAULT 0,
|
| 176 |
+
last_seen TEXT NOT NULL,
|
| 177 |
+
UNIQUE(failure_signature, context_hash)
|
| 178 |
+
);
|
| 179 |
+
CREATE INDEX IF NOT EXISTS idx_fix_attempts_repo ON fix_attempts(repo, test_path);
|
| 180 |
+
CREATE INDEX IF NOT EXISTS idx_fix_attempts_success ON fix_attempts(success_signal);
|
| 181 |
+
CREATE INDEX IF NOT EXISTS idx_fix_patterns_sig ON fix_patterns(failure_signature);
|
| 182 |
+
""")
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def record_attempt(
|
| 186 |
+
tenant_id: str,
|
| 187 |
+
repo: str,
|
| 188 |
+
test_path: str,
|
| 189 |
+
failure_output: str,
|
| 190 |
+
model_version: str,
|
| 191 |
+
prompt_hash: str,
|
| 192 |
+
attempt_number: int = 1,
|
| 193 |
+
diff_produced: str = "",
|
| 194 |
+
sast_passed: Optional[bool] = None,
|
| 195 |
+
sast_findings_count: int = 0,
|
| 196 |
+
adversarial_verdict: Optional[str] = None,
|
| 197 |
+
adversarial_issues: Optional[list] = None,
|
| 198 |
+
adversarial_summary: str = "",
|
| 199 |
+
adversary_model: str = "",
|
| 200 |
+
test_passed_after: Optional[bool] = None,
|
| 201 |
+
pr_url: str = "",
|
| 202 |
+
) -> int:
|
| 203 |
+
failure_signature = _make_failure_signature(failure_output)
|
| 204 |
+
|
| 205 |
+
with _get_conn() as conn:
|
| 206 |
+
cursor = conn.execute("""
|
| 207 |
+
INSERT INTO fix_attempts (
|
| 208 |
+
created_at, tenant_id, repo, test_path, failure_signature, failure_output,
|
| 209 |
+
model_version, adversary_model, prompt_hash, attempt_number, diff_produced,
|
| 210 |
+
sast_passed, sast_findings_count, adversarial_verdict, adversarial_issues,
|
| 211 |
+
adversarial_summary, test_passed_after, pr_url
|
| 212 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 213 |
+
""", (
|
| 214 |
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 215 |
+
tenant_id, repo, test_path, failure_signature,
|
| 216 |
+
failure_output[:5000], model_version, adversary_model, prompt_hash,
|
| 217 |
+
attempt_number, diff_produced[:8000],
|
| 218 |
+
1 if sast_passed else 0 if sast_passed is False else None,
|
| 219 |
+
sast_findings_count,
|
| 220 |
+
adversarial_verdict,
|
| 221 |
+
json.dumps(adversarial_issues or []),
|
| 222 |
+
adversarial_summary,
|
| 223 |
+
1 if test_passed_after else 0 if test_passed_after is False else None,
|
| 224 |
+
pr_url,
|
| 225 |
+
))
|
| 226 |
+
if DB_BACKEND == "postgres":
|
| 227 |
+
row = cursor.fetchone()
|
| 228 |
+
return row[0] if row else 0
|
| 229 |
+
return cursor.lastrowid
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def update_test_result(attempt_id: int, test_passed: bool, pr_url: str = ""):
|
| 233 |
+
with _get_conn() as conn:
|
| 234 |
+
conn.execute(
|
| 235 |
+
"UPDATE fix_attempts SET test_passed_after=?, pr_url=? WHERE id=?",
|
| 236 |
+
(1 if test_passed else 0, pr_url, attempt_id)
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def mark_human_merged(repo: str, test_path: str):
|
| 241 |
+
with _get_conn() as conn:
|
| 242 |
+
conn.execute("""
|
| 243 |
+
UPDATE fix_attempts SET human_merged=1, human_merged_at=?
|
| 244 |
+
WHERE repo=? AND test_path=? AND pr_url IS NOT NULL
|
| 245 |
+
AND human_merged IS NULL
|
| 246 |
+
ORDER BY id DESC LIMIT 1
|
| 247 |
+
""", (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), repo, test_path))
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def record_pattern(failure_output: str, context_hash: str, fix_diff: str, success: bool):
|
| 251 |
+
sig = _make_failure_signature(failure_output)
|
| 252 |
+
with _get_conn() as conn:
|
| 253 |
+
conn.execute("""
|
| 254 |
+
INSERT INTO fix_patterns (failure_signature, context_hash, fix_diff, success_count, attempt_count, last_seen)
|
| 255 |
+
VALUES (?, ?, ?, ?, 1, ?)
|
| 256 |
+
ON CONFLICT(failure_signature, context_hash) DO UPDATE SET
|
| 257 |
+
success_count = success_count + ?,
|
| 258 |
+
attempt_count = attempt_count + 1,
|
| 259 |
+
fix_diff = CASE WHEN ? = 1 THEN ? ELSE fix_diff END,
|
| 260 |
+
last_seen = ?
|
| 261 |
+
""", (
|
| 262 |
+
sig, context_hash, fix_diff[:4000],
|
| 263 |
+
1 if success else 0, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 264 |
+
1 if success else 0,
|
| 265 |
+
1 if success else 0, fix_diff[:4000],
|
| 266 |
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 267 |
+
))
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def get_statistics() -> dict:
|
| 271 |
+
with _get_conn() as conn:
|
| 272 |
+
total = conn.execute("SELECT COUNT(*) FROM fix_attempts").fetchone()[0]
|
| 273 |
+
success = conn.execute("SELECT COUNT(*) FROM fix_attempts WHERE success_signal=1").fetchone()[0]
|
| 274 |
+
sast_blocked = conn.execute("SELECT COUNT(*) FROM fix_attempts WHERE sast_passed=0").fetchone()[0]
|
| 275 |
+
adv_rejected = conn.execute(
|
| 276 |
+
"SELECT COUNT(*) FROM fix_attempts WHERE adversarial_verdict='REJECT'"
|
| 277 |
+
).fetchone()[0]
|
| 278 |
+
patterns = conn.execute("SELECT COUNT(*) FROM fix_patterns").fetchone()[0]
|
| 279 |
+
merged = conn.execute("SELECT COUNT(*) FROM fix_attempts WHERE human_merged=1").fetchone()[0]
|
| 280 |
+
|
| 281 |
+
top_failing = conn.execute("""
|
| 282 |
+
SELECT test_path, COUNT(*) as cnt FROM fix_attempts
|
| 283 |
+
GROUP BY test_path ORDER BY cnt DESC LIMIT 5
|
| 284 |
+
""").fetchall()
|
| 285 |
+
|
| 286 |
+
return {
|
| 287 |
+
"total_attempts": total,
|
| 288 |
+
"successful_fixes": success,
|
| 289 |
+
"fix_success_rate": f"{(success/total*100):.1f}%" if total > 0 else "0%",
|
| 290 |
+
"sast_blocked": sast_blocked,
|
| 291 |
+
"adversarially_rejected": adv_rejected,
|
| 292 |
+
"patterns_learned": patterns,
|
| 293 |
+
"human_merged": merged,
|
| 294 |
+
"top_failing_tests": [{"path": r["test_path"], "attempts": r["cnt"]} for r in top_failing],
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def export_training_data(limit: int = 1000) -> str:
|
| 299 |
+
"""Export successful fixes as JSONL for fine-tuning."""
|
| 300 |
+
with _get_conn() as conn:
|
| 301 |
+
rows = conn.execute("""
|
| 302 |
+
SELECT repo, test_path, failure_output, model_version,
|
| 303 |
+
diff_produced, adversarial_verdict, adversarial_summary,
|
| 304 |
+
sast_passed, attempt_number, created_at
|
| 305 |
+
FROM fix_attempts
|
| 306 |
+
WHERE success_signal = 1
|
| 307 |
+
ORDER BY created_at DESC
|
| 308 |
+
LIMIT ?
|
| 309 |
+
""", (limit,)).fetchall()
|
| 310 |
+
|
| 311 |
+
lines = []
|
| 312 |
+
for row in rows:
|
| 313 |
+
entry = {
|
| 314 |
+
"messages": [
|
| 315 |
+
{
|
| 316 |
+
"role": "system",
|
| 317 |
+
"content": "You are an autonomous DevSecOps engineer. Fix failing tests in Python repositories."
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
"role": "user",
|
| 321 |
+
"content": f"Fix the following failing test in repo '{row['repo']}':\n\n{row['failure_output'][:2000]}"
|
| 322 |
+
},
|
| 323 |
+
{
|
| 324 |
+
"role": "assistant",
|
| 325 |
+
"content": f"```diff\n{row['diff_produced']}\n```"
|
| 326 |
+
}
|
| 327 |
+
],
|
| 328 |
+
"metadata": {
|
| 329 |
+
"repo": row["repo"],
|
| 330 |
+
"test_path": row["test_path"],
|
| 331 |
+
"model": row["model_version"],
|
| 332 |
+
"adversarial_verdict": row["adversarial_verdict"],
|
| 333 |
+
"attempt_number": row["attempt_number"],
|
| 334 |
+
"created_at": row["created_at"],
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
lines.append(json.dumps(entry))
|
| 338 |
+
|
| 339 |
+
return "\n".join(lines)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def export_hf_dataset(repo_id: str, limit: int = 1000, private: bool = True) -> str:
|
| 343 |
+
from datasets import Dataset
|
| 344 |
+
|
| 345 |
+
raw = export_training_data(limit=limit)
|
| 346 |
+
rows = [json.loads(line) for line in raw.splitlines() if line.strip()]
|
| 347 |
+
dataset = Dataset.from_list(rows)
|
| 348 |
+
dataset.push_to_hub(repo_id, private=private)
|
| 349 |
+
return f"Exported {len(rows)} training examples to {repo_id}"
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _make_failure_signature(failure_output: str) -> str:
|
| 353 |
+
lines = failure_output.splitlines()
|
| 354 |
+
key_lines = [l for l in lines if any(kw in l for kw in [
|
| 355 |
+
"FAILED", "ERROR", "assert", "ImportError", "AttributeError",
|
| 356 |
+
"TypeError", "ValueError", "ModuleNotFoundError", "Exception"
|
| 357 |
+
])]
|
| 358 |
+
signature_text = " ".join(key_lines[:5])[:200]
|
| 359 |
+
return hashlib.sha256(signature_text.encode()).hexdigest()[:32]
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
initialize_store()
|
verification_loop.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Closed Verification Loop Engine
|
| 3 |
+
=============================================
|
| 4 |
+
This is the core capability that separates Rhodawk from every other AI CI tool.
|
| 5 |
+
|
| 6 |
+
Standard tools: AI generates fix → open PR (no idea if fix works)
|
| 7 |
+
Rhodawk: AI generates fix → re-run tests → if still failing, retry with
|
| 8 |
+
new failure context + what was tried → up to MAX_RETRIES rounds
|
| 9 |
+
|
| 10 |
+
The loop:
|
| 11 |
+
1. Run tests → get failure output
|
| 12 |
+
2. Dispatch Aider with failure context + memory-retrieved similar fixes
|
| 13 |
+
3. Re-run tests on the modified code
|
| 14 |
+
4. If GREEN → gate through adversarial review → open PR
|
| 15 |
+
5. If STILL RED → append new failure + what was tried → goto 2
|
| 16 |
+
6. After MAX_RETRIES → mark as FAILED, escalate
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import time
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
MAX_RETRIES = int(os.getenv("RHODAWK_MAX_RETRIES", "5"))
|
| 25 |
+
ADVERSARIAL_REJECTION_MULTIPLIER = int(os.getenv("RHODAWK_ADVERSARIAL_REJECTION_MULTIPLIER", "0"))
|
| 26 |
+
RETRY_BACKOFF_SECONDS = 5
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class VerificationAttempt:
|
| 31 |
+
attempt_number: int
|
| 32 |
+
prompt_hash: str
|
| 33 |
+
aider_exit_code: int
|
| 34 |
+
test_exit_code: int
|
| 35 |
+
test_output: str
|
| 36 |
+
diff_produced: str
|
| 37 |
+
timestamp: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class VerificationResult:
|
| 42 |
+
success: bool
|
| 43 |
+
attempts: list[VerificationAttempt] = field(default_factory=list)
|
| 44 |
+
final_diff: str = ""
|
| 45 |
+
final_test_output: str = ""
|
| 46 |
+
failure_reason: str = ""
|
| 47 |
+
total_attempts: int = 0
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def build_retry_prompt(
|
| 51 |
+
test_path: str,
|
| 52 |
+
src_file: str,
|
| 53 |
+
branch_name: str,
|
| 54 |
+
original_failure: str,
|
| 55 |
+
attempt_history: list[VerificationAttempt],
|
| 56 |
+
similar_fixes: list[dict],
|
| 57 |
+
) -> str:
|
| 58 |
+
"""
|
| 59 |
+
Build an increasingly rich prompt for each retry attempt.
|
| 60 |
+
Each retry includes:
|
| 61 |
+
- The original failure
|
| 62 |
+
- What was tried in previous attempts and why it failed
|
| 63 |
+
- Retrieved similar successful fixes from memory
|
| 64 |
+
"""
|
| 65 |
+
sections = []
|
| 66 |
+
|
| 67 |
+
sections.append(
|
| 68 |
+
f"The pytest test '{test_path}' is STILL FAILING. This is attempt "
|
| 69 |
+
f"{len(attempt_history) + 1} of {MAX_RETRIES}.\n"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
sections.append(
|
| 73 |
+
f"ORIGINAL FAILURE:\n```\n{original_failure[:2000]}\n```\n"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
if attempt_history:
|
| 77 |
+
sections.append("PREVIOUS ATTEMPTS THAT DID NOT WORK:")
|
| 78 |
+
for a in attempt_history:
|
| 79 |
+
sections.append(
|
| 80 |
+
f"\nAttempt {a.attempt_number}:\n"
|
| 81 |
+
f" Test output after fix:\n```\n{a.test_output[:800]}\n```\n"
|
| 82 |
+
f" Diff that was applied:\n```diff\n{a.diff_produced[:600]}\n```"
|
| 83 |
+
)
|
| 84 |
+
sections.append(
|
| 85 |
+
"\nDo NOT repeat the same fix approach. Analyze why previous attempts failed "
|
| 86 |
+
"and try a fundamentally different strategy.\n"
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
if similar_fixes:
|
| 90 |
+
sections.append("\nSIMILAR FIXES FROM MEMORY (from previously healed tests — use as guidance):")
|
| 91 |
+
for i, fix in enumerate(similar_fixes[:2], 1):
|
| 92 |
+
sections.append(
|
| 93 |
+
f"\nSimilar fix {i} (success rate: {fix.get('success_rate', 'unknown')}):\n"
|
| 94 |
+
f" Failure pattern: {fix.get('failure_signature', '')[:200]}\n"
|
| 95 |
+
f" Fix applied:\n```diff\n{fix.get('fix_diff', '')[:400]}\n```"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
sections.append(
|
| 99 |
+
f"\nINSTRUCTIONS:\n"
|
| 100 |
+
f"1. Fix the source code in '{src_file}' or 'requirements.txt' ONLY. Do NOT modify test files.\n"
|
| 101 |
+
f"2. Use the 'fetch-docs' MCP tool to look up library documentation if needed.\n"
|
| 102 |
+
f"3. Work on branch '{branch_name}'. Commit the minimal fix when complete.\n"
|
| 103 |
+
f"4. The fix MUST be different from all previous attempts.\n"
|
| 104 |
+
f"5. Ensure the fix is minimal and does not introduce regressions."
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
return "\n".join(sections)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def build_initial_prompt(
|
| 111 |
+
test_path: str,
|
| 112 |
+
src_file: str,
|
| 113 |
+
branch_name: str,
|
| 114 |
+
failure_output: str,
|
| 115 |
+
similar_fixes: list[dict],
|
| 116 |
+
) -> str:
|
| 117 |
+
sections = []
|
| 118 |
+
sections.append(
|
| 119 |
+
f"The pytest test '{test_path}' is failing:\n\n"
|
| 120 |
+
f"```\n{failure_output[:3000]}\n```\n"
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
if similar_fixes:
|
| 124 |
+
sections.append("RELEVANT FIXES FROM MEMORY (similar past failures that were healed):")
|
| 125 |
+
for i, fix in enumerate(similar_fixes[:2], 1):
|
| 126 |
+
sections.append(
|
| 127 |
+
f"\nSimilar case {i} (success rate: {fix.get('success_rate', 'unknown')}):\n"
|
| 128 |
+
f" Failure: {fix.get('failure_signature', '')[:150]}\n"
|
| 129 |
+
f" What worked:\n```diff\n{fix.get('fix_diff', '')[:400]}\n```"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
sections.append(
|
| 133 |
+
f"\nINSTRUCTIONS:\n"
|
| 134 |
+
f"1. If there is an import error or version conflict, use 'fetch-docs' MCP to look up "
|
| 135 |
+
f" documentation on docs.python.org or pypi.org.\n"
|
| 136 |
+
f"2. Fix '{src_file}' or 'requirements.txt' to make the test pass. Do NOT modify test files.\n"
|
| 137 |
+
f"3. Work on branch '{branch_name}'. Commit the minimal fix when complete.\n"
|
| 138 |
+
f"4. The fix must be minimal and must not introduce regressions."
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
return "\n".join(sections)
|
webhook_server.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Event-Driven Webhook Server
|
| 3 |
+
==========================================
|
| 4 |
+
Accepts GitHub push events, CI failure webhooks, and manual triggers.
|
| 5 |
+
Runs alongside Gradio in a separate thread on port 7861.
|
| 6 |
+
|
| 7 |
+
Supported events:
|
| 8 |
+
POST /webhook/github — GitHub push/status/check_run webhooks (HMAC-SHA256 validated)
|
| 9 |
+
POST /webhook/ci — Generic CI failure payload (any CI system)
|
| 10 |
+
POST /webhook/trigger — Manual trigger with repo + test path
|
| 11 |
+
GET /webhook/health — Health check
|
| 12 |
+
GET /webhook/queue — Current job queue status
|
| 13 |
+
|
| 14 |
+
This makes Rhodawk a first-class CI/CD participant — not a side tool you run manually.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import hashlib
|
| 18 |
+
import hmac
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
| 24 |
+
from typing import Callable
|
| 25 |
+
from urllib.parse import urlparse
|
| 26 |
+
|
| 27 |
+
WEBHOOK_SECRET = os.getenv("RHODAWK_WEBHOOK_SECRET", "")
|
| 28 |
+
WEBHOOK_PORT = int(os.getenv("RHODAWK_WEBHOOK_PORT", "7861"))
|
| 29 |
+
|
| 30 |
+
_webhook_log: list[dict] = []
|
| 31 |
+
_webhook_lock = threading.Lock()
|
| 32 |
+
_job_dispatcher: Callable = None # Set at runtime by app.py
|
| 33 |
+
_rate_limit: dict[str, list[float]] = {}
|
| 34 |
+
_RATE_LIMIT_MAX_EVENTS = int(os.getenv("RHODAWK_WEBHOOK_RATE_LIMIT", "10"))
|
| 35 |
+
_RATE_LIMIT_WINDOW_SECONDS = 60
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def set_job_dispatcher(fn: Callable):
|
| 39 |
+
"""Register the function that app.py uses to spawn audit jobs."""
|
| 40 |
+
global _job_dispatcher
|
| 41 |
+
_job_dispatcher = fn
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _log_webhook(event_type: str, payload: dict, status: str, detail: str = ""):
|
| 45 |
+
with _webhook_lock:
|
| 46 |
+
_webhook_log.append({
|
| 47 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 48 |
+
"event_type": event_type,
|
| 49 |
+
"status": status,
|
| 50 |
+
"detail": detail,
|
| 51 |
+
"repo": payload.get("repository", {}).get("full_name", payload.get("repo", "unknown")),
|
| 52 |
+
})
|
| 53 |
+
if len(_webhook_log) > 200:
|
| 54 |
+
_webhook_log.pop(0)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_webhook_log(limit: int = 50) -> list[dict]:
|
| 58 |
+
with _webhook_lock:
|
| 59 |
+
return list(reversed(_webhook_log[-limit:]))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _verify_github_signature(body: bytes, signature_header: str) -> bool:
|
| 63 |
+
if not WEBHOOK_SECRET:
|
| 64 |
+
return True # Skip validation if secret not configured
|
| 65 |
+
if not signature_header or not signature_header.startswith("sha256="):
|
| 66 |
+
return False
|
| 67 |
+
mac = hmac.new(WEBHOOK_SECRET.encode(), msg=body, digestmod=hashlib.sha256)
|
| 68 |
+
expected = mac.hexdigest()
|
| 69 |
+
received = signature_header[7:]
|
| 70 |
+
return hmac.compare_digest(expected, received)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _rate_limit_allows(ip: str) -> bool:
|
| 74 |
+
now = time.time()
|
| 75 |
+
with _webhook_lock:
|
| 76 |
+
events = [t for t in _rate_limit.get(ip, []) if now - t < _RATE_LIMIT_WINDOW_SECONDS]
|
| 77 |
+
if len(events) >= _RATE_LIMIT_MAX_EVENTS:
|
| 78 |
+
_rate_limit[ip] = events
|
| 79 |
+
return False
|
| 80 |
+
events.append(now)
|
| 81 |
+
_rate_limit[ip] = events
|
| 82 |
+
return True
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _parse_github_event(event_type: str, payload: dict) -> dict:
|
| 86 |
+
"""Extract repo, branch, and context from a GitHub webhook payload."""
|
| 87 |
+
repo = payload.get("repository", {}).get("full_name", "")
|
| 88 |
+
branch = (
|
| 89 |
+
payload.get("ref", "").replace("refs/heads/", "") or
|
| 90 |
+
payload.get("check_run", {}).get("head_branch", "main") or
|
| 91 |
+
"main"
|
| 92 |
+
)
|
| 93 |
+
context = {
|
| 94 |
+
"repo": repo,
|
| 95 |
+
"branch": branch,
|
| 96 |
+
"event_type": event_type,
|
| 97 |
+
"commit_sha": payload.get("after") or payload.get("check_run", {}).get("head_sha", ""),
|
| 98 |
+
"triggered_by": "github_webhook",
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if event_type == "check_run":
|
| 102 |
+
check = payload.get("check_run", {})
|
| 103 |
+
if check.get("conclusion") == "failure":
|
| 104 |
+
context["failing_check"] = check.get("name", "")
|
| 105 |
+
context["details_url"] = check.get("details_url", "")
|
| 106 |
+
return context
|
| 107 |
+
return {} # Only care about failures
|
| 108 |
+
|
| 109 |
+
if event_type == "status":
|
| 110 |
+
if payload.get("state") == "failure":
|
| 111 |
+
context["failing_context"] = payload.get("context", "")
|
| 112 |
+
return context
|
| 113 |
+
return {}
|
| 114 |
+
|
| 115 |
+
# push event — trigger full audit
|
| 116 |
+
return context
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class WebhookHandler(BaseHTTPRequestHandler):
|
| 120 |
+
def log_message(self, format, *args):
|
| 121 |
+
pass # Suppress default HTTP server logs
|
| 122 |
+
|
| 123 |
+
def _send_json(self, status_code: int, data: dict):
|
| 124 |
+
body = json.dumps(data).encode()
|
| 125 |
+
self.send_response(status_code)
|
| 126 |
+
self.send_header("Content-Type", "application/json")
|
| 127 |
+
self.send_header("Content-Length", len(body))
|
| 128 |
+
self.end_headers()
|
| 129 |
+
self.wfile.write(body)
|
| 130 |
+
|
| 131 |
+
def do_GET(self):
|
| 132 |
+
path = urlparse(self.path).path
|
| 133 |
+
|
| 134 |
+
if path == "/webhook/health":
|
| 135 |
+
self._send_json(200, {
|
| 136 |
+
"status": "ok",
|
| 137 |
+
"dispatcher_ready": _job_dispatcher is not None,
|
| 138 |
+
"webhook_events_received": len(_webhook_log),
|
| 139 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 140 |
+
})
|
| 141 |
+
|
| 142 |
+
elif path == "/webhook/queue":
|
| 143 |
+
from job_queue import list_all_jobs, get_metrics
|
| 144 |
+
self._send_json(200, {
|
| 145 |
+
"metrics": get_metrics(),
|
| 146 |
+
"recent_jobs": list_all_jobs()[:10],
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
elif path == "/webhook/log":
|
| 150 |
+
self._send_json(200, {"events": get_webhook_log(50)})
|
| 151 |
+
|
| 152 |
+
else:
|
| 153 |
+
self._send_json(404, {"error": "Not found"})
|
| 154 |
+
|
| 155 |
+
def do_POST(self):
|
| 156 |
+
path = urlparse(self.path).path
|
| 157 |
+
client_ip = self.client_address[0] if self.client_address else "unknown"
|
| 158 |
+
if not _rate_limit_allows(client_ip):
|
| 159 |
+
_log_webhook("rate_limit", {"repo": "unknown"}, "REJECTED", f"Too many events from {client_ip}")
|
| 160 |
+
self._send_json(429, {"error": "Rate limit exceeded"})
|
| 161 |
+
return
|
| 162 |
+
length = int(self.headers.get("Content-Length", 0))
|
| 163 |
+
body = self.rfile.read(length)
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
payload = json.loads(body) if body else {}
|
| 167 |
+
except json.JSONDecodeError:
|
| 168 |
+
self._send_json(400, {"error": "Invalid JSON"})
|
| 169 |
+
return
|
| 170 |
+
|
| 171 |
+
if path == "/webhook/github":
|
| 172 |
+
sig = self.headers.get("X-Hub-Signature-256", "")
|
| 173 |
+
if not _verify_github_signature(body, sig):
|
| 174 |
+
_log_webhook("github", payload, "REJECTED", "Invalid HMAC signature")
|
| 175 |
+
self._send_json(401, {"error": "Invalid signature"})
|
| 176 |
+
return
|
| 177 |
+
|
| 178 |
+
event_type = self.headers.get("X-GitHub-Event", "push")
|
| 179 |
+
context = _parse_github_event(event_type, payload)
|
| 180 |
+
|
| 181 |
+
if not context or not context.get("repo"):
|
| 182 |
+
_log_webhook(event_type, payload, "IGNORED", "Event not actionable")
|
| 183 |
+
self._send_json(200, {"status": "ignored", "reason": "Event not actionable"})
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
_log_webhook(event_type, payload, "ACCEPTED", f"Triggering audit for {context['repo']}")
|
| 187 |
+
|
| 188 |
+
if _job_dispatcher:
|
| 189 |
+
threading.Thread(
|
| 190 |
+
target=_job_dispatcher,
|
| 191 |
+
kwargs={"repo_override": context.get("repo"), "branch": context.get("branch", "main")},
|
| 192 |
+
daemon=True,
|
| 193 |
+
).start()
|
| 194 |
+
|
| 195 |
+
self._send_json(202, {"status": "accepted", "context": context})
|
| 196 |
+
|
| 197 |
+
elif path == "/webhook/ci":
|
| 198 |
+
repo = payload.get("repo") or payload.get("repository", "")
|
| 199 |
+
test_path = payload.get("test_path") or payload.get("failing_test", "")
|
| 200 |
+
failure_output = payload.get("failure_output") or payload.get("log", "")
|
| 201 |
+
|
| 202 |
+
if not repo:
|
| 203 |
+
self._send_json(400, {"error": "Missing 'repo' field"})
|
| 204 |
+
return
|
| 205 |
+
|
| 206 |
+
_log_webhook("ci_failure", payload, "ACCEPTED", f"CI failure from {repo}")
|
| 207 |
+
|
| 208 |
+
if _job_dispatcher:
|
| 209 |
+
threading.Thread(
|
| 210 |
+
target=_job_dispatcher,
|
| 211 |
+
kwargs={"repo_override": repo, "specific_test": test_path},
|
| 212 |
+
daemon=True,
|
| 213 |
+
).start()
|
| 214 |
+
|
| 215 |
+
self._send_json(202, {"status": "accepted", "repo": repo, "test": test_path})
|
| 216 |
+
|
| 217 |
+
elif path == "/webhook/trigger":
|
| 218 |
+
repo = payload.get("repo", os.getenv("GITHUB_REPO", ""))
|
| 219 |
+
if _job_dispatcher:
|
| 220 |
+
threading.Thread(target=_job_dispatcher, daemon=True).start()
|
| 221 |
+
_log_webhook("manual_trigger", payload, "ACCEPTED", f"Manual trigger for {repo}")
|
| 222 |
+
self._send_json(202, {"status": "accepted", "repo": repo})
|
| 223 |
+
else:
|
| 224 |
+
self._send_json(503, {"error": "Dispatcher not ready"})
|
| 225 |
+
|
| 226 |
+
else:
|
| 227 |
+
self._send_json(404, {"error": "Unknown webhook path"})
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def start_webhook_server():
|
| 231 |
+
"""Start the webhook server in a daemon thread."""
|
| 232 |
+
server = HTTPServer(("0.0.0.0", WEBHOOK_PORT), WebhookHandler)
|
| 233 |
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
| 234 |
+
thread.start()
|
| 235 |
+
return server
|
worker_pool.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Concurrent Worker Pool
|
| 3 |
+
====================================
|
| 4 |
+
ThreadPoolExecutor-based audit orchestration for parallel test healing.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import concurrent.futures
|
| 8 |
+
import os
|
| 9 |
+
import threading
|
| 10 |
+
from typing import Callable
|
| 11 |
+
|
| 12 |
+
MAX_WORKERS = int(os.getenv("RHODAWK_WORKERS", "8"))
|
| 13 |
+
_pool_lock = threading.Lock()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def run_parallel_audit(
|
| 17 |
+
test_files: list[str],
|
| 18 |
+
process_fn: Callable,
|
| 19 |
+
pytest_bin: str,
|
| 20 |
+
mcp_config_path: str,
|
| 21 |
+
tenant_id: str,
|
| 22 |
+
target_repo: str,
|
| 23 |
+
) -> dict:
|
| 24 |
+
results = {"healed": 0, "failed": 0, "skipped": 0, "prs": [], "errors": []}
|
| 25 |
+
|
| 26 |
+
if not test_files:
|
| 27 |
+
return results
|
| 28 |
+
|
| 29 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
| 30 |
+
futures = {
|
| 31 |
+
executor.submit(
|
| 32 |
+
_process_one_test,
|
| 33 |
+
test_path=t,
|
| 34 |
+
process_fn=process_fn,
|
| 35 |
+
pytest_bin=pytest_bin,
|
| 36 |
+
mcp_config_path=mcp_config_path,
|
| 37 |
+
tenant_id=tenant_id,
|
| 38 |
+
repo=target_repo,
|
| 39 |
+
): t
|
| 40 |
+
for t in test_files
|
| 41 |
+
}
|
| 42 |
+
for future in concurrent.futures.as_completed(futures):
|
| 43 |
+
try:
|
| 44 |
+
outcome = future.result()
|
| 45 |
+
except Exception as e:
|
| 46 |
+
outcome = {"success": False, "error": str(e)}
|
| 47 |
+
|
| 48 |
+
if outcome.get("skipped"):
|
| 49 |
+
results["skipped"] += 1
|
| 50 |
+
elif outcome.get("success"):
|
| 51 |
+
results["healed"] += 1
|
| 52 |
+
if outcome.get("pr_url"):
|
| 53 |
+
results["prs"].append(outcome.get("pr_url"))
|
| 54 |
+
else:
|
| 55 |
+
results["failed"] += 1
|
| 56 |
+
if outcome.get("error"):
|
| 57 |
+
results["errors"].append(outcome["error"])
|
| 58 |
+
|
| 59 |
+
return results
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _process_one_test(
|
| 63 |
+
test_path: str,
|
| 64 |
+
process_fn: Callable,
|
| 65 |
+
pytest_bin: str,
|
| 66 |
+
mcp_config_path: str,
|
| 67 |
+
tenant_id: str,
|
| 68 |
+
repo: str,
|
| 69 |
+
) -> dict:
|
| 70 |
+
return process_fn(
|
| 71 |
+
test_path=test_path,
|
| 72 |
+
pytest_bin=pytest_bin,
|
| 73 |
+
mcp_config_path=mcp_config_path,
|
| 74 |
+
tenant_id=tenant_id,
|
| 75 |
+
target_repo=repo,
|
| 76 |
+
)
|