ungraduate1394 commited on
Commit
c32102b
·
0 Parent(s):

Saved progress at the end of the loop

Browse files

Replit-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 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 &nbsp;·&nbsp;
870
+ CLOSED VERIFICATION LOOP &nbsp;·&nbsp;
871
+ ADVERSARIAL LLM REVIEW &nbsp;·&nbsp;
872
+ DATA FLYWHEEL &nbsp;·&nbsp;
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
+ )