Spaces:
Sleeping
Sleeping
Merge branch 'dev' into Dev-2
Browse files- .gitignore +2 -1
- README.md +6 -6
- check.py +1 -1
- config.yaml +1 -1
- grpo_lora/adapter_model.bin +0 -0
- grpo_lora/config.json +0 -1
- inference.py +18 -9
- patchhawk/__init__.py +12 -4
- patchhawk/agent/environment.py +15 -8
- patchhawk/agent/sandbox.py +36 -22
- patchhawk/agent/server.py +25 -10
- patchhawk/app/dashboard.py +90 -26
- patchhawk/data/benign/ds_binarysearch.py +1 -1
- patchhawk/data/benign/ds_linkedlist.py +4 -2
- patchhawk/data/benign/ds_queue.py +5 -4
- patchhawk/data/benign/ds_sorting.py +4 -4
- patchhawk/data/benign/ds_stack.py +5 -4
- patchhawk/data/benign/io_config.py +6 -6
- patchhawk/data/benign/io_csv.py +4 -4
- patchhawk/data/benign/io_json.py +2 -1
- patchhawk/data/benign/io_log.py +3 -3
- patchhawk/data/benign/io_template.py +1 -1
- patchhawk/data/benign/math_factorial.py +1 -1
- patchhawk/data/benign/math_fibonacci.py +1 -1
- patchhawk/data/benign/math_gcd.py +1 -1
- patchhawk/data/benign/math_matrix.py +3 -1
- patchhawk/data/benign/math_prime.py +2 -2
- patchhawk/data/benign/misc_calc.py +5 -5
- patchhawk/data/benign/misc_date.py +1 -1
- patchhawk/data/benign/misc_password.py +1 -1
- patchhawk/data/benign/misc_temp.py +3 -2
- patchhawk/data/benign/misc_url.py +6 -6
- patchhawk/data/benign/str_anagram.py +1 -1
- patchhawk/data/benign/str_caesar.py +2 -2
- patchhawk/data/benign/str_palindrome.py +2 -2
- patchhawk/data/benign/str_slug.py +3 -2
- patchhawk/data/benign/str_wordcount.py +1 -1
- patchhawk/data/generate_scenarios.py +6 -15
- patchhawk/data/scenarios.json +20 -7
- patchhawk/env_models.py +10 -9
- patchhawk/tasks.py +2 -7
- patchhawk/training/train_grpo.py +269 -164
- server/app.py +7 -1
- setup.py +1 -1
- tests/test_env.py +106 -57
- tests/test_sandbox.py +0 -1
- validate-submission.sh +162 -0
.gitignore
CHANGED
|
@@ -5,7 +5,8 @@ __pycache__/
|
|
| 5 |
|
| 6 |
# C extensions
|
| 7 |
*.so
|
| 8 |
-
|
|
|
|
| 9 |
# Distribution / packaging
|
| 10 |
.Python
|
| 11 |
build/
|
|
|
|
| 5 |
|
| 6 |
# C extensions
|
| 7 |
*.so
|
| 8 |
+
docs/
|
| 9 |
+
walkthrough_final.md
|
| 10 |
# Distribution / packaging
|
| 11 |
.Python
|
| 12 |
build/
|
README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# 🦅 PatchHawk: Autonomous Supply-Chain Guard
|
| 2 |
|
| 3 |
-
[](https://wandb.ai/
|
| 4 |
-
[](https://huggingface.co/
|
| 5 |
[](https://python.org)
|
| 6 |
[](LICENSE)
|
| 7 |
[](https://github.com/pytorch/openenv)
|
|
@@ -21,7 +21,7 @@ graph TD
|
|
| 21 |
end
|
| 22 |
|
| 23 |
subgraph OpenEnv Loop
|
| 24 |
-
D --> E["PatchHawkEnv (openenv.
|
| 25 |
E -->|observation| F["GRPO Agent (Qwen2.5-Coder-7B + LoRA)"]
|
| 26 |
F -->|action 0-4| E
|
| 27 |
E -->|EXECUTE_SANDBOX| G["Docker Sandbox (--network none)"]
|
|
@@ -43,7 +43,7 @@ graph TD
|
|
| 43 |
|
| 44 |
| Component | Path | Description |
|
| 45 |
|-----------|------|-------------|
|
| 46 |
-
| **Agent / Environment** | `patchhawk/agent/environment.py` | OpenEnv `openenv.
|
| 47 |
| **Agent / Sandbox** | `patchhawk/agent/sandbox.py` | Docker sandbox + 3-stage patch validation |
|
| 48 |
| **Agent / A2A Server** | `patchhawk/agent/server.py` | FastAPI: `GET /agent/card`, `POST /agent/act` |
|
| 49 |
| **Training** | `patchhawk/training/train_grpo.py` | GRPO with unsloth + trl, 4-bit LoRA, W&B logging |
|
|
@@ -205,7 +205,7 @@ PatchHawk exposes an **Agent-to-Agent (A2A)** interface via FastAPI:
|
|
| 205 |
- **No real malicious execution**: Docker sandbox runs with `--network none`, `--memory 256m`, `--cpus 0.5`, and non-root user.
|
| 206 |
- **Re-attack verification**: Stage 3 only checks for *attempts* (socket creation, file writes) — never permits actual harm.
|
| 207 |
- **SDK fallback**: If `synthetic-data-kit` CLI is not installed, Track A gracefully skips with a warning; Track B always generates ≥40 scenarios.
|
| 208 |
-
- **OpenEnv compliant**: `PatchHawkEnv` inherits `openenv.
|
| 209 |
- **Deterministic dry-run**: `--dry-run` mode requires zero GPU and no external services.
|
| 210 |
|
| 211 |
---
|
|
@@ -216,7 +216,7 @@ PatchHawk exposes an **Agent-to-Agent (A2A)** interface via FastAPI:
|
|
| 216 |
PatchHawk/
|
| 217 |
├── patchhawk/
|
| 218 |
│ ├── __init__.py # Config loader
|
| 219 |
-
│ ├──
|
| 220 |
│ ├── agent/
|
| 221 |
│ │ ├── __init__.py
|
| 222 |
│ │ ├── environment.py # openenv.Env implementation
|
|
|
|
| 1 |
# 🦅 PatchHawk: Autonomous Supply-Chain Guard
|
| 2 |
|
| 3 |
+
[](https://wandb.ai/ramprasathk07/patchhawk)
|
| 4 |
+
[](https://huggingface.co/ramprasathk07/patchhawk)
|
| 5 |
[](https://python.org)
|
| 6 |
[](LICENSE)
|
| 7 |
[](https://github.com/pytorch/openenv)
|
|
|
|
| 21 |
end
|
| 22 |
|
| 23 |
subgraph OpenEnv Loop
|
| 24 |
+
D --> E["PatchHawkEnv (openenv.core.Environment)"]
|
| 25 |
E -->|observation| F["GRPO Agent (Qwen2.5-Coder-7B + LoRA)"]
|
| 26 |
F -->|action 0-4| E
|
| 27 |
E -->|EXECUTE_SANDBOX| G["Docker Sandbox (--network none)"]
|
|
|
|
| 43 |
|
| 44 |
| Component | Path | Description |
|
| 45 |
|-----------|------|-------------|
|
| 46 |
+
| **Agent / Environment** | `patchhawk/agent/environment.py` | OpenEnv `openenv.core.Environment` with Pydantic typed obs/actions |
|
| 47 |
| **Agent / Sandbox** | `patchhawk/agent/sandbox.py` | Docker sandbox + 3-stage patch validation |
|
| 48 |
| **Agent / A2A Server** | `patchhawk/agent/server.py` | FastAPI: `GET /agent/card`, `POST /agent/act` |
|
| 49 |
| **Training** | `patchhawk/training/train_grpo.py` | GRPO with unsloth + trl, 4-bit LoRA, W&B logging |
|
|
|
|
| 205 |
- **No real malicious execution**: Docker sandbox runs with `--network none`, `--memory 256m`, `--cpus 0.5`, and non-root user.
|
| 206 |
- **Re-attack verification**: Stage 3 only checks for *attempts* (socket creation, file writes) — never permits actual harm.
|
| 207 |
- **SDK fallback**: If `synthetic-data-kit` CLI is not installed, Track A gracefully skips with a warning; Track B always generates ≥40 scenarios.
|
| 208 |
+
- **OpenEnv compliant**: `PatchHawkEnv` inherits `openenv.core.Environment` with proper `reset()` → `Observation` and `step(Action)` → `Observation` signatures (reward/done on observation).
|
| 209 |
- **Deterministic dry-run**: `--dry-run` mode requires zero GPU and no external services.
|
| 210 |
|
| 211 |
---
|
|
|
|
| 216 |
PatchHawk/
|
| 217 |
├── patchhawk/
|
| 218 |
│ ├── __init__.py # Config loader
|
| 219 |
+
│ ├── env_models.py # Pydantic Action/Observation/State models
|
| 220 |
│ ├── agent/
|
| 221 |
│ │ ├── __init__.py
|
| 222 |
│ │ ├── environment.py # openenv.Env implementation
|
check.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
import pandas as pd
|
| 2 |
|
| 3 |
df = pd.read_csv("/home/ram/Downloads/MSR_data_cleaned.csv")
|
| 4 |
-
print(df.head(2))
|
|
|
|
| 1 |
import pandas as pd
|
| 2 |
|
| 3 |
df = pd.read_csv("/home/ram/Downloads/MSR_data_cleaned.csv")
|
| 4 |
+
print(df.head(2))
|
config.yaml
CHANGED
|
@@ -38,5 +38,5 @@ a2a:
|
|
| 38 |
port: 8000
|
| 39 |
|
| 40 |
huggingface:
|
| 41 |
-
repo: "
|
| 42 |
push_after_training: true
|
|
|
|
| 38 |
port: 8000
|
| 39 |
|
| 40 |
huggingface:
|
| 41 |
+
repo: "ramprasathk07/patchhawk"
|
| 42 |
push_after_training: true
|
grpo_lora/adapter_model.bin
DELETED
|
Binary file (64 Bytes)
|
|
|
grpo_lora/config.json
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
{"model_type": "patchhawk-grpo-dry-run"}
|
|
|
|
|
|
inference.py
CHANGED
|
@@ -12,7 +12,7 @@ Environment variables:
|
|
| 12 |
|
| 13 |
Usage:
|
| 14 |
python inference.py # run all tasks via LLM
|
| 15 |
-
DRY_RUN=1 python inference.py # dry-run with
|
| 16 |
TASK=easy_typosquat python inference.py
|
| 17 |
"""
|
| 18 |
|
|
@@ -30,8 +30,10 @@ from patchhawk import tasks as graders
|
|
| 30 |
|
| 31 |
# ── Configuration ────────────────────────────────────────────────────
|
| 32 |
|
| 33 |
-
API_BASE_URL = os.getenv(
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
HF_TOKEN = os.getenv("HF_TOKEN", "")
|
| 36 |
DRY_RUN = os.getenv("DRY_RUN", "0") == "1"
|
| 37 |
SINGLE_TASK = os.getenv("TASK", "")
|
|
@@ -86,6 +88,7 @@ def _build_user_prompt(obs: PatchHawkObservation, step: int) -> str:
|
|
| 86 |
|
| 87 |
# ── LLM caller ───────────────────────────────────────────────────────
|
| 88 |
|
|
|
|
| 89 |
def _call_llm(messages: list[dict]) -> str:
|
| 90 |
"""Call the OpenAI-compatible LLM and return the text content."""
|
| 91 |
from openai import OpenAI
|
|
@@ -122,6 +125,7 @@ def _parse_action(text: str) -> PatchHawkAction:
|
|
| 122 |
|
| 123 |
# ── Episode runner ───────────────────────────────────────────────────
|
| 124 |
|
|
|
|
| 125 |
def run_episode(
|
| 126 |
env: PatchHawkEnv,
|
| 127 |
task_id: str,
|
|
@@ -155,7 +159,7 @@ def run_episode(
|
|
| 155 |
action = _parse_action(llm_text)
|
| 156 |
except Exception as exc:
|
| 157 |
error = str(exc)
|
| 158 |
-
#
|
| 159 |
action = PatchHawkAction(action_type=PatchHawkEnv.ACTION_BLOCK_PR)
|
| 160 |
|
| 161 |
# ── Step ─────────────────────────────────────────────────
|
|
@@ -169,21 +173,23 @@ def run_episode(
|
|
| 169 |
|
| 170 |
action_name = PatchHawkEnv.ACTION_NAMES[action.action_type]
|
| 171 |
_done = str(obs.done).lower()
|
| 172 |
-
_err =
|
| 173 |
print(
|
| 174 |
f"[STEP] step={step_num} action={action_name} "
|
| 175 |
-
f"reward={step_reward.value:.2f} done={_done} error={_err}"
|
|
|
|
| 176 |
)
|
| 177 |
error = None # reset for next step
|
| 178 |
|
| 179 |
# ── Grade ────────────────────────────────────────────────────
|
| 180 |
score = grader_fn(env, trajectory)
|
| 181 |
|
| 182 |
-
|
| 183 |
success = score >= 1.0
|
| 184 |
print(
|
| 185 |
f"[END] success={str(success).lower()} steps={step_num} "
|
| 186 |
-
f"score={score:.2f} rewards={
|
|
|
|
| 187 |
)
|
| 188 |
|
| 189 |
return {
|
|
@@ -197,6 +203,7 @@ def run_episode(
|
|
| 197 |
|
| 198 |
# ── Main ─────────────────────────────────────────────────────────────
|
| 199 |
|
|
|
|
| 200 |
def main():
|
| 201 |
env = PatchHawkEnv(use_docker=False)
|
| 202 |
|
|
@@ -226,7 +233,9 @@ def main():
|
|
| 226 |
# Summary
|
| 227 |
print("\n=== Summary ===")
|
| 228 |
for r in results:
|
| 229 |
-
print(
|
|
|
|
|
|
|
| 230 |
|
| 231 |
|
| 232 |
if __name__ == "__main__":
|
|
|
|
| 12 |
|
| 13 |
Usage:
|
| 14 |
python inference.py # run all tasks via LLM
|
| 15 |
+
DRY_RUN=1 python inference.py # dry-run with static mock actions
|
| 16 |
TASK=easy_typosquat python inference.py
|
| 17 |
"""
|
| 18 |
|
|
|
|
| 30 |
|
| 31 |
# ── Configuration ────────────────────────────────────────────────────
|
| 32 |
|
| 33 |
+
API_BASE_URL = os.getenv(
|
| 34 |
+
"API_BASE_URL", "https://router.huggingface.co/hf-inference/v1"
|
| 35 |
+
)
|
| 36 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-Coder-32B-Instruct")
|
| 37 |
HF_TOKEN = os.getenv("HF_TOKEN", "")
|
| 38 |
DRY_RUN = os.getenv("DRY_RUN", "0") == "1"
|
| 39 |
SINGLE_TASK = os.getenv("TASK", "")
|
|
|
|
| 88 |
|
| 89 |
# ── LLM caller ───────────────────────────────────────────────────────
|
| 90 |
|
| 91 |
+
|
| 92 |
def _call_llm(messages: list[dict]) -> str:
|
| 93 |
"""Call the OpenAI-compatible LLM and return the text content."""
|
| 94 |
from openai import OpenAI
|
|
|
|
| 125 |
|
| 126 |
# ── Episode runner ───────────────────────────────────────────────────
|
| 127 |
|
| 128 |
+
|
| 129 |
def run_episode(
|
| 130 |
env: PatchHawkEnv,
|
| 131 |
task_id: str,
|
|
|
|
| 159 |
action = _parse_action(llm_text)
|
| 160 |
except Exception as exc:
|
| 161 |
error = str(exc)
|
| 162 |
+
# Apply conservative BLOCK_PR constraint on malformed LLM responses
|
| 163 |
action = PatchHawkAction(action_type=PatchHawkEnv.ACTION_BLOCK_PR)
|
| 164 |
|
| 165 |
# ── Step ─────────────────────────────────────────────────
|
|
|
|
| 173 |
|
| 174 |
action_name = PatchHawkEnv.ACTION_NAMES[action.action_type]
|
| 175 |
_done = str(obs.done).lower()
|
| 176 |
+
_err = "null" if error is None else error
|
| 177 |
print(
|
| 178 |
f"[STEP] step={step_num} action={action_name} "
|
| 179 |
+
f"reward={step_reward.value:.2f} done={_done} error={_err}",
|
| 180 |
+
flush=True,
|
| 181 |
)
|
| 182 |
error = None # reset for next step
|
| 183 |
|
| 184 |
# ── Grade ────────────────────────────────────────────────────
|
| 185 |
score = grader_fn(env, trajectory)
|
| 186 |
|
| 187 |
+
rewards_str = ",".join(f"{r.value:.2f}" for r in rewards)
|
| 188 |
success = score >= 1.0
|
| 189 |
print(
|
| 190 |
f"[END] success={str(success).lower()} steps={step_num} "
|
| 191 |
+
f"score={score:.2f} rewards={rewards_str}",
|
| 192 |
+
flush=True,
|
| 193 |
)
|
| 194 |
|
| 195 |
return {
|
|
|
|
| 203 |
|
| 204 |
# ── Main ─────────────────────────────────────────────────────────────
|
| 205 |
|
| 206 |
+
|
| 207 |
def main():
|
| 208 |
env = PatchHawkEnv(use_docker=False)
|
| 209 |
|
|
|
|
| 233 |
# Summary
|
| 234 |
print("\n=== Summary ===")
|
| 235 |
for r in results:
|
| 236 |
+
print(
|
| 237 |
+
f" {r['task_id']}: success={r.get('success')} score={r.get('score', 'N/A')}"
|
| 238 |
+
)
|
| 239 |
|
| 240 |
|
| 241 |
if __name__ == "__main__":
|
patchhawk/__init__.py
CHANGED
|
@@ -45,11 +45,19 @@ ENV = {
|
|
| 45 |
_dotenv_raw.get("GRPO_POLICY_MODEL", "unsloth/Qwen2.5-Coder-7B-Instruct"),
|
| 46 |
),
|
| 47 |
"WANDB_API_KEY": os.getenv("WANDB_API_KEY", _dotenv_raw.get("WANDB_API_KEY", "")),
|
| 48 |
-
"WANDB_PROJECT": os.getenv(
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
"HF_TOKEN": os.getenv("HF_TOKEN", _dotenv_raw.get("HF_TOKEN", "")),
|
| 51 |
-
"HF_REPO": os.getenv(
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
# ── config.yaml loading ──────────────────────────────────────────
|
|
|
|
| 45 |
_dotenv_raw.get("GRPO_POLICY_MODEL", "unsloth/Qwen2.5-Coder-7B-Instruct"),
|
| 46 |
),
|
| 47 |
"WANDB_API_KEY": os.getenv("WANDB_API_KEY", _dotenv_raw.get("WANDB_API_KEY", "")),
|
| 48 |
+
"WANDB_PROJECT": os.getenv(
|
| 49 |
+
"WANDB_PROJECT", _dotenv_raw.get("WANDB_PROJECT", "patchhawk")
|
| 50 |
+
),
|
| 51 |
+
"WANDB_RUN_NAME": os.getenv(
|
| 52 |
+
"WANDB_RUN_NAME", _dotenv_raw.get("WANDB_RUN_NAME", "grpo-run-1")
|
| 53 |
+
),
|
| 54 |
"HF_TOKEN": os.getenv("HF_TOKEN", _dotenv_raw.get("HF_TOKEN", "")),
|
| 55 |
+
"HF_REPO": os.getenv(
|
| 56 |
+
"HF_REPO", _dotenv_raw.get("HF_REPO", "ramprasathk07/patchhawk")
|
| 57 |
+
),
|
| 58 |
+
"DOCKER_IMAGE": os.getenv(
|
| 59 |
+
"DOCKER_IMAGE", _dotenv_raw.get("DOCKER_IMAGE", "patchhawk-sandbox:latest")
|
| 60 |
+
),
|
| 61 |
}
|
| 62 |
|
| 63 |
# ── config.yaml loading ──────────────────────────────────────────
|
patchhawk/agent/environment.py
CHANGED
|
@@ -20,7 +20,6 @@ Reward table (set on Observation.reward):
|
|
| 20 |
|
| 21 |
import json
|
| 22 |
import random
|
| 23 |
-
from pathlib import Path
|
| 24 |
from typing import Optional, Any
|
| 25 |
from uuid import uuid4
|
| 26 |
|
|
@@ -34,9 +33,7 @@ from patchhawk.env_models import (
|
|
| 34 |
from patchhawk.agent.sandbox import run_code, validate_patch
|
| 35 |
|
| 36 |
|
| 37 |
-
class PatchHawkEnv(
|
| 38 |
-
Environment[PatchHawkAction, PatchHawkObservation, PatchHawkState]
|
| 39 |
-
):
|
| 40 |
"""OpenEnv environment for PatchHawk."""
|
| 41 |
|
| 42 |
# Action constants
|
|
@@ -123,7 +120,9 @@ class PatchHawkEnv(
|
|
| 123 |
risk = sum(flags) / max(len(flags), 1)
|
| 124 |
|
| 125 |
meta: dict[str, Any] = {
|
| 126 |
-
"scenario_id": self.current_scenario.get("id", "none")
|
|
|
|
|
|
|
| 127 |
"step": self.step_counter,
|
| 128 |
"cumulative_reward": self.cumulative_reward,
|
| 129 |
"reward_reason": reason,
|
|
@@ -167,8 +166,13 @@ class PatchHawkEnv(
|
|
| 167 |
if seed is not None:
|
| 168 |
random.seed(seed)
|
| 169 |
|
|
|
|
|
|
|
|
|
|
| 170 |
# Pick scenario
|
| 171 |
-
if
|
|
|
|
|
|
|
| 172 |
self.current_scenario = {
|
| 173 |
"id": "fallback",
|
| 174 |
"type": "functional",
|
|
@@ -194,10 +198,13 @@ class PatchHawkEnv(
|
|
| 194 |
matches = [s for s in self.scenarios if s.get("attack_type") == atk]
|
| 195 |
else:
|
| 196 |
matches = [
|
| 197 |
-
s
|
|
|
|
| 198 |
if s.get("label") == "malicious" and s.get("patch")
|
| 199 |
]
|
| 200 |
-
self.current_scenario =
|
|
|
|
|
|
|
| 201 |
else:
|
| 202 |
self.current_scenario = random.choice(self.scenarios)
|
| 203 |
|
|
|
|
| 20 |
|
| 21 |
import json
|
| 22 |
import random
|
|
|
|
| 23 |
from typing import Optional, Any
|
| 24 |
from uuid import uuid4
|
| 25 |
|
|
|
|
| 33 |
from patchhawk.agent.sandbox import run_code, validate_patch
|
| 34 |
|
| 35 |
|
| 36 |
+
class PatchHawkEnv(Environment[PatchHawkAction, PatchHawkObservation, PatchHawkState]):
|
|
|
|
|
|
|
| 37 |
"""OpenEnv environment for PatchHawk."""
|
| 38 |
|
| 39 |
# Action constants
|
|
|
|
| 120 |
risk = sum(flags) / max(len(flags), 1)
|
| 121 |
|
| 122 |
meta: dict[str, Any] = {
|
| 123 |
+
"scenario_id": self.current_scenario.get("id", "none")
|
| 124 |
+
if self.current_scenario
|
| 125 |
+
else "none",
|
| 126 |
"step": self.step_counter,
|
| 127 |
"cumulative_reward": self.cumulative_reward,
|
| 128 |
"reward_reason": reason,
|
|
|
|
| 166 |
if seed is not None:
|
| 167 |
random.seed(seed)
|
| 168 |
|
| 169 |
+
# Check for direct scenario override (used by GRPO training)
|
| 170 |
+
scenario_override = kwargs.get("scenario")
|
| 171 |
+
|
| 172 |
# Pick scenario
|
| 173 |
+
if scenario_override:
|
| 174 |
+
self.current_scenario = scenario_override
|
| 175 |
+
elif not self.scenarios:
|
| 176 |
self.current_scenario = {
|
| 177 |
"id": "fallback",
|
| 178 |
"type": "functional",
|
|
|
|
| 198 |
matches = [s for s in self.scenarios if s.get("attack_type") == atk]
|
| 199 |
else:
|
| 200 |
matches = [
|
| 201 |
+
s
|
| 202 |
+
for s in self.scenarios
|
| 203 |
if s.get("label") == "malicious" and s.get("patch")
|
| 204 |
]
|
| 205 |
+
self.current_scenario = (
|
| 206 |
+
random.choice(matches) if matches else random.choice(self.scenarios)
|
| 207 |
+
)
|
| 208 |
else:
|
| 209 |
self.current_scenario = random.choice(self.scenarios)
|
| 210 |
|
patchhawk/agent/sandbox.py
CHANGED
|
@@ -45,20 +45,25 @@ def run_code(
|
|
| 45 |
try:
|
| 46 |
if use_docker:
|
| 47 |
cmd = [
|
| 48 |
-
"docker",
|
| 49 |
-
"
|
| 50 |
-
"--
|
| 51 |
-
"--
|
| 52 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
"patchhawk-sandbox:latest",
|
| 54 |
-
"python",
|
|
|
|
| 55 |
]
|
| 56 |
else:
|
| 57 |
cmd = ["python3", script_path]
|
| 58 |
|
| 59 |
-
proc = subprocess.run(
|
| 60 |
-
cmd, capture_output=True, text=True, timeout=timeout_sec
|
| 61 |
-
)
|
| 62 |
result["stdout"] = proc.stdout
|
| 63 |
result["stderr"] = proc.stderr
|
| 64 |
result["exit_code"] = proc.returncode
|
|
@@ -92,13 +97,22 @@ def check_syntax(
|
|
| 92 |
try:
|
| 93 |
if use_docker:
|
| 94 |
cmd = [
|
| 95 |
-
"docker",
|
| 96 |
-
"
|
| 97 |
-
"--
|
| 98 |
-
"--
|
| 99 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
"patchhawk-sandbox:latest",
|
| 101 |
-
"python",
|
|
|
|
|
|
|
|
|
|
| 102 |
]
|
| 103 |
else:
|
| 104 |
cmd = ["python3", "-m", "py_compile", script_path]
|
|
@@ -166,20 +180,20 @@ def validate_patch(
|
|
| 166 |
# Stage 3 – Re-attack verification
|
| 167 |
# ------------------------------------------------------------------
|
| 168 |
if scenario.get("type") == "true_positive" and scenario.get("attack_type"):
|
| 169 |
-
attack_res = run_code(
|
|
|
|
|
|
|
| 170 |
patch_res = run_code(patch_code, timeout_sec=5, use_docker=use_docker)
|
| 171 |
|
| 172 |
vulnerability_remains = False
|
| 173 |
|
| 174 |
# Check file-write side-effects
|
| 175 |
-
if (
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
>= len(attack_res.get("file_writes", []))
|
| 179 |
-
):
|
| 180 |
vulnerability_remains = True
|
| 181 |
|
| 182 |
-
#
|
| 183 |
attack_type = scenario["attack_type"]
|
| 184 |
if attack_type == "typosquatting":
|
| 185 |
if "pythonn" in patch_res.get("stderr", ""):
|
|
|
|
| 45 |
try:
|
| 46 |
if use_docker:
|
| 47 |
cmd = [
|
| 48 |
+
"docker",
|
| 49 |
+
"run",
|
| 50 |
+
"--rm",
|
| 51 |
+
"--network",
|
| 52 |
+
"none",
|
| 53 |
+
"--memory",
|
| 54 |
+
"256m",
|
| 55 |
+
"--cpus",
|
| 56 |
+
"0.5",
|
| 57 |
+
"-v",
|
| 58 |
+
f"{temp_dir}:/app:rw",
|
| 59 |
"patchhawk-sandbox:latest",
|
| 60 |
+
"python",
|
| 61 |
+
"/app/script.py",
|
| 62 |
]
|
| 63 |
else:
|
| 64 |
cmd = ["python3", script_path]
|
| 65 |
|
| 66 |
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
|
|
|
|
|
|
|
| 67 |
result["stdout"] = proc.stdout
|
| 68 |
result["stderr"] = proc.stderr
|
| 69 |
result["exit_code"] = proc.returncode
|
|
|
|
| 97 |
try:
|
| 98 |
if use_docker:
|
| 99 |
cmd = [
|
| 100 |
+
"docker",
|
| 101 |
+
"run",
|
| 102 |
+
"--rm",
|
| 103 |
+
"--network",
|
| 104 |
+
"none",
|
| 105 |
+
"--memory",
|
| 106 |
+
"256m",
|
| 107 |
+
"--cpus",
|
| 108 |
+
"0.5",
|
| 109 |
+
"-v",
|
| 110 |
+
f"{temp_dir}:/app:ro",
|
| 111 |
"patchhawk-sandbox:latest",
|
| 112 |
+
"python",
|
| 113 |
+
"-m",
|
| 114 |
+
"py_compile",
|
| 115 |
+
"/app/script.py",
|
| 116 |
]
|
| 117 |
else:
|
| 118 |
cmd = ["python3", "-m", "py_compile", script_path]
|
|
|
|
| 180 |
# Stage 3 – Re-attack verification
|
| 181 |
# ------------------------------------------------------------------
|
| 182 |
if scenario.get("type") == "true_positive" and scenario.get("attack_type"):
|
| 183 |
+
attack_res = run_code(
|
| 184 |
+
scenario["code_snippet"], timeout_sec=5, use_docker=use_docker
|
| 185 |
+
)
|
| 186 |
patch_res = run_code(patch_code, timeout_sec=5, use_docker=use_docker)
|
| 187 |
|
| 188 |
vulnerability_remains = False
|
| 189 |
|
| 190 |
# Check file-write side-effects
|
| 191 |
+
if len(attack_res.get("file_writes", [])) > 0 and len(
|
| 192 |
+
patch_res.get("file_writes", [])
|
| 193 |
+
) >= len(attack_res.get("file_writes", [])):
|
|
|
|
|
|
|
| 194 |
vulnerability_remains = True
|
| 195 |
|
| 196 |
+
# Isolate and match syntax tree against domain-specific threat heuristics
|
| 197 |
attack_type = scenario["attack_type"]
|
| 198 |
if attack_type == "typosquatting":
|
| 199 |
if "pythonn" in patch_res.get("stderr", ""):
|
patchhawk/agent/server.py
CHANGED
|
@@ -15,7 +15,6 @@ import os
|
|
| 15 |
import sys
|
| 16 |
from typing import Optional
|
| 17 |
|
| 18 |
-
from fastapi import FastAPI
|
| 19 |
from pydantic import BaseModel, Field
|
| 20 |
|
| 21 |
from openenv.core import create_app
|
|
@@ -24,10 +23,19 @@ from patchhawk.agent.environment import PatchHawkEnv
|
|
| 24 |
from patchhawk.env_models import PatchHawkAction, PatchHawkObservation
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
# ── OpenEnv app (primary) ─────────────────────────────────────────
|
| 28 |
|
| 29 |
openenv_app = create_app(
|
| 30 |
-
|
| 31 |
PatchHawkAction,
|
| 32 |
PatchHawkObservation,
|
| 33 |
env_name="PatchHawk",
|
|
@@ -36,12 +44,15 @@ openenv_app = create_app(
|
|
| 36 |
|
| 37 |
# ── Legacy A2A schemas ────────────────────────────────────────────
|
| 38 |
|
|
|
|
| 39 |
class ActRequest(BaseModel):
|
| 40 |
code_snippet: str = Field(..., description="Python source code to analyse")
|
| 41 |
|
| 42 |
|
| 43 |
class ActResponse(BaseModel):
|
| 44 |
-
decision: str = Field(
|
|
|
|
|
|
|
| 45 |
patch: Optional[str] = Field(None, description="Proposed patch code, if any")
|
| 46 |
confidence: float = Field(..., description="Agent confidence score 0-1")
|
| 47 |
reward: float = Field(0.0, description="Total episode reward")
|
|
@@ -63,7 +74,10 @@ class AgentCard(BaseModel):
|
|
| 63 |
input_schema: dict = {
|
| 64 |
"type": "object",
|
| 65 |
"properties": {
|
| 66 |
-
"code_snippet": {
|
|
|
|
|
|
|
|
|
|
| 67 |
},
|
| 68 |
"required": ["code_snippet"],
|
| 69 |
}
|
|
@@ -123,17 +137,15 @@ def agent_act(request: ActRequest):
|
|
| 123 |
}
|
| 124 |
|
| 125 |
# Force this scenario
|
| 126 |
-
|
| 127 |
-
obs = env.reset()
|
| 128 |
|
| 129 |
-
done = False
|
| 130 |
total_reward = 0.0
|
| 131 |
decision_action = None
|
| 132 |
|
| 133 |
while not obs.done:
|
| 134 |
risk = obs.risk_score
|
| 135 |
|
| 136 |
-
#
|
| 137 |
if risk > 0.6:
|
| 138 |
action = PatchHawkAction(action_type=PatchHawkEnv.ACTION_BLOCK_PR)
|
| 139 |
elif risk > 0.3:
|
|
@@ -142,13 +154,15 @@ def agent_act(request: ActRequest):
|
|
| 142 |
action = PatchHawkAction(action_type=PatchHawkEnv.ACTION_REQUEST_REVIEW)
|
| 143 |
|
| 144 |
obs = env.step(action)
|
| 145 |
-
total_reward +=
|
| 146 |
decision_action = action.action_type
|
| 147 |
|
| 148 |
confidence = min(1.0, max(0.0, 0.5 + total_reward / 6.0))
|
| 149 |
|
| 150 |
return ActResponse(
|
| 151 |
-
decision=PatchHawkEnv.ACTION_NAMES[decision_action]
|
|
|
|
|
|
|
| 152 |
patch=None,
|
| 153 |
confidence=round(confidence, 2),
|
| 154 |
reward=round(total_reward, 2),
|
|
@@ -158,6 +172,7 @@ def agent_act(request: ActRequest):
|
|
| 158 |
|
| 159 |
# ── CLI entry point ──────────────────────────────────────────────
|
| 160 |
|
|
|
|
| 161 |
def main():
|
| 162 |
import uvicorn
|
| 163 |
|
|
|
|
| 15 |
import sys
|
| 16 |
from typing import Optional
|
| 17 |
|
|
|
|
| 18 |
from pydantic import BaseModel, Field
|
| 19 |
|
| 20 |
from openenv.core import create_app
|
|
|
|
| 23 |
from patchhawk.env_models import PatchHawkAction, PatchHawkObservation
|
| 24 |
|
| 25 |
|
| 26 |
+
# ── OpenEnv factory ───────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _env_factory() -> PatchHawkEnv:
|
| 30 |
+
"""Factory callable for create_app — returns a fresh PatchHawkEnv."""
|
| 31 |
+
scenarios_path = os.getenv("PATCHHAWK_SCENARIOS", "patchhawk/data/scenarios.json")
|
| 32 |
+
return PatchHawkEnv(scenarios_path=scenarios_path, use_docker=False)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
# ── OpenEnv app (primary) ─────────────────────────────────────────
|
| 36 |
|
| 37 |
openenv_app = create_app(
|
| 38 |
+
_env_factory,
|
| 39 |
PatchHawkAction,
|
| 40 |
PatchHawkObservation,
|
| 41 |
env_name="PatchHawk",
|
|
|
|
| 44 |
|
| 45 |
# ── Legacy A2A schemas ────────────────────────────────────────────
|
| 46 |
|
| 47 |
+
|
| 48 |
class ActRequest(BaseModel):
|
| 49 |
code_snippet: str = Field(..., description="Python source code to analyse")
|
| 50 |
|
| 51 |
|
| 52 |
class ActResponse(BaseModel):
|
| 53 |
+
decision: str = Field(
|
| 54 |
+
..., description="Agent decision (BLOCK_PR, SUBMIT_PATCH, etc.)"
|
| 55 |
+
)
|
| 56 |
patch: Optional[str] = Field(None, description="Proposed patch code, if any")
|
| 57 |
confidence: float = Field(..., description="Agent confidence score 0-1")
|
| 58 |
reward: float = Field(0.0, description="Total episode reward")
|
|
|
|
| 74 |
input_schema: dict = {
|
| 75 |
"type": "object",
|
| 76 |
"properties": {
|
| 77 |
+
"code_snippet": {
|
| 78 |
+
"type": "string",
|
| 79 |
+
"description": "Python source code to analyse",
|
| 80 |
+
},
|
| 81 |
},
|
| 82 |
"required": ["code_snippet"],
|
| 83 |
}
|
|
|
|
| 137 |
}
|
| 138 |
|
| 139 |
# Force this scenario
|
| 140 |
+
obs = env.reset(scenario=scenario)
|
|
|
|
| 141 |
|
|
|
|
| 142 |
total_reward = 0.0
|
| 143 |
decision_action = None
|
| 144 |
|
| 145 |
while not obs.done:
|
| 146 |
risk = obs.risk_score
|
| 147 |
|
| 148 |
+
# Native rule-based baseline policy (Zero-shot RL initialization state)
|
| 149 |
if risk > 0.6:
|
| 150 |
action = PatchHawkAction(action_type=PatchHawkEnv.ACTION_BLOCK_PR)
|
| 151 |
elif risk > 0.3:
|
|
|
|
| 154 |
action = PatchHawkAction(action_type=PatchHawkEnv.ACTION_REQUEST_REVIEW)
|
| 155 |
|
| 156 |
obs = env.step(action)
|
| 157 |
+
total_reward += obs.reward or 0.0
|
| 158 |
decision_action = action.action_type
|
| 159 |
|
| 160 |
confidence = min(1.0, max(0.0, 0.5 + total_reward / 6.0))
|
| 161 |
|
| 162 |
return ActResponse(
|
| 163 |
+
decision=PatchHawkEnv.ACTION_NAMES[decision_action]
|
| 164 |
+
if decision_action is not None
|
| 165 |
+
else "ANALYZE",
|
| 166 |
patch=None,
|
| 167 |
confidence=round(confidence, 2),
|
| 168 |
reward=round(total_reward, 2),
|
|
|
|
| 172 |
|
| 173 |
# ── CLI entry point ──────────────────────────────────────────────
|
| 174 |
|
| 175 |
+
|
| 176 |
def main():
|
| 177 |
import uvicorn
|
| 178 |
|
patchhawk/app/dashboard.py
CHANGED
|
@@ -5,8 +5,6 @@ Usage:
|
|
| 5 |
streamlit run patchhawk/app/dashboard.py
|
| 6 |
"""
|
| 7 |
|
| 8 |
-
import json
|
| 9 |
-
import os
|
| 10 |
import sys
|
| 11 |
import time
|
| 12 |
from pathlib import Path
|
|
@@ -20,6 +18,7 @@ if _project_root not in sys.path:
|
|
| 20 |
|
| 21 |
from patchhawk.agent.environment import PatchHawkEnv
|
| 22 |
from patchhawk.agent.sandbox import validate_patch
|
|
|
|
| 23 |
|
| 24 |
# ── Page config ───────────────────────────────────────────────────
|
| 25 |
st.set_page_config(
|
|
@@ -30,7 +29,8 @@ st.set_page_config(
|
|
| 30 |
)
|
| 31 |
|
| 32 |
# ── Custom styling ────────────────────────────────────────────────
|
| 33 |
-
st.markdown(
|
|
|
|
| 34 |
<style>
|
| 35 |
:root {
|
| 36 |
--cobalt: #0047AB;
|
|
@@ -61,7 +61,9 @@ st.markdown("""
|
|
| 61 |
.status-benign { color: var(--accent-green); font-weight: bold; }
|
| 62 |
.status-patched { color: var(--accent-blue); font-weight: bold; }
|
| 63 |
</style>
|
| 64 |
-
""",
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
# ── Singleton env ─────────────────────────────────────────────────
|
|
@@ -74,8 +76,7 @@ def get_env():
|
|
| 74 |
def main():
|
| 75 |
st.title("🦅 PatchHawk | Supply-Chain Guard")
|
| 76 |
st.caption(
|
| 77 |
-
"RL-powered vulnerability detection and auto-patching — "
|
| 78 |
-
"OpenEnv Hackathon MVP"
|
| 79 |
)
|
| 80 |
|
| 81 |
env = get_env()
|
|
@@ -138,48 +139,109 @@ def main():
|
|
| 138 |
}
|
| 139 |
|
| 140 |
with st.spinner("Agent running in OpenEnv…"):
|
| 141 |
-
obs
|
| 142 |
time.sleep(0.4) # visual feedback
|
| 143 |
-
|
| 144 |
-
risk = float(obs["risk_score"][0])
|
| 145 |
|
| 146 |
# Step 1 – Analyze
|
| 147 |
-
obs
|
|
|
|
| 148 |
|
| 149 |
-
# Step 2 –
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
# ── Results ───────────────────────────────────────────────
|
| 161 |
st.subheader("📊 Agent Report")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
m1, m2, m3 = st.columns(3)
|
| 163 |
m1.metric("Risk Score", f"{risk:.2f}")
|
| 164 |
-
m2.metric("Decision",
|
| 165 |
m3.metric("Reward", f"{total_reward:+.2f}")
|
| 166 |
|
| 167 |
-
tab1, tab2, tab3 = st.tabs(
|
|
|
|
|
|
|
| 168 |
|
| 169 |
with tab1:
|
| 170 |
-
if
|
| 171 |
st.markdown(
|
| 172 |
"<div class='info-box status-malicious'>⛔ BLOCKED — "
|
| 173 |
"Vulnerability detected.</div>",
|
| 174 |
unsafe_allow_html=True,
|
| 175 |
)
|
| 176 |
-
elif
|
| 177 |
st.markdown(
|
| 178 |
"<div class='info-box status-patched'>🩹 PATCH SUBMITTED — "
|
| 179 |
"Vulnerability neutralised.</div>",
|
| 180 |
unsafe_allow_html=True,
|
| 181 |
)
|
| 182 |
-
val_info =
|
| 183 |
if val_info:
|
| 184 |
st.info(val_info)
|
| 185 |
else:
|
|
@@ -190,14 +252,16 @@ def main():
|
|
| 190 |
)
|
| 191 |
|
| 192 |
with tab2:
|
| 193 |
-
telem =
|
| 194 |
if telem:
|
| 195 |
st.json(telem)
|
| 196 |
else:
|
| 197 |
st.info("No sandbox execution for this path.")
|
| 198 |
|
| 199 |
with tab3:
|
| 200 |
-
if
|
|
|
|
|
|
|
| 201 |
st.code(scenario["patch"], language="python")
|
| 202 |
|
| 203 |
# Run validation pipeline for display
|
|
|
|
| 5 |
streamlit run patchhawk/app/dashboard.py
|
| 6 |
"""
|
| 7 |
|
|
|
|
|
|
|
| 8 |
import sys
|
| 9 |
import time
|
| 10 |
from pathlib import Path
|
|
|
|
| 18 |
|
| 19 |
from patchhawk.agent.environment import PatchHawkEnv
|
| 20 |
from patchhawk.agent.sandbox import validate_patch
|
| 21 |
+
from patchhawk.env_models import PatchHawkAction
|
| 22 |
|
| 23 |
# ── Page config ───────────────────────────────────────────────────
|
| 24 |
st.set_page_config(
|
|
|
|
| 29 |
)
|
| 30 |
|
| 31 |
# ── Custom styling ────────────────────────────────────────────────
|
| 32 |
+
st.markdown(
|
| 33 |
+
"""
|
| 34 |
<style>
|
| 35 |
:root {
|
| 36 |
--cobalt: #0047AB;
|
|
|
|
| 61 |
.status-benign { color: var(--accent-green); font-weight: bold; }
|
| 62 |
.status-patched { color: var(--accent-blue); font-weight: bold; }
|
| 63 |
</style>
|
| 64 |
+
""",
|
| 65 |
+
unsafe_allow_html=True,
|
| 66 |
+
)
|
| 67 |
|
| 68 |
|
| 69 |
# ── Singleton env ─────────────────────────────────────────────────
|
|
|
|
| 76 |
def main():
|
| 77 |
st.title("🦅 PatchHawk | Supply-Chain Guard")
|
| 78 |
st.caption(
|
| 79 |
+
"RL-powered vulnerability detection and auto-patching — OpenEnv Hackathon MVP"
|
|
|
|
| 80 |
)
|
| 81 |
|
| 82 |
env = get_env()
|
|
|
|
| 139 |
}
|
| 140 |
|
| 141 |
with st.spinner("Agent running in OpenEnv…"):
|
| 142 |
+
obs = env.reset(scenario=scenario)
|
| 143 |
time.sleep(0.4) # visual feedback
|
| 144 |
+
risk = obs.risk_score
|
|
|
|
| 145 |
|
| 146 |
# Step 1 – Analyze
|
| 147 |
+
obs = env.step(PatchHawkAction(action_type=PatchHawkEnv.ACTION_ANALYZE))
|
| 148 |
+
r1 = obs.reward or 0.0
|
| 149 |
|
| 150 |
+
# Step 2 – Zero-shot LLM inference or rule-based static analysis
|
| 151 |
+
llm_thought_process = ""
|
| 152 |
+
try:
|
| 153 |
+
from inference import (
|
| 154 |
+
_build_user_prompt,
|
| 155 |
+
_call_llm,
|
| 156 |
+
_parse_action,
|
| 157 |
+
SYSTEM_PROMPT,
|
| 158 |
+
)
|
| 159 |
|
| 160 |
+
# Attempt real LLM integration
|
| 161 |
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 162 |
+
user_msg = _build_user_prompt(obs, 1)
|
| 163 |
+
messages.append({"role": "user", "content": user_msg})
|
| 164 |
+
|
| 165 |
+
llm_response = _call_llm(messages)
|
| 166 |
+
llm_thought_process = llm_response
|
| 167 |
+
|
| 168 |
+
action = _parse_action(llm_response)
|
| 169 |
+
final_action_type = action.action_type
|
| 170 |
+
if (
|
| 171 |
+
final_action_type == PatchHawkEnv.ACTION_SUBMIT_PATCH
|
| 172 |
+
and action.patch_content
|
| 173 |
+
):
|
| 174 |
+
scenario["patch"] = action.patch_content # inject LLM patch
|
| 175 |
+
except Exception as e:
|
| 176 |
+
# LLM Service Unavailable: Initiating Static Analysis Fallback
|
| 177 |
+
llm_thought_process = f"⚠️ LLM Error or HF_TOKEN missing ({e}). Using rule-based static fallback."
|
| 178 |
+
if risk > 0.4 and scenario.get("patch"):
|
| 179 |
+
final_action_type = PatchHawkEnv.ACTION_SUBMIT_PATCH
|
| 180 |
+
elif risk > 0.6:
|
| 181 |
+
final_action_type = PatchHawkEnv.ACTION_BLOCK_PR
|
| 182 |
+
else:
|
| 183 |
+
final_action_type = PatchHawkEnv.ACTION_REQUEST_REVIEW
|
| 184 |
+
action = PatchHawkAction(action_type=final_action_type)
|
| 185 |
+
|
| 186 |
+
# Visual Hacker Terminal Effect
|
| 187 |
+
if final_action_type == PatchHawkEnv.ACTION_SUBMIT_PATCH:
|
| 188 |
+
with st.status(
|
| 189 |
+
"💻 Injecting Patch into Sandbox Terminal...", expanded=True
|
| 190 |
+
) as status:
|
| 191 |
+
st.write("⏳ Containerizing Python Syntax check...")
|
| 192 |
+
time.sleep(0.4)
|
| 193 |
+
st.write("✅ Syntax verified.")
|
| 194 |
+
st.write("⏳ Running Unit Test validations...")
|
| 195 |
+
time.sleep(0.5)
|
| 196 |
+
st.write("✅ Regression checks passed.")
|
| 197 |
+
st.write("⏳ Re-Attacking Payload against isolated memory...")
|
| 198 |
+
time.sleep(0.8)
|
| 199 |
+
|
| 200 |
+
obs = env.step(action)
|
| 201 |
+
r2 = obs.reward or 0.0
|
| 202 |
+
total_reward = r1 + r2
|
| 203 |
+
|
| 204 |
+
if r2 > 0:
|
| 205 |
+
st.write("🛑 **Threat Neutralized Successfully!**")
|
| 206 |
+
status.update(label="Patch Verified!", state="complete")
|
| 207 |
+
else:
|
| 208 |
+
st.write("🚨 **Patch Failed to Neutralize Attack!**")
|
| 209 |
+
status.update(label="Validation Failed", state="error")
|
| 210 |
+
else:
|
| 211 |
+
with st.spinner("Agent committing decision..."):
|
| 212 |
+
obs = env.step(action)
|
| 213 |
+
r2 = obs.reward or 0.0
|
| 214 |
+
total_reward = r1 + r2
|
| 215 |
|
| 216 |
# ── Results ───────────────────────────────────────────────
|
| 217 |
st.subheader("📊 Agent Report")
|
| 218 |
+
|
| 219 |
+
with st.expander("🤖 Agent Thought Process (LLM Trace)"):
|
| 220 |
+
st.markdown(f"```json\n{llm_thought_process}\n```")
|
| 221 |
+
|
| 222 |
m1, m2, m3 = st.columns(3)
|
| 223 |
m1.metric("Risk Score", f"{risk:.2f}")
|
| 224 |
+
m2.metric("Decision", PatchHawkEnv.ACTION_NAMES[final_action_type])
|
| 225 |
m3.metric("Reward", f"{total_reward:+.2f}")
|
| 226 |
|
| 227 |
+
tab1, tab2, tab3 = st.tabs(
|
| 228 |
+
["Action Details", "Docker Telemetry", "Patch Proposal"]
|
| 229 |
+
)
|
| 230 |
|
| 231 |
with tab1:
|
| 232 |
+
if final_action_type == PatchHawkEnv.ACTION_BLOCK_PR:
|
| 233 |
st.markdown(
|
| 234 |
"<div class='info-box status-malicious'>⛔ BLOCKED — "
|
| 235 |
"Vulnerability detected.</div>",
|
| 236 |
unsafe_allow_html=True,
|
| 237 |
)
|
| 238 |
+
elif final_action_type == PatchHawkEnv.ACTION_SUBMIT_PATCH:
|
| 239 |
st.markdown(
|
| 240 |
"<div class='info-box status-patched'>🩹 PATCH SUBMITTED — "
|
| 241 |
"Vulnerability neutralised.</div>",
|
| 242 |
unsafe_allow_html=True,
|
| 243 |
)
|
| 244 |
+
val_info = obs.metadata.get("validation", "")
|
| 245 |
if val_info:
|
| 246 |
st.info(val_info)
|
| 247 |
else:
|
|
|
|
| 252 |
)
|
| 253 |
|
| 254 |
with tab2:
|
| 255 |
+
telem = obs.metadata.get("telemetry")
|
| 256 |
if telem:
|
| 257 |
st.json(telem)
|
| 258 |
else:
|
| 259 |
st.info("No sandbox execution for this path.")
|
| 260 |
|
| 261 |
with tab3:
|
| 262 |
+
if final_action_type == PatchHawkEnv.ACTION_SUBMIT_PATCH and scenario.get(
|
| 263 |
+
"patch"
|
| 264 |
+
):
|
| 265 |
st.code(scenario["patch"], language="python")
|
| 266 |
|
| 267 |
# Run validation pipeline for display
|
patchhawk/data/benign/ds_binarysearch.py
CHANGED
|
@@ -10,4 +10,4 @@ def binary_search(arr, target):
|
|
| 10 |
low = mid + 1
|
| 11 |
else:
|
| 12 |
high = mid - 1
|
| 13 |
-
return -1
|
|
|
|
| 10 |
low = mid + 1
|
| 11 |
else:
|
| 12 |
high = mid - 1
|
| 13 |
+
return -1
|
patchhawk/data/benign/ds_linkedlist.py
CHANGED
|
@@ -3,11 +3,13 @@ class Node:
|
|
| 3 |
self.data = data
|
| 4 |
self.next = None
|
| 5 |
|
|
|
|
| 6 |
class LinkedList:
|
| 7 |
"""A simple linked list."""
|
|
|
|
| 8 |
def __init__(self):
|
| 9 |
self.head = None
|
| 10 |
-
|
| 11 |
def append(self, data):
|
| 12 |
new_node = Node(data)
|
| 13 |
if not self.head:
|
|
@@ -16,4 +18,4 @@ class LinkedList:
|
|
| 16 |
last = self.head
|
| 17 |
while last.next:
|
| 18 |
last = last.next
|
| 19 |
-
last.next = new_node
|
|
|
|
| 3 |
self.data = data
|
| 4 |
self.next = None
|
| 5 |
|
| 6 |
+
|
| 7 |
class LinkedList:
|
| 8 |
"""A simple linked list."""
|
| 9 |
+
|
| 10 |
def __init__(self):
|
| 11 |
self.head = None
|
| 12 |
+
|
| 13 |
def append(self, data):
|
| 14 |
new_node = Node(data)
|
| 15 |
if not self.head:
|
|
|
|
| 18 |
last = self.head
|
| 19 |
while last.next:
|
| 20 |
last = last.next
|
| 21 |
+
last.next = new_node
|
patchhawk/data/benign/ds_queue.py
CHANGED
|
@@ -1,15 +1,16 @@
|
|
| 1 |
class Queue:
|
| 2 |
"""A simple queue implementation."""
|
|
|
|
| 3 |
def __init__(self):
|
| 4 |
self.items = []
|
| 5 |
-
|
| 6 |
def enqueue(self, item):
|
| 7 |
self.items.insert(0, item)
|
| 8 |
-
|
| 9 |
def dequeue(self):
|
| 10 |
if not self.is_empty():
|
| 11 |
return self.items.pop()
|
| 12 |
return None
|
| 13 |
-
|
| 14 |
def is_empty(self):
|
| 15 |
-
return len(self.items) == 0
|
|
|
|
| 1 |
class Queue:
|
| 2 |
"""A simple queue implementation."""
|
| 3 |
+
|
| 4 |
def __init__(self):
|
| 5 |
self.items = []
|
| 6 |
+
|
| 7 |
def enqueue(self, item):
|
| 8 |
self.items.insert(0, item)
|
| 9 |
+
|
| 10 |
def dequeue(self):
|
| 11 |
if not self.is_empty():
|
| 12 |
return self.items.pop()
|
| 13 |
return None
|
| 14 |
+
|
| 15 |
def is_empty(self):
|
| 16 |
+
return len(self.items) == 0
|
patchhawk/data/benign/ds_sorting.py
CHANGED
|
@@ -2,7 +2,7 @@ def bubble_sort(arr):
|
|
| 2 |
"""Sort an array using bubble sort."""
|
| 3 |
n = len(arr)
|
| 4 |
for i in range(n):
|
| 5 |
-
for j in range(0, n-i-1):
|
| 6 |
-
if arr[j] > arr[j+1]:
|
| 7 |
-
arr[j], arr[j+1] = arr[j+1], arr[j]
|
| 8 |
-
return arr
|
|
|
|
| 2 |
"""Sort an array using bubble sort."""
|
| 3 |
n = len(arr)
|
| 4 |
for i in range(n):
|
| 5 |
+
for j in range(0, n - i - 1):
|
| 6 |
+
if arr[j] > arr[j + 1]:
|
| 7 |
+
arr[j], arr[j + 1] = arr[j + 1], arr[j]
|
| 8 |
+
return arr
|
patchhawk/data/benign/ds_stack.py
CHANGED
|
@@ -1,15 +1,16 @@
|
|
| 1 |
class Stack:
|
| 2 |
"""A simple stack implementation."""
|
|
|
|
| 3 |
def __init__(self):
|
| 4 |
self.items = []
|
| 5 |
-
|
| 6 |
def push(self, item):
|
| 7 |
self.items.append(item)
|
| 8 |
-
|
| 9 |
def pop(self):
|
| 10 |
if not self.is_empty():
|
| 11 |
return self.items.pop()
|
| 12 |
return None
|
| 13 |
-
|
| 14 |
def is_empty(self):
|
| 15 |
-
return len(self.items) == 0
|
|
|
|
| 1 |
class Stack:
|
| 2 |
"""A simple stack implementation."""
|
| 3 |
+
|
| 4 |
def __init__(self):
|
| 5 |
self.items = []
|
| 6 |
+
|
| 7 |
def push(self, item):
|
| 8 |
self.items.append(item)
|
| 9 |
+
|
| 10 |
def pop(self):
|
| 11 |
if not self.is_empty():
|
| 12 |
return self.items.pop()
|
| 13 |
return None
|
| 14 |
+
|
| 15 |
def is_empty(self):
|
| 16 |
+
return len(self.items) == 0
|
patchhawk/data/benign/io_config.py
CHANGED
|
@@ -2,14 +2,14 @@ def read_ini_config(content):
|
|
| 2 |
"""Read a simple INI configuration."""
|
| 3 |
config = {}
|
| 4 |
current_section = None
|
| 5 |
-
for line in content.split(
|
| 6 |
line = line.strip()
|
| 7 |
-
if not line or line.startswith(
|
| 8 |
continue
|
| 9 |
-
if line.startswith(
|
| 10 |
current_section = line[1:-1]
|
| 11 |
config[current_section] = {}
|
| 12 |
-
elif
|
| 13 |
-
key, val = line.split(
|
| 14 |
config[current_section][key.strip()] = val.strip()
|
| 15 |
-
return config
|
|
|
|
| 2 |
"""Read a simple INI configuration."""
|
| 3 |
config = {}
|
| 4 |
current_section = None
|
| 5 |
+
for line in content.split("\n"):
|
| 6 |
line = line.strip()
|
| 7 |
+
if not line or line.startswith("#"):
|
| 8 |
continue
|
| 9 |
+
if line.startswith("[") and line.endswith("]"):
|
| 10 |
current_section = line[1:-1]
|
| 11 |
config[current_section] = {}
|
| 12 |
+
elif "=" in line and current_section:
|
| 13 |
+
key, val = line.split("=", 1)
|
| 14 |
config[current_section][key.strip()] = val.strip()
|
| 15 |
+
return config
|
patchhawk/data/benign/io_csv.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
def parse_csv(csv_content):
|
| 2 |
"""Parse simple CSV content."""
|
| 3 |
-
lines = csv_content.strip().split(
|
| 4 |
if not lines:
|
| 5 |
return []
|
| 6 |
-
headers = lines[0].split(
|
| 7 |
result = []
|
| 8 |
for line in lines[1:]:
|
| 9 |
-
values = line.split(
|
| 10 |
result.append(dict(zip(headers, values)))
|
| 11 |
-
return result
|
|
|
|
| 1 |
def parse_csv(csv_content):
|
| 2 |
"""Parse simple CSV content."""
|
| 3 |
+
lines = csv_content.strip().split("\n")
|
| 4 |
if not lines:
|
| 5 |
return []
|
| 6 |
+
headers = lines[0].split(",")
|
| 7 |
result = []
|
| 8 |
for line in lines[1:]:
|
| 9 |
+
values = line.split(",")
|
| 10 |
result.append(dict(zip(headers, values)))
|
| 11 |
+
return result
|
patchhawk/data/benign/io_json.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import json
|
| 2 |
|
|
|
|
| 3 |
def format_json(obj):
|
| 4 |
"""Format dictionary as readable JSON string."""
|
| 5 |
-
return json.dumps(obj, indent=4, sort_keys=True)
|
|
|
|
| 1 |
import json
|
| 2 |
|
| 3 |
+
|
| 4 |
def format_json(obj):
|
| 5 |
"""Format dictionary as readable JSON string."""
|
| 6 |
+
return json.dumps(obj, indent=4, sort_keys=True)
|
patchhawk/data/benign/io_log.py
CHANGED
|
@@ -2,7 +2,7 @@ def parse_logs(log_lines):
|
|
| 2 |
"""Parse simple log lines into level and message."""
|
| 3 |
parsed = []
|
| 4 |
for line in log_lines:
|
| 5 |
-
parts = line.split(
|
| 6 |
if len(parts) == 2:
|
| 7 |
-
parsed.append({"level": parts[0].strip(
|
| 8 |
-
return parsed
|
|
|
|
| 2 |
"""Parse simple log lines into level and message."""
|
| 3 |
parsed = []
|
| 4 |
for line in log_lines:
|
| 5 |
+
parts = line.split(" - ", 1)
|
| 6 |
if len(parts) == 2:
|
| 7 |
+
parsed.append({"level": parts[0].strip("[]"), "message": parts[1]})
|
| 8 |
+
return parsed
|
patchhawk/data/benign/io_template.py
CHANGED
|
@@ -3,4 +3,4 @@ def render_template(template, context):
|
|
| 3 |
result = template
|
| 4 |
for key, value in context.items():
|
| 5 |
result = result.replace(f"{{{{{key}}}}}", str(value))
|
| 6 |
-
return result
|
|
|
|
| 3 |
result = template
|
| 4 |
for key, value in context.items():
|
| 5 |
result = result.replace(f"{{{{{key}}}}}", str(value))
|
| 6 |
+
return result
|
patchhawk/data/benign/math_factorial.py
CHANGED
|
@@ -2,4 +2,4 @@ def factorial(n):
|
|
| 2 |
"""Calculate the factorial of a number."""
|
| 3 |
if n == 0:
|
| 4 |
return 1
|
| 5 |
-
return n * factorial(n - 1)
|
|
|
|
| 2 |
"""Calculate the factorial of a number."""
|
| 3 |
if n == 0:
|
| 4 |
return 1
|
| 5 |
+
return n * factorial(n - 1)
|
patchhawk/data/benign/math_fibonacci.py
CHANGED
|
@@ -4,4 +4,4 @@ def fibonacci(n):
|
|
| 4 |
return 0
|
| 5 |
elif n == 1:
|
| 6 |
return 1
|
| 7 |
-
return fibonacci(n - 1) + fibonacci(n - 2)
|
|
|
|
| 4 |
return 0
|
| 5 |
elif n == 1:
|
| 6 |
return 1
|
| 7 |
+
return fibonacci(n - 1) + fibonacci(n - 2)
|
patchhawk/data/benign/math_gcd.py
CHANGED
|
@@ -2,4 +2,4 @@ def gcd(a, b):
|
|
| 2 |
"""Calculate the Greatest Common Divisor."""
|
| 3 |
while b:
|
| 4 |
a, b = b, a % b
|
| 5 |
-
return a
|
|
|
|
| 2 |
"""Calculate the Greatest Common Divisor."""
|
| 3 |
while b:
|
| 4 |
a, b = b, a % b
|
| 5 |
+
return a
|
patchhawk/data/benign/math_matrix.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
| 1 |
def matrix_addition(mat1, mat2):
|
| 2 |
"""Add two matrices."""
|
| 3 |
-
return [
|
|
|
|
|
|
|
|
|
| 1 |
def matrix_addition(mat1, mat2):
|
| 2 |
"""Add two matrices."""
|
| 3 |
+
return [
|
| 4 |
+
[mat1[i][j] + mat2[i][j] for j in range(len(mat1[0]))] for i in range(len(mat1))
|
| 5 |
+
]
|
patchhawk/data/benign/math_prime.py
CHANGED
|
@@ -2,7 +2,7 @@ def is_prime(n):
|
|
| 2 |
"""Check if a number is prime."""
|
| 3 |
if n <= 1:
|
| 4 |
return False
|
| 5 |
-
for i in range(2, int(n
|
| 6 |
if n % i == 0:
|
| 7 |
return False
|
| 8 |
-
return True
|
|
|
|
| 2 |
"""Check if a number is prime."""
|
| 3 |
if n <= 1:
|
| 4 |
return False
|
| 5 |
+
for i in range(2, int(n**0.5) + 1):
|
| 6 |
if n % i == 0:
|
| 7 |
return False
|
| 8 |
+
return True
|
patchhawk/data/benign/misc_calc.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
def basic_calculator(a, b, op):
|
| 2 |
"""Perform a basic math operation."""
|
| 3 |
-
if op ==
|
| 4 |
return a + b
|
| 5 |
-
elif op ==
|
| 6 |
return a - b
|
| 7 |
-
elif op ==
|
| 8 |
return a * b
|
| 9 |
-
elif op ==
|
| 10 |
if b == 0:
|
| 11 |
raise ValueError("Division by zero")
|
| 12 |
return a / b
|
| 13 |
-
return None
|
|
|
|
| 1 |
def basic_calculator(a, b, op):
|
| 2 |
"""Perform a basic math operation."""
|
| 3 |
+
if op == "+":
|
| 4 |
return a + b
|
| 5 |
+
elif op == "-":
|
| 6 |
return a - b
|
| 7 |
+
elif op == "*":
|
| 8 |
return a * b
|
| 9 |
+
elif op == "/":
|
| 10 |
if b == 0:
|
| 11 |
raise ValueError("Division by zero")
|
| 12 |
return a / b
|
| 13 |
+
return None
|
patchhawk/data/benign/misc_date.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
def format_iso_date(year, month, day):
|
| 2 |
"""Format date components into an ISO 8601 string."""
|
| 3 |
-
return f"{year:04d}-{month:02d}-{day:02d}"
|
|
|
|
| 1 |
def format_iso_date(year, month, day):
|
| 2 |
"""Format date components into an ISO 8601 string."""
|
| 3 |
+
return f"{year:04d}-{month:02d}-{day:02d}"
|
patchhawk/data/benign/misc_password.py
CHANGED
|
@@ -5,4 +5,4 @@ def is_strong_password(pwd):
|
|
| 5 |
has_upper = any(c.isupper() for c in pwd)
|
| 6 |
has_lower = any(c.islower() for c in pwd)
|
| 7 |
has_digit = any(c.isdigit() for c in pwd)
|
| 8 |
-
return has_upper and has_lower and has_digit
|
|
|
|
| 5 |
has_upper = any(c.isupper() for c in pwd)
|
| 6 |
has_lower = any(c.islower() for c in pwd)
|
| 7 |
has_digit = any(c.isdigit() for c in pwd)
|
| 8 |
+
return has_upper and has_lower and has_digit
|
patchhawk/data/benign/misc_temp.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
def celsius_to_fahrenheit(c):
|
| 2 |
"""Convert Celsius to Fahrenheit."""
|
| 3 |
-
return (c * 9/5) + 32
|
|
|
|
| 4 |
|
| 5 |
def fahrenheit_to_celsius(f):
|
| 6 |
"""Convert Fahrenheit to Celsius."""
|
| 7 |
-
return (f - 32) * 5/9
|
|
|
|
| 1 |
def celsius_to_fahrenheit(c):
|
| 2 |
"""Convert Celsius to Fahrenheit."""
|
| 3 |
+
return (c * 9 / 5) + 32
|
| 4 |
+
|
| 5 |
|
| 6 |
def fahrenheit_to_celsius(f):
|
| 7 |
"""Convert Fahrenheit to Celsius."""
|
| 8 |
+
return (f - 32) * 5 / 9
|
patchhawk/data/benign/misc_url.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
def parse_url_params(url):
|
| 2 |
"""Parse query parameters from a URL."""
|
| 3 |
-
if
|
| 4 |
return {}
|
| 5 |
-
query = url.split(
|
| 6 |
params = {}
|
| 7 |
-
for pair in query.split(
|
| 8 |
-
if
|
| 9 |
-
k, v = pair.split(
|
| 10 |
params[k] = v
|
| 11 |
-
return params
|
|
|
|
| 1 |
def parse_url_params(url):
|
| 2 |
"""Parse query parameters from a URL."""
|
| 3 |
+
if "?" not in url:
|
| 4 |
return {}
|
| 5 |
+
query = url.split("?", 1)[1]
|
| 6 |
params = {}
|
| 7 |
+
for pair in query.split("&"):
|
| 8 |
+
if "=" in pair:
|
| 9 |
+
k, v = pair.split("=", 1)
|
| 10 |
params[k] = v
|
| 11 |
+
return params
|
patchhawk/data/benign/str_anagram.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
def is_anagram(s1, s2):
|
| 2 |
"""Check if two strings are anagrams."""
|
| 3 |
-
return sorted(s1.replace(" ", "").lower()) == sorted(s2.replace(" ", "").lower())
|
|
|
|
| 1 |
def is_anagram(s1, s2):
|
| 2 |
"""Check if two strings are anagrams."""
|
| 3 |
+
return sorted(s1.replace(" ", "").lower()) == sorted(s2.replace(" ", "").lower())
|
patchhawk/data/benign/str_caesar.py
CHANGED
|
@@ -3,8 +3,8 @@ def caesar_cipher(text, shift):
|
|
| 3 |
result = ""
|
| 4 |
for char in text:
|
| 5 |
if char.isalpha():
|
| 6 |
-
start = ord(
|
| 7 |
result += chr((ord(char) - start + shift) % 26 + start)
|
| 8 |
else:
|
| 9 |
result += char
|
| 10 |
-
return result
|
|
|
|
| 3 |
result = ""
|
| 4 |
for char in text:
|
| 5 |
if char.isalpha():
|
| 6 |
+
start = ord("a") if char.islower() else ord("A")
|
| 7 |
result += chr((ord(char) - start + shift) % 26 + start)
|
| 8 |
else:
|
| 9 |
result += char
|
| 10 |
+
return result
|
patchhawk/data/benign/str_palindrome.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
def is_palindrome(s):
|
| 2 |
"""Check if a string is a palindrome."""
|
| 3 |
-
cleaned =
|
| 4 |
-
return cleaned == cleaned[::-1]
|
|
|
|
| 1 |
def is_palindrome(s):
|
| 2 |
"""Check if a string is a palindrome."""
|
| 3 |
+
cleaned = "".join(c.lower() for c in s if c.isalnum())
|
| 4 |
+
return cleaned == cleaned[::-1]
|
patchhawk/data/benign/str_slug.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
def generate_slug(title):
|
| 2 |
"""Generate a URL-friendly slug."""
|
| 3 |
import re
|
|
|
|
| 4 |
title = title.lower()
|
| 5 |
-
title = re.sub(r
|
| 6 |
-
return re.sub(r
|
|
|
|
| 1 |
def generate_slug(title):
|
| 2 |
"""Generate a URL-friendly slug."""
|
| 3 |
import re
|
| 4 |
+
|
| 5 |
title = title.lower()
|
| 6 |
+
title = re.sub(r"[^a-z0-9\s-]", "", title)
|
| 7 |
+
return re.sub(r"[\s-]+", "-", title).strip("-")
|
patchhawk/data/benign/str_wordcount.py
CHANGED
|
@@ -4,4 +4,4 @@ def word_count(text):
|
|
| 4 |
counts = {}
|
| 5 |
for word in words:
|
| 6 |
counts[word] = counts.get(word, 0) + 1
|
| 7 |
-
return counts
|
|
|
|
| 4 |
counts = {}
|
| 5 |
for word in words:
|
| 6 |
counts[word] = counts.get(word, 0) + 1
|
| 7 |
+
return counts
|
patchhawk/data/generate_scenarios.py
CHANGED
|
@@ -35,8 +35,7 @@ ATTACK_TEMPLATES = {
|
|
| 35 |
},
|
| 36 |
"env_hijack": {
|
| 37 |
"inject": (
|
| 38 |
-
"import os\n"
|
| 39 |
-
"os.environ['PATH'] = '/tmp:' + os.environ.get('PATH', '')\n\n"
|
| 40 |
),
|
| 41 |
"patch": "",
|
| 42 |
},
|
|
@@ -54,7 +53,7 @@ ATTACK_TEMPLATES = {
|
|
| 54 |
"inject": (
|
| 55 |
"def safe_eval(x):\n"
|
| 56 |
" return eval(x)\n"
|
| 57 |
-
|
| 58 |
),
|
| 59 |
"patch": "",
|
| 60 |
},
|
|
@@ -231,9 +230,7 @@ def generate_track_b_scenarios(benign_files: list) -> list:
|
|
| 231 |
# ============================================================
|
| 232 |
|
| 233 |
|
| 234 |
-
def generate_track_a_scenarios_with_sdk(
|
| 235 |
-
output_dir: str, num_samples: int = 10
|
| 236 |
-
) -> list:
|
| 237 |
"""
|
| 238 |
Track A: Use Meta's synthetic-data-kit to generate high-quality
|
| 239 |
code examples. Falls back gracefully if not installed.
|
|
@@ -336,13 +333,9 @@ def generate_track_a_scenarios_with_sdk(
|
|
| 336 |
"unit_test_code": item.get(
|
| 337 |
"unit_test_code", "import code\nassert True"
|
| 338 |
),
|
| 339 |
-
"label": "malicious"
|
| 340 |
-
if item.get("patch")
|
| 341 |
-
else "benign",
|
| 342 |
"source": "synthetic_data_kit",
|
| 343 |
-
"attack_type": item.get(
|
| 344 |
-
"attack_type", "llm_generated"
|
| 345 |
-
),
|
| 346 |
}
|
| 347 |
)
|
| 348 |
|
|
@@ -482,9 +475,7 @@ def generate_track_hf_scenarios(
|
|
| 482 |
def main():
|
| 483 |
import argparse
|
| 484 |
|
| 485 |
-
parser = argparse.ArgumentParser(
|
| 486 |
-
description="Generate scenarios for PatchHawk"
|
| 487 |
-
)
|
| 488 |
parser.add_argument(
|
| 489 |
"--benign-dir",
|
| 490 |
type=str,
|
|
|
|
| 35 |
},
|
| 36 |
"env_hijack": {
|
| 37 |
"inject": (
|
| 38 |
+
"import os\nos.environ['PATH'] = '/tmp:' + os.environ.get('PATH', '')\n\n"
|
|
|
|
| 39 |
),
|
| 40 |
"patch": "",
|
| 41 |
},
|
|
|
|
| 53 |
"inject": (
|
| 54 |
"def safe_eval(x):\n"
|
| 55 |
" return eval(x)\n"
|
| 56 |
+
'safe_eval(\'__import__("os").system("echo 1")\')\n\n'
|
| 57 |
),
|
| 58 |
"patch": "",
|
| 59 |
},
|
|
|
|
| 230 |
# ============================================================
|
| 231 |
|
| 232 |
|
| 233 |
+
def generate_track_a_scenarios_with_sdk(output_dir: str, num_samples: int = 10) -> list:
|
|
|
|
|
|
|
| 234 |
"""
|
| 235 |
Track A: Use Meta's synthetic-data-kit to generate high-quality
|
| 236 |
code examples. Falls back gracefully if not installed.
|
|
|
|
| 333 |
"unit_test_code": item.get(
|
| 334 |
"unit_test_code", "import code\nassert True"
|
| 335 |
),
|
| 336 |
+
"label": "malicious" if item.get("patch") else "benign",
|
|
|
|
|
|
|
| 337 |
"source": "synthetic_data_kit",
|
| 338 |
+
"attack_type": item.get("attack_type", "llm_generated"),
|
|
|
|
|
|
|
| 339 |
}
|
| 340 |
)
|
| 341 |
|
|
|
|
| 475 |
def main():
|
| 476 |
import argparse
|
| 477 |
|
| 478 |
+
parser = argparse.ArgumentParser(description="Generate scenarios for PatchHawk")
|
|
|
|
|
|
|
| 479 |
parser.add_argument(
|
| 480 |
"--benign-dir",
|
| 481 |
type=str,
|
patchhawk/data/scenarios.json
CHANGED
|
@@ -537,16 +537,29 @@
|
|
| 537 |
"unit_test_code": "import code\nassert code.is_prime(7) == True\n",
|
| 538 |
"label": "benign",
|
| 539 |
"source": "mutation_engine",
|
| 540 |
-
"attack_type": null
|
|
|
|
| 541 |
},
|
| 542 |
{
|
| 543 |
-
"id": "
|
| 544 |
"type": "true_positive",
|
| 545 |
-
"code_snippet": "import os\
|
| 546 |
-
"patch": "
|
| 547 |
-
"unit_test_code": "import code\nassert
|
| 548 |
"label": "malicious",
|
| 549 |
-
"source": "
|
| 550 |
-
"attack_type": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
}
|
| 552 |
]
|
|
|
|
| 537 |
"unit_test_code": "import code\nassert code.is_prime(7) == True\n",
|
| 538 |
"label": "benign",
|
| 539 |
"source": "mutation_engine",
|
| 540 |
+
"attack_type": null,
|
| 541 |
+
"task_id": null
|
| 542 |
},
|
| 543 |
{
|
| 544 |
+
"id": "tp_torch_poison",
|
| 545 |
"type": "true_positive",
|
| 546 |
+
"code_snippet": "import torch\nimport os\n\nclass PoisonedModel(torch.nn.Module):\n def __init__(self):\n super().__init__()\n self.linear = torch.nn.Linear(10, 10)\n \n def forward(self, x):\n os.system(\"echo 'Poisoning training loop!'\")\n return self.linear(x)\n\ndef load_model():\n return PoisonedModel()",
|
| 547 |
+
"patch": "import torch\n\nclass CleanModel(torch.nn.Module):\n def __init__(self):\n super().__init__()\n self.linear = torch.nn.Linear(10, 10)\n \n def forward(self, x):\n return self.linear(x)\n\ndef load_model():\n return CleanModel()",
|
| 548 |
+
"unit_test_code": "import code\nassert True",
|
| 549 |
"label": "malicious",
|
| 550 |
+
"source": "manual",
|
| 551 |
+
"attack_type": "pytorch_poisoning",
|
| 552 |
+
"task_id": "hard_patch"
|
| 553 |
+
},
|
| 554 |
+
{
|
| 555 |
+
"id": "fp_torch_clean",
|
| 556 |
+
"type": "false_positive",
|
| 557 |
+
"code_snippet": "import torch\n\ndef load_model():\n # Initialize clean model\n model = torch.nn.Sequential(\n torch.nn.Linear(10, 10),\n torch.nn.ReLU()\n )\n return model",
|
| 558 |
+
"patch": null,
|
| 559 |
+
"unit_test_code": "import code\nassert True",
|
| 560 |
+
"label": "benign",
|
| 561 |
+
"source": "manual",
|
| 562 |
+
"attack_type": null,
|
| 563 |
+
"task_id": null
|
| 564 |
}
|
| 565 |
]
|
patchhawk/env_models.py
CHANGED
|
@@ -5,7 +5,7 @@ Extends openenv.core base types (Action, Observation, State) so the
|
|
| 5 |
environment is fully compatible with the OpenEnv framework.
|
| 6 |
"""
|
| 7 |
|
| 8 |
-
from typing import Optional, List
|
| 9 |
|
| 10 |
from pydantic import BaseModel, ConfigDict, Field
|
| 11 |
|
|
@@ -14,12 +14,11 @@ from openenv.core import Action, Observation, State
|
|
| 14 |
|
| 15 |
# ── Observation ──────────────────────────────────────────────────────
|
| 16 |
|
|
|
|
| 17 |
class PatchHawkObservation(Observation):
|
| 18 |
"""Observation returned by PatchHawkEnv after reset() and step()."""
|
| 19 |
|
| 20 |
-
code_snippet: str = Field(
|
| 21 |
-
default="", description="Python source code to analyse"
|
| 22 |
-
)
|
| 23 |
static_flags: List[int] = Field(
|
| 24 |
default_factory=list,
|
| 25 |
description="Binary flags indicating static risk patterns",
|
|
@@ -34,6 +33,7 @@ class PatchHawkObservation(Observation):
|
|
| 34 |
|
| 35 |
# ── Action ───────────────────────────────────────────────────────────
|
| 36 |
|
|
|
|
| 37 |
class PatchHawkAction(Action):
|
| 38 |
"""Action submitted to PatchHawkEnv.step().
|
| 39 |
|
|
@@ -48,7 +48,7 @@ class PatchHawkAction(Action):
|
|
| 48 |
action_type: int = Field(
|
| 49 |
...,
|
| 50 |
description="0: ANALYZE, 1: EXECUTE_SANDBOX, 2: BLOCK_PR, "
|
| 51 |
-
|
| 52 |
)
|
| 53 |
patch_content: Optional[str] = Field(
|
| 54 |
None, description="The unified context patch if action is SUBMIT_PATCH"
|
|
@@ -57,16 +57,16 @@ class PatchHawkAction(Action):
|
|
| 57 |
|
| 58 |
# ── State ────────────────────────────────────────────────────────────
|
| 59 |
|
|
|
|
| 60 |
class PatchHawkState(State):
|
| 61 |
"""Internal state of a PatchHawkEnv episode."""
|
| 62 |
|
| 63 |
scenario_id: str = Field(default="", description="Current scenario ID")
|
| 64 |
current_task: Optional[str] = Field(
|
| 65 |
-
None,
|
| 66 |
-
|
| 67 |
-
last_action_type: Optional[int] = Field(
|
| 68 |
-
None, description="Last action type taken"
|
| 69 |
)
|
|
|
|
| 70 |
patch_validated: bool = Field(
|
| 71 |
default=False, description="Whether the last patch was validated"
|
| 72 |
)
|
|
@@ -77,6 +77,7 @@ class PatchHawkState(State):
|
|
| 77 |
|
| 78 |
# ── Reward (standalone — no OpenEnv base type for rewards) ───────────
|
| 79 |
|
|
|
|
| 80 |
class PatchHawkReward(BaseModel):
|
| 81 |
"""Pydantic reward model used by graders and inference logging."""
|
| 82 |
|
|
|
|
| 5 |
environment is fully compatible with the OpenEnv framework.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
from typing import Optional, List
|
| 9 |
|
| 10 |
from pydantic import BaseModel, ConfigDict, Field
|
| 11 |
|
|
|
|
| 14 |
|
| 15 |
# ── Observation ──────────────────────────────────────────────────────
|
| 16 |
|
| 17 |
+
|
| 18 |
class PatchHawkObservation(Observation):
|
| 19 |
"""Observation returned by PatchHawkEnv after reset() and step()."""
|
| 20 |
|
| 21 |
+
code_snippet: str = Field(default="", description="Python source code to analyse")
|
|
|
|
|
|
|
| 22 |
static_flags: List[int] = Field(
|
| 23 |
default_factory=list,
|
| 24 |
description="Binary flags indicating static risk patterns",
|
|
|
|
| 33 |
|
| 34 |
# ── Action ───────────────────────────────────────────────────────────
|
| 35 |
|
| 36 |
+
|
| 37 |
class PatchHawkAction(Action):
|
| 38 |
"""Action submitted to PatchHawkEnv.step().
|
| 39 |
|
|
|
|
| 48 |
action_type: int = Field(
|
| 49 |
...,
|
| 50 |
description="0: ANALYZE, 1: EXECUTE_SANDBOX, 2: BLOCK_PR, "
|
| 51 |
+
"3: SUBMIT_PATCH, 4: REQUEST_REVIEW",
|
| 52 |
)
|
| 53 |
patch_content: Optional[str] = Field(
|
| 54 |
None, description="The unified context patch if action is SUBMIT_PATCH"
|
|
|
|
| 57 |
|
| 58 |
# ── State ────────────────────────────────────────────────────────────
|
| 59 |
|
| 60 |
+
|
| 61 |
class PatchHawkState(State):
|
| 62 |
"""Internal state of a PatchHawkEnv episode."""
|
| 63 |
|
| 64 |
scenario_id: str = Field(default="", description="Current scenario ID")
|
| 65 |
current_task: Optional[str] = Field(
|
| 66 |
+
None,
|
| 67 |
+
description="Active task_id (easy_typosquat, medium_obfuscated, hard_patch)",
|
|
|
|
|
|
|
| 68 |
)
|
| 69 |
+
last_action_type: Optional[int] = Field(None, description="Last action type taken")
|
| 70 |
patch_validated: bool = Field(
|
| 71 |
default=False, description="Whether the last patch was validated"
|
| 72 |
)
|
|
|
|
| 77 |
|
| 78 |
# ── Reward (standalone — no OpenEnv base type for rewards) ───────────
|
| 79 |
|
| 80 |
+
|
| 81 |
class PatchHawkReward(BaseModel):
|
| 82 |
"""Pydantic reward model used by graders and inference logging."""
|
| 83 |
|
patchhawk/tasks.py
CHANGED
|
@@ -58,9 +58,7 @@ def grade_medium(
|
|
| 58 |
if not trajectory:
|
| 59 |
return 0.0
|
| 60 |
|
| 61 |
-
used_sandbox = any(
|
| 62 |
-
a.action_type == _EXECUTE_SANDBOX for a, _o in trajectory
|
| 63 |
-
)
|
| 64 |
last_action, _last_obs = trajectory[-1]
|
| 65 |
scenario = env.current_scenario or {}
|
| 66 |
|
|
@@ -88,9 +86,6 @@ def grade_hard(
|
|
| 88 |
|
| 89 |
last_action, _last_obs = trajectory[-1]
|
| 90 |
|
| 91 |
-
if
|
| 92 |
-
last_action.action_type == _SUBMIT_PATCH
|
| 93 |
-
and env.state.patch_validated
|
| 94 |
-
):
|
| 95 |
return 1.0
|
| 96 |
return 0.0
|
|
|
|
| 58 |
if not trajectory:
|
| 59 |
return 0.0
|
| 60 |
|
| 61 |
+
used_sandbox = any(a.action_type == _EXECUTE_SANDBOX for a, _o in trajectory)
|
|
|
|
|
|
|
| 62 |
last_action, _last_obs = trajectory[-1]
|
| 63 |
scenario = env.current_scenario or {}
|
| 64 |
|
|
|
|
| 86 |
|
| 87 |
last_action, _last_obs = trajectory[-1]
|
| 88 |
|
| 89 |
+
if last_action.action_type == _SUBMIT_PATCH and env.state.patch_validated:
|
|
|
|
|
|
|
|
|
|
| 90 |
return 1.0
|
| 91 |
return 0.0
|
patchhawk/training/train_grpo.py
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
|
|
| 1 |
"""
|
| 2 |
-
GRPO training pipeline for PatchHawk.
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
heavy ML dependencies.
|
| 12 |
"""
|
| 13 |
|
| 14 |
import argparse
|
| 15 |
import os
|
| 16 |
import random
|
| 17 |
-
import
|
| 18 |
from pathlib import Path
|
| 19 |
|
| 20 |
import numpy as np
|
|
@@ -24,161 +24,277 @@ try:
|
|
| 24 |
except ImportError:
|
| 25 |
wandb = None
|
| 26 |
|
| 27 |
-
# Resolve project root for relative paths
|
| 28 |
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 29 |
|
| 30 |
|
| 31 |
def _build_prompt(scenario: dict) -> str:
|
| 32 |
-
"""Convert a scenario into a training prompt."""
|
| 33 |
return (
|
| 34 |
"Analyze this Python code for supply-chain vulnerabilities.\n"
|
| 35 |
f"<code_snippet>\n{scenario['code_snippet']}\n</code_snippet>\n"
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
-
"0
|
| 39 |
-
"
|
| 40 |
)
|
| 41 |
|
| 42 |
|
| 43 |
def train_agent(args):
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
# ──
|
| 47 |
if not args.dry_run and wandb is not None:
|
|
|
|
|
|
|
| 48 |
wandb.init(
|
| 49 |
project="patchhawk",
|
| 50 |
-
name="grpo-run
|
| 51 |
config=vars(args),
|
| 52 |
)
|
| 53 |
else:
|
| 54 |
-
print("[
|
| 55 |
|
| 56 |
-
# ──
|
| 57 |
from patchhawk.agent.environment import PatchHawkEnv
|
| 58 |
|
| 59 |
env = PatchHawkEnv(
|
| 60 |
scenarios_path=str(_PROJECT_ROOT / "patchhawk" / "data" / "scenarios.json"),
|
| 61 |
use_docker=args.use_docker,
|
| 62 |
)
|
| 63 |
-
print(f"Loaded
|
| 64 |
|
| 65 |
-
# ── 3. Model ──────────────────────────────────────────────────
|
| 66 |
if args.dry_run:
|
| 67 |
_dry_run_training(env, args)
|
| 68 |
return
|
| 69 |
|
| 70 |
-
#
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
-
model, tokenizer = FastLanguageModel.from_pretrained(
|
| 81 |
-
model_name="unsloth/Qwen2.5-Coder-7B-Instruct",
|
| 82 |
-
max_seq_length=max_seq_length,
|
| 83 |
-
load_in_4bit=True,
|
| 84 |
-
)
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
],
|
| 94 |
-
lora_alpha=16,
|
| 95 |
-
lora_dropout=0,
|
| 96 |
-
bias="none",
|
| 97 |
-
use_gradient_checkpointing="unsloth",
|
| 98 |
-
random_state=3407,
|
| 99 |
-
)
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
rewards.append(0.0)
|
| 118 |
-
continue
|
| 119 |
-
|
| 120 |
-
obs = env.reset(scenario=target)
|
| 121 |
-
obs_after_step = env.step(PatchHawkAction(action_type=action_type))
|
| 122 |
-
rewards.append(obs_after_step.reward or 0.0)
|
| 123 |
-
return rewards
|
| 124 |
-
|
| 125 |
-
# ── Dataset ──────────────────────────────────────────────
|
| 126 |
-
from patchhawk.env_models import PatchHawkAction
|
| 127 |
-
prompt_data = [{"prompt": _build_prompt(s)} for s in env.scenarios]
|
| 128 |
-
dataset = Dataset.from_list(prompt_data)
|
| 129 |
-
|
| 130 |
-
training_args = GRPOConfig(
|
| 131 |
-
output_dir=args.output_dir,
|
| 132 |
-
learning_rate=args.learning_rate,
|
| 133 |
-
per_device_train_batch_size=1,
|
| 134 |
-
gradient_accumulation_steps=args.gradient_accumulation_steps,
|
| 135 |
-
max_prompt_length=max_seq_length // 2,
|
| 136 |
-
max_completion_length=max_seq_length // 2,
|
| 137 |
-
num_generations=args.group_size,
|
| 138 |
-
max_steps=args.max_steps,
|
| 139 |
-
save_steps=50,
|
| 140 |
-
logging_steps=10,
|
| 141 |
-
report_to="wandb",
|
| 142 |
-
)
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
train_dataset=dataset,
|
| 149 |
-
)
|
| 150 |
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
try:
|
| 160 |
-
model.push_to_hub(hf_repo
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
except ImportError as e:
|
| 166 |
-
print(f"⚠️ Missing dependency for full training: {e}")
|
| 167 |
-
print("Falling back to dry-run mode…")
|
| 168 |
-
_dry_run_training(env, args)
|
| 169 |
|
| 170 |
|
|
|
|
|
|
|
|
|
|
| 171 |
def _dry_run_training(env, args):
|
| 172 |
-
""
|
| 173 |
-
Simulate GRPO training without GPU / heavy deps.
|
| 174 |
-
Uses a deterministic heuristic policy.
|
| 175 |
-
"""
|
| 176 |
-
print("[DRY RUN] Simulating GRPO training with heuristic policy…")
|
| 177 |
-
|
| 178 |
from patchhawk.env_models import PatchHawkAction
|
| 179 |
|
| 180 |
def heuristic_policy(obs):
|
| 181 |
-
# We know obs is PatchHawkObservation, so risk_score is a float
|
| 182 |
risk = obs.risk_score
|
| 183 |
if risk > 0.5:
|
| 184 |
return PatchHawkAction(action_type=env.ACTION_BLOCK_PR)
|
|
@@ -186,36 +302,26 @@ def _dry_run_training(env, args):
|
|
| 186 |
return PatchHawkAction(action_type=env.ACTION_EXECUTE_SANDBOX)
|
| 187 |
return PatchHawkAction(action_type=env.ACTION_REQUEST_REVIEW)
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
for epoch in range(epochs):
|
| 193 |
-
print(f"\n── Epoch {epoch + 1}/{epochs} ──")
|
| 194 |
epoch_rewards = []
|
| 195 |
-
attack_success
|
| 196 |
|
| 197 |
-
for
|
| 198 |
group_rewards = []
|
| 199 |
-
for
|
| 200 |
obs = env.reset()
|
| 201 |
-
done = False
|
| 202 |
ep_reward = 0.0
|
| 203 |
steps = 0
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
obs = env.step(action)
|
| 208 |
-
ep_reward += (obs.reward or 0.0)
|
| 209 |
steps += 1
|
| 210 |
-
done = obs.done
|
| 211 |
-
|
| 212 |
group_rewards.append(ep_reward)
|
| 213 |
|
| 214 |
-
# Track per-attack-type success
|
| 215 |
label = env.current_scenario.get("label", "benign")
|
| 216 |
atype = env.current_scenario.get("attack_type", "none") or "none"
|
| 217 |
-
|
| 218 |
-
attack_success[atype] = {"correct": 0, "total": 0}
|
| 219 |
attack_success[atype]["total"] += 1
|
| 220 |
if (label == "malicious" and ep_reward > 0) or (label == "benign" and ep_reward >= 0):
|
| 221 |
attack_success[atype]["correct"] += 1
|
|
@@ -224,23 +330,20 @@ def _dry_run_training(env, args):
|
|
| 224 |
std_r = float(np.std(group_rewards)) + 1e-8
|
| 225 |
advantages = [(r - mean_r) / std_r for r in group_rewards]
|
| 226 |
epoch_rewards.append(mean_r)
|
| 227 |
-
|
| 228 |
-
print(f" Batch: mean_reward={mean_r:+.2f} advantages={[f'{a:+.2f}' for a in advantages]}")
|
| 229 |
|
| 230 |
epoch_mean = float(np.mean(epoch_rewards)) if epoch_rewards else 0.0
|
| 231 |
print(f" Epoch {epoch + 1} mean_reward: {epoch_mean:+.2f}")
|
| 232 |
-
|
| 233 |
-
# Per-attack-type success rates
|
| 234 |
for atype, counts in attack_success.items():
|
| 235 |
rate = counts["correct"] / max(counts["total"], 1)
|
| 236 |
print(f" {atype}: {rate:.0%} ({counts['correct']}/{counts['total']})")
|
| 237 |
|
| 238 |
-
if wandb is not None
|
| 239 |
try:
|
| 240 |
log_data = {
|
| 241 |
"epoch": epoch + 1,
|
| 242 |
"mean_reward": epoch_mean,
|
| 243 |
-
"loss": max(0, 1.0 - epoch_mean / 3.0),
|
| 244 |
}
|
| 245 |
for atype, counts in attack_success.items():
|
| 246 |
log_data[f"success_rate/{atype}"] = counts["correct"] / max(counts["total"], 1)
|
|
@@ -248,26 +351,28 @@ def _dry_run_training(env, args):
|
|
| 248 |
except Exception:
|
| 249 |
pass
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
(
|
| 255 |
-
(
|
| 256 |
-
print(f"\n[DRY RUN] Dummy adapter saved to {args.output_dir}/")
|
| 257 |
-
|
| 258 |
|
| 259 |
-
# ── CLI ───────────────────────────────────────────────────────────
|
| 260 |
|
|
|
|
|
|
|
|
|
|
| 261 |
if __name__ == "__main__":
|
| 262 |
-
parser = argparse.ArgumentParser(description="PatchHawk GRPO Training")
|
| 263 |
-
parser.add_argument("--dry-run", action="store_true", help="
|
| 264 |
-
parser.add_argument("--use-docker", action="store_true", help="Use Docker sandbox
|
| 265 |
-
parser.add_argument("--
|
| 266 |
-
parser.add_argument("--
|
| 267 |
-
parser.add_argument("--
|
| 268 |
-
parser.add_argument("--
|
| 269 |
-
parser.add_argument("--
|
|
|
|
|
|
|
|
|
|
| 270 |
parser.add_argument("--output-dir", type=str, default="grpo_lora")
|
| 271 |
-
|
| 272 |
args = parser.parse_args()
|
| 273 |
-
train_agent(args)
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
+
GRPO training pipeline for PatchHawk (trl 1.0.0, RTX 3060 12GB).
|
| 4 |
+
|
| 5 |
+
Fixed for trl 1.0.0:
|
| 6 |
+
- Removed max_prompt_length / max_completion_length.
|
| 7 |
+
- Disabled fp16 to avoid BFloat16 AMP error.
|
| 8 |
+
- Set tokenizer.model_max_length for sequence length control.
|
| 9 |
+
- Forced WandB logging every step via custom callback (no step argument to avoid warnings).
|
| 10 |
+
- Loss displayed in tqdm progress bar.
|
| 11 |
+
- WandB online mode forced before init.
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import argparse
|
| 15 |
import os
|
| 16 |
import random
|
| 17 |
+
import re
|
| 18 |
from pathlib import Path
|
| 19 |
|
| 20 |
import numpy as np
|
|
|
|
| 24 |
except ImportError:
|
| 25 |
wandb = None
|
| 26 |
|
|
|
|
| 27 |
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 28 |
|
| 29 |
|
| 30 |
def _build_prompt(scenario: dict) -> str:
|
|
|
|
| 31 |
return (
|
| 32 |
"Analyze this Python code for supply-chain vulnerabilities.\n"
|
| 33 |
f"<code_snippet>\n{scenario['code_snippet']}\n</code_snippet>\n"
|
| 34 |
+
"Respond in STRICT XML:\n"
|
| 35 |
+
"<thought>...</thought>\n"
|
| 36 |
+
"<action>0-4</action>\n"
|
| 37 |
+
"<patch>...</patch> (ONLY if action=3)\n"
|
| 38 |
)
|
| 39 |
|
| 40 |
|
| 41 |
def train_agent(args):
|
| 42 |
+
# Check trl availability
|
| 43 |
+
if not args.dry_run:
|
| 44 |
+
try:
|
| 45 |
+
from trl import GRPOTrainer, GRPOConfig
|
| 46 |
+
except Exception as exc:
|
| 47 |
+
raise RuntimeError(
|
| 48 |
+
"trl not found.\nInstall: pip install trl==1.0.0 peft bitsandbytes accelerate transformers"
|
| 49 |
+
) from exc
|
| 50 |
|
| 51 |
+
# ── WandB initialisation (force online mode before init) ──
|
| 52 |
if not args.dry_run and wandb is not None:
|
| 53 |
+
os.environ["WANDB_MODE"] = "online"
|
| 54 |
+
os.environ["WANDB_SILENT"] = "false"
|
| 55 |
wandb.init(
|
| 56 |
project="patchhawk",
|
| 57 |
+
name="grpo-run",
|
| 58 |
config=vars(args),
|
| 59 |
)
|
| 60 |
else:
|
| 61 |
+
print("[INFO] WandB skipped.")
|
| 62 |
|
| 63 |
+
# ── Environment ──────────────────────────────────────────
|
| 64 |
from patchhawk.agent.environment import PatchHawkEnv
|
| 65 |
|
| 66 |
env = PatchHawkEnv(
|
| 67 |
scenarios_path=str(_PROJECT_ROOT / "patchhawk" / "data" / "scenarios.json"),
|
| 68 |
use_docker=args.use_docker,
|
| 69 |
)
|
| 70 |
+
print(f"Loaded {len(env.scenarios)} scenarios.")
|
| 71 |
|
|
|
|
| 72 |
if args.dry_run:
|
| 73 |
_dry_run_training(env, args)
|
| 74 |
return
|
| 75 |
|
| 76 |
+
# ── GPU training imports ─────────────────────────────────
|
| 77 |
+
import torch
|
| 78 |
+
from transformers import (
|
| 79 |
+
AutoModelForCausalLM,
|
| 80 |
+
AutoTokenizer,
|
| 81 |
+
BitsAndBytesConfig,
|
| 82 |
+
TrainerCallback,
|
| 83 |
+
)
|
| 84 |
+
from peft import LoraConfig, TaskType, get_peft_model, prepare_model_for_kbit_training
|
| 85 |
+
from datasets import Dataset
|
| 86 |
+
from trl import GRPOConfig, GRPOTrainer
|
| 87 |
|
| 88 |
+
if torch.cuda.is_available():
|
| 89 |
+
print(f"GPU: {torch.cuda.get_device_name(0)}")
|
| 90 |
+
else:
|
| 91 |
+
print("No GPU found — training will be slow.")
|
| 92 |
|
| 93 |
+
MODEL_NAME = "Qwen/Qwen2.5-Coder-3B-Instruct"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
+
# 4‑bit quantisation config
|
| 96 |
+
bnb_config = BitsAndBytesConfig(
|
| 97 |
+
load_in_4bit=True,
|
| 98 |
+
bnb_4bit_quant_type="nf4",
|
| 99 |
+
bnb_4bit_compute_dtype=torch.float16,
|
| 100 |
+
bnb_4bit_use_double_quant=True,
|
| 101 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
print(f"Loading {MODEL_NAME} in 4-bit ...")
|
| 104 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
|
| 105 |
+
if tokenizer.pad_token is None:
|
| 106 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 107 |
+
tokenizer.padding_side = "left"
|
| 108 |
+
|
| 109 |
+
# Critical: set total sequence length (prompt + generation)
|
| 110 |
+
tokenizer.model_max_length = args.max_seq_len
|
| 111 |
+
|
| 112 |
+
base_model = AutoModelForCausalLM.from_pretrained(
|
| 113 |
+
MODEL_NAME,
|
| 114 |
+
quantization_config=bnb_config,
|
| 115 |
+
device_map="auto",
|
| 116 |
+
trust_remote_code=True,
|
| 117 |
+
torch_dtype=torch.float16,
|
| 118 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
+
base_model = prepare_model_for_kbit_training(
|
| 121 |
+
base_model,
|
| 122 |
+
use_gradient_checkpointing=True,
|
| 123 |
+
)
|
|
|
|
|
|
|
| 124 |
|
| 125 |
+
# LoRA configuration
|
| 126 |
+
lora_config = LoraConfig(
|
| 127 |
+
task_type=TaskType.CAUSAL_LM,
|
| 128 |
+
r=16,
|
| 129 |
+
lora_alpha=16,
|
| 130 |
+
lora_dropout=0.05,
|
| 131 |
+
bias="none",
|
| 132 |
+
target_modules=[
|
| 133 |
+
"q_proj", "k_proj", "v_proj", "o_proj",
|
| 134 |
+
"gate_proj", "up_proj", "down_proj",
|
| 135 |
+
],
|
| 136 |
+
)
|
| 137 |
+
model = get_peft_model(base_model, lora_config)
|
| 138 |
+
model.print_trainable_parameters()
|
| 139 |
+
|
| 140 |
+
# ── Reward 1: XML format ─────────────────────────────────
|
| 141 |
+
def format_reward(completions, **kwargs):
|
| 142 |
+
rewards = []
|
| 143 |
+
for c in completions:
|
| 144 |
+
text = c if isinstance(c, str) else str(c)
|
| 145 |
+
score = 0.0
|
| 146 |
+
if re.search(r"<thought>.*?</thought>", text, re.DOTALL):
|
| 147 |
+
score += 0.5
|
| 148 |
+
else:
|
| 149 |
+
score -= 1.0
|
| 150 |
+
if re.search(r"<action>[0-4]</action>", text):
|
| 151 |
+
score += 0.5
|
| 152 |
+
else:
|
| 153 |
+
score -= 1.5
|
| 154 |
+
if "<action>3</action>" in text:
|
| 155 |
+
if re.search(r"<patch>.*?</patch>", text, re.DOTALL):
|
| 156 |
+
score += 0.5
|
| 157 |
+
else:
|
| 158 |
+
score -= 2.0
|
| 159 |
+
rewards.append(score)
|
| 160 |
+
return rewards
|
| 161 |
+
|
| 162 |
+
# ── Reward 2: environment feedback ───────────────────────
|
| 163 |
+
from patchhawk.env_models import PatchHawkAction
|
| 164 |
|
| 165 |
+
def env_reward(completions, prompts, **kwargs):
|
| 166 |
+
rewards = []
|
| 167 |
+
for prompt, c in zip(prompts, completions):
|
| 168 |
+
text = c if isinstance(c, str) else str(c)
|
| 169 |
+
|
| 170 |
+
# Extract code snippet from prompt to identify scenario
|
| 171 |
+
code_match = re.search(r"<code_snippet>(.*?)</code_snippet>", prompt, re.DOTALL)
|
| 172 |
+
if not code_match:
|
| 173 |
+
rewards.append(-2.0)
|
| 174 |
+
continue
|
| 175 |
+
snippet = code_match.group(1).strip()
|
| 176 |
+
scenario = None
|
| 177 |
+
for s in env.scenarios:
|
| 178 |
+
if s.get("code_snippet", "").strip() == snippet:
|
| 179 |
+
scenario = s
|
| 180 |
+
break
|
| 181 |
+
if scenario is None:
|
| 182 |
+
rewards.append(-2.0)
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
# Parse action
|
| 186 |
+
action_match = re.search(r"<action>(\d+)</action>", text)
|
| 187 |
+
if not action_match:
|
| 188 |
+
rewards.append(-2.0)
|
| 189 |
+
continue
|
| 190 |
+
action_type = int(action_match.group(1))
|
| 191 |
+
|
| 192 |
+
# Parse patch (if any)
|
| 193 |
+
patch = None
|
| 194 |
+
patch_match = re.search(r"<patch>(.*?)</patch>", text, re.DOTALL)
|
| 195 |
+
if patch_match:
|
| 196 |
+
patch = patch_match.group(1).strip()
|
| 197 |
|
| 198 |
+
try:
|
| 199 |
+
# Reset environment to the exact scenario
|
| 200 |
+
env.reset(scenario_idx=env.scenarios.index(scenario))
|
| 201 |
+
obs = env.step(PatchHawkAction(action_type=action_type, patch_content=patch))
|
| 202 |
+
rewards.append(float(obs.reward or 0.0))
|
| 203 |
+
except Exception as exc:
|
| 204 |
+
print(f"env_reward crash: {exc}")
|
| 205 |
+
rewards.append(-3.0)
|
| 206 |
+
return rewards
|
| 207 |
+
|
| 208 |
+
# ── Dataset preparation ──────────────────────────────────
|
| 209 |
+
valid = [s for s in env.scenarios if s.get("label") in ("malicious", "benign")]
|
| 210 |
+
random.seed(42)
|
| 211 |
+
random.shuffle(valid)
|
| 212 |
+
|
| 213 |
+
split = int(0.8 * len(valid))
|
| 214 |
+
train_ds = Dataset.from_list([{"prompt": _build_prompt(s)} for s in valid[:split]])
|
| 215 |
+
eval_ds = Dataset.from_list([{"prompt": _build_prompt(s)} for s in valid[split:]])
|
| 216 |
+
print(f"Dataset — train: {len(train_ds)}, eval: {len(eval_ds)}")
|
| 217 |
+
|
| 218 |
+
# ── GRPO Config (trl 1.0.0 compatible) ───────────────────
|
| 219 |
+
grpo_config = GRPOConfig(
|
| 220 |
+
output_dir=args.output_dir,
|
| 221 |
+
learning_rate=args.learning_rate,
|
| 222 |
+
per_device_train_batch_size=args.batch_size,
|
| 223 |
+
gradient_accumulation_steps=args.grad_accum,
|
| 224 |
+
fp16=False, # avoids BFloat16 AMP error
|
| 225 |
+
gradient_checkpointing=True,
|
| 226 |
+
num_generations=args.group_size,
|
| 227 |
+
beta=args.kl_coef,
|
| 228 |
+
num_train_epochs=args.epochs,
|
| 229 |
+
warmup_steps=10,
|
| 230 |
+
max_grad_norm=1.0,
|
| 231 |
+
logging_steps=1, # log every step
|
| 232 |
+
logging_first_step=True, # log step 0 immediately
|
| 233 |
+
save_steps=50,
|
| 234 |
+
report_to="wandb" if (wandb is not None and not args.dry_run) else "none",
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
# ── Custom callback: force WandB logging + progress bar (no step warnings) ──
|
| 238 |
+
class ForceWandbCallback(TrainerCallback):
|
| 239 |
+
def on_log(self, args, state, control, logs=None, **kwargs):
|
| 240 |
+
if not logs:
|
| 241 |
+
return
|
| 242 |
+
# Log everything to wandb WITHOUT step argument (avoids step warnings)
|
| 243 |
+
if wandb is not None and wandb.run is not None:
|
| 244 |
+
wandb.log(logs)
|
| 245 |
+
# Update progress bar with loss
|
| 246 |
+
loss_key = None
|
| 247 |
+
for key in ["loss", "grpo_loss", "train_loss"]:
|
| 248 |
+
if key in logs:
|
| 249 |
+
loss_key = key
|
| 250 |
+
break
|
| 251 |
+
if loss_key is not None:
|
| 252 |
+
loss_val = logs[loss_key]
|
| 253 |
+
if hasattr(state, "progress_bar") and state.progress_bar is not None:
|
| 254 |
+
state.progress_bar.set_postfix({loss_key: f"{loss_val:.4f}"})
|
| 255 |
+
|
| 256 |
+
trainer = GRPOTrainer(
|
| 257 |
+
model=model,
|
| 258 |
+
reward_funcs=[format_reward, env_reward],
|
| 259 |
+
args=grpo_config,
|
| 260 |
+
train_dataset=train_ds,
|
| 261 |
+
eval_dataset=eval_ds,
|
| 262 |
+
)
|
| 263 |
+
trainer.add_callback(ForceWandbCallback())
|
| 264 |
+
|
| 265 |
+
print("Starting GRPO training ...")
|
| 266 |
+
trainer.train()
|
| 267 |
+
|
| 268 |
+
# Ensure all pending logs are sent to wandb
|
| 269 |
+
if wandb is not None and wandb.run is not None:
|
| 270 |
+
wandb.finish()
|
| 271 |
+
|
| 272 |
+
# ── Save LoRA adapter ────────────────────────────────────
|
| 273 |
+
out = Path(args.output_dir)
|
| 274 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 275 |
+
model.save_pretrained(str(out))
|
| 276 |
+
tokenizer.save_pretrained(str(out))
|
| 277 |
+
print(f"LoRA adapter saved to {out}")
|
| 278 |
+
|
| 279 |
+
# ── Optional HF Hub upload ────────────────────────���──────
|
| 280 |
+
hf_repo = os.getenv("HF_REPO", "")
|
| 281 |
+
if hf_repo:
|
| 282 |
try:
|
| 283 |
+
model.push_to_hub(hf_repo)
|
| 284 |
+
tokenizer.push_to_hub(hf_repo)
|
| 285 |
+
print(f"Uploaded to https://huggingface.co/{hf_repo}")
|
| 286 |
+
except Exception as exc:
|
| 287 |
+
print(f"HF upload failed: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
|
| 290 |
+
# ─────────────────────────────────────────────────────────────
|
| 291 |
+
# Dry-run (CPU simulation, no model)
|
| 292 |
+
# ─────────────────────────────────────────────────────────────
|
| 293 |
def _dry_run_training(env, args):
|
| 294 |
+
print("[DRY RUN] CPU simulation only — no model loaded.\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
from patchhawk.env_models import PatchHawkAction
|
| 296 |
|
| 297 |
def heuristic_policy(obs):
|
|
|
|
| 298 |
risk = obs.risk_score
|
| 299 |
if risk > 0.5:
|
| 300 |
return PatchHawkAction(action_type=env.ACTION_BLOCK_PR)
|
|
|
|
| 302 |
return PatchHawkAction(action_type=env.ACTION_EXECUTE_SANDBOX)
|
| 303 |
return PatchHawkAction(action_type=env.ACTION_REQUEST_REVIEW)
|
| 304 |
|
| 305 |
+
for epoch in range(args.epochs):
|
| 306 |
+
print(f"── Epoch {epoch + 1}/{args.epochs} ──")
|
|
|
|
|
|
|
|
|
|
| 307 |
epoch_rewards = []
|
| 308 |
+
attack_success = {}
|
| 309 |
|
| 310 |
+
for _ in range(0, min(len(env.scenarios), args.max_steps), args.group_size):
|
| 311 |
group_rewards = []
|
| 312 |
+
for _ in range(args.group_size):
|
| 313 |
obs = env.reset()
|
|
|
|
| 314 |
ep_reward = 0.0
|
| 315 |
steps = 0
|
| 316 |
+
while not obs.done and steps < env.max_steps:
|
| 317 |
+
obs = env.step(heuristic_policy(obs))
|
| 318 |
+
ep_reward += float(obs.reward or 0.0)
|
|
|
|
|
|
|
| 319 |
steps += 1
|
|
|
|
|
|
|
| 320 |
group_rewards.append(ep_reward)
|
| 321 |
|
|
|
|
| 322 |
label = env.current_scenario.get("label", "benign")
|
| 323 |
atype = env.current_scenario.get("attack_type", "none") or "none"
|
| 324 |
+
attack_success.setdefault(atype, {"correct": 0, "total": 0})
|
|
|
|
| 325 |
attack_success[atype]["total"] += 1
|
| 326 |
if (label == "malicious" and ep_reward > 0) or (label == "benign" and ep_reward >= 0):
|
| 327 |
attack_success[atype]["correct"] += 1
|
|
|
|
| 330 |
std_r = float(np.std(group_rewards)) + 1e-8
|
| 331 |
advantages = [(r - mean_r) / std_r for r in group_rewards]
|
| 332 |
epoch_rewards.append(mean_r)
|
| 333 |
+
print(f" Batch mean_reward={mean_r:+.2f} advantages={[f'{a:+.2f}' for a in advantages]}")
|
|
|
|
| 334 |
|
| 335 |
epoch_mean = float(np.mean(epoch_rewards)) if epoch_rewards else 0.0
|
| 336 |
print(f" Epoch {epoch + 1} mean_reward: {epoch_mean:+.2f}")
|
|
|
|
|
|
|
| 337 |
for atype, counts in attack_success.items():
|
| 338 |
rate = counts["correct"] / max(counts["total"], 1)
|
| 339 |
print(f" {atype}: {rate:.0%} ({counts['correct']}/{counts['total']})")
|
| 340 |
|
| 341 |
+
if wandb is not None:
|
| 342 |
try:
|
| 343 |
log_data = {
|
| 344 |
"epoch": epoch + 1,
|
| 345 |
"mean_reward": epoch_mean,
|
| 346 |
+
"loss": max(0.0, 1.0 - epoch_mean / 3.0),
|
| 347 |
}
|
| 348 |
for atype, counts in attack_success.items():
|
| 349 |
log_data[f"success_rate/{atype}"] = counts["correct"] / max(counts["total"], 1)
|
|
|
|
| 351 |
except Exception:
|
| 352 |
pass
|
| 353 |
|
| 354 |
+
out = Path(args.output_dir)
|
| 355 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 356 |
+
(out / "adapter_config.json").write_text('{"model_type":"patchhawk-grpo-dry-run"}')
|
| 357 |
+
(out / "adapter_model.bin").write_bytes(b"\x00" * 64)
|
| 358 |
+
print(f"\n[DRY RUN] Dummy adapter written to {args.output_dir}/")
|
|
|
|
|
|
|
| 359 |
|
|
|
|
| 360 |
|
| 361 |
+
# ─────────────────────────────────────────────────────────────
|
| 362 |
+
# CLI entry point
|
| 363 |
+
# ─────────────────────────────────────────────────────────────
|
| 364 |
if __name__ == "__main__":
|
| 365 |
+
parser = argparse.ArgumentParser(description="PatchHawk GRPO Training (trl 1.0.0)")
|
| 366 |
+
parser.add_argument("--dry-run", action="store_true", help="CPU simulation, no model")
|
| 367 |
+
parser.add_argument("--use-docker", action="store_true", help="Use Docker sandbox")
|
| 368 |
+
parser.add_argument("--max-seq-len", type=int, default=1024, help="Total sequence length (prompt+completion)")
|
| 369 |
+
parser.add_argument("--learning-rate", type=float, default=5e-6)
|
| 370 |
+
parser.add_argument("--kl-coef", type=float, default=0.01)
|
| 371 |
+
parser.add_argument("--batch-size", type=int, default=1)
|
| 372 |
+
parser.add_argument("--grad-accum", type=int, default=8)
|
| 373 |
+
parser.add_argument("--group-size", type=int, default=4)
|
| 374 |
+
parser.add_argument("--epochs", type=int, default=3)
|
| 375 |
+
parser.add_argument("--max-steps", type=int, default=200)
|
| 376 |
parser.add_argument("--output-dir", type=str, default="grpo_lora")
|
|
|
|
| 377 |
args = parser.parse_args()
|
| 378 |
+
train_agent(args)
|
server/app.py
CHANGED
|
@@ -32,10 +32,16 @@ from patchhawk.agent.environment import PatchHawkEnv
|
|
| 32 |
from patchhawk.env_models import PatchHawkAction, PatchHawkObservation
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def create_openenv_app():
|
| 36 |
"""Create the OpenEnv FastAPI application."""
|
| 37 |
return create_app(
|
| 38 |
-
|
| 39 |
PatchHawkAction,
|
| 40 |
PatchHawkObservation,
|
| 41 |
env_name="PatchHawk",
|
|
|
|
| 32 |
from patchhawk.env_models import PatchHawkAction, PatchHawkObservation
|
| 33 |
|
| 34 |
|
| 35 |
+
def _env_factory() -> PatchHawkEnv:
|
| 36 |
+
"""Factory callable for create_app — returns a fresh PatchHawkEnv instance."""
|
| 37 |
+
scenarios_path = os.getenv("PATCHHAWK_SCENARIOS", "patchhawk/data/scenarios.json")
|
| 38 |
+
return PatchHawkEnv(scenarios_path=scenarios_path, use_docker=False)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
def create_openenv_app():
|
| 42 |
"""Create the OpenEnv FastAPI application."""
|
| 43 |
return create_app(
|
| 44 |
+
_env_factory,
|
| 45 |
PatchHawkAction,
|
| 46 |
PatchHawkObservation,
|
| 47 |
env_name="PatchHawk",
|
setup.py
CHANGED
|
@@ -10,7 +10,7 @@ setup(
|
|
| 10 |
description="RL-powered supply-chain vulnerability detector & auto-patcher",
|
| 11 |
long_description=long_description,
|
| 12 |
long_description_content_type="text/markdown",
|
| 13 |
-
url="https://github.com/
|
| 14 |
packages=find_packages(),
|
| 15 |
python_requires=">=3.10",
|
| 16 |
install_requires=[
|
|
|
|
| 10 |
description="RL-powered supply-chain vulnerability detector & auto-patcher",
|
| 11 |
long_description=long_description,
|
| 12 |
long_description_content_type="text/markdown",
|
| 13 |
+
url="https://github.com/ramprasathk07/patchhawk",
|
| 14 |
packages=find_packages(),
|
| 15 |
python_requires=">=3.10",
|
| 16 |
install_requires=[
|
tests/test_env.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""Tests for PatchHawkEnv (OpenEnv compliance + reward logic)."""
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
-
import numpy as np
|
| 5 |
from patchhawk.agent.environment import PatchHawkEnv
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
@pytest.fixture
|
|
@@ -10,98 +10,131 @@ def env():
|
|
| 10 |
"""Create a PatchHawkEnv with the default scenarios file."""
|
| 11 |
e = PatchHawkEnv(use_docker=False)
|
| 12 |
yield e
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
# ── Basic API ─────────────────────────────────────────────────────
|
| 16 |
|
| 17 |
-
def test_reset_returns_obs_and_info(env):
|
| 18 |
-
obs, info = env.reset()
|
| 19 |
-
assert isinstance(obs, dict)
|
| 20 |
-
assert "code_snippet" in obs
|
| 21 |
-
assert "static_flags" in obs
|
| 22 |
-
assert "risk_score" in obs
|
| 23 |
-
assert isinstance(info, dict)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
def test_observation_shapes(env):
|
| 27 |
-
obs, _ = env.reset()
|
| 28 |
-
assert isinstance(obs["code_snippet"], str)
|
| 29 |
-
assert obs["static_flags"].shape == (5,)
|
| 30 |
-
assert obs["risk_score"].shape == (1,)
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
| 34 |
env.reset()
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
obs,
|
| 38 |
-
assert isinstance(reward, float)
|
| 39 |
-
assert isinstance(
|
| 40 |
-
assert isinstance(
|
| 41 |
-
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
# ── Reward logic ──────────────────────────────────────────────────
|
| 51 |
|
|
|
|
| 52 |
def test_block_malicious_positive_reward(env):
|
| 53 |
malicious = [s for s in env.scenarios if s.get("label") == "malicious"]
|
| 54 |
if not malicious:
|
| 55 |
pytest.skip("No malicious scenarios available")
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
assert
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def test_block_benign_negative_reward(env):
|
| 63 |
benign = [s for s in env.scenarios if s.get("label") == "benign"]
|
| 64 |
if not benign:
|
| 65 |
pytest.skip("No benign scenarios available")
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
assert
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
def test_execute_sandbox_reward(env):
|
| 73 |
env.reset()
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
-
def
|
| 80 |
malicious = [s for s in env.scenarios if s.get("label") == "malicious"]
|
| 81 |
if not malicious:
|
| 82 |
pytest.skip("No malicious scenarios available")
|
| 83 |
-
|
| 84 |
-
|
|
|
|
| 85 |
for _ in range(env.max_steps):
|
| 86 |
-
obs
|
| 87 |
-
|
| 88 |
-
if terminated or truncated:
|
| 89 |
break
|
| 90 |
# Last step on malicious without block/patch → -5.0
|
| 91 |
-
assert reward == -5.0
|
| 92 |
-
assert
|
| 93 |
-
|
| 94 |
|
| 95 |
-
def test_render_does_not_crash(env, capsys):
|
| 96 |
-
env.reset()
|
| 97 |
-
env.step(env.ACTION_ANALYZE)
|
| 98 |
-
env.render()
|
| 99 |
-
captured = capsys.readouterr()
|
| 100 |
-
assert "Step" in captured.out
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
"""Verify that passing a scenario via options works."""
|
| 105 |
scenario = {
|
| 106 |
"id": "test_custom",
|
| 107 |
"type": "functional",
|
|
@@ -111,6 +144,22 @@ def test_episode_with_scenario_option(env):
|
|
| 111 |
"unit_test_code": None,
|
| 112 |
"attack_type": None,
|
| 113 |
}
|
| 114 |
-
obs
|
| 115 |
-
assert obs
|
| 116 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Tests for PatchHawkEnv (OpenEnv compliance + reward logic)."""
|
| 2 |
|
| 3 |
import pytest
|
|
|
|
| 4 |
from patchhawk.agent.environment import PatchHawkEnv
|
| 5 |
+
from patchhawk.env_models import PatchHawkAction, PatchHawkObservation, PatchHawkState
|
| 6 |
|
| 7 |
|
| 8 |
@pytest.fixture
|
|
|
|
| 10 |
"""Create a PatchHawkEnv with the default scenarios file."""
|
| 11 |
e = PatchHawkEnv(use_docker=False)
|
| 12 |
yield e
|
| 13 |
+
e.close()
|
| 14 |
|
| 15 |
|
| 16 |
# ── Basic API ─────────────────────────────────────────────────────
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
def test_reset_returns_observation(env):
|
| 20 |
+
"""reset() returns a PatchHawkObservation (OpenEnv API)."""
|
| 21 |
+
obs = env.reset()
|
| 22 |
+
assert isinstance(obs, PatchHawkObservation)
|
| 23 |
+
assert hasattr(obs, "code_snippet")
|
| 24 |
+
assert hasattr(obs, "static_flags")
|
| 25 |
+
assert hasattr(obs, "risk_score")
|
| 26 |
+
assert hasattr(obs, "done")
|
| 27 |
+
assert hasattr(obs, "reward")
|
| 28 |
+
assert hasattr(obs, "metadata")
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
def test_observation_fields(env):
|
| 32 |
+
"""Verify observation field types."""
|
| 33 |
+
obs = env.reset()
|
| 34 |
+
assert isinstance(obs.code_snippet, str)
|
| 35 |
+
assert isinstance(obs.static_flags, list)
|
| 36 |
+
assert isinstance(obs.risk_score, float)
|
| 37 |
+
assert isinstance(obs.done, bool)
|
| 38 |
+
assert isinstance(obs.metadata, dict)
|
| 39 |
|
| 40 |
+
|
| 41 |
+
def test_step_returns_observation(env):
|
| 42 |
+
"""step() returns a PatchHawkObservation (OpenEnv API)."""
|
| 43 |
env.reset()
|
| 44 |
+
action = PatchHawkAction(action_type=env.ACTION_ANALYZE)
|
| 45 |
+
obs = env.step(action)
|
| 46 |
+
assert isinstance(obs, PatchHawkObservation)
|
| 47 |
+
assert isinstance(obs.reward, (int, float))
|
| 48 |
+
assert isinstance(obs.done, bool)
|
| 49 |
+
assert isinstance(obs.metadata, dict)
|
| 50 |
+
|
| 51 |
|
| 52 |
+
def test_state_property(env):
|
| 53 |
+
"""state property returns a PatchHawkState."""
|
| 54 |
+
env.reset()
|
| 55 |
+
state = env.state
|
| 56 |
+
assert isinstance(state, PatchHawkState)
|
| 57 |
+
assert hasattr(state, "episode_id")
|
| 58 |
+
assert hasattr(state, "step_count")
|
| 59 |
+
assert hasattr(state, "scenario_id")
|
| 60 |
|
| 61 |
+
|
| 62 |
+
def test_all_action_types_accepted(env):
|
| 63 |
+
"""All five action types (0-4) are accepted."""
|
| 64 |
+
for action_type in range(5):
|
| 65 |
+
obs = env.reset()
|
| 66 |
+
action = PatchHawkAction(action_type=action_type)
|
| 67 |
+
result = env.step(action)
|
| 68 |
+
assert isinstance(result, PatchHawkObservation)
|
| 69 |
|
| 70 |
|
| 71 |
# ── Reward logic ──────────────────────────────────────────────────
|
| 72 |
|
| 73 |
+
|
| 74 |
def test_block_malicious_positive_reward(env):
|
| 75 |
malicious = [s for s in env.scenarios if s.get("label") == "malicious"]
|
| 76 |
if not malicious:
|
| 77 |
pytest.skip("No malicious scenarios available")
|
| 78 |
+
env.reset(scenario=malicious[0])
|
| 79 |
+
action = PatchHawkAction(action_type=env.ACTION_BLOCK_PR)
|
| 80 |
+
obs = env.step(action)
|
| 81 |
+
assert obs.reward == 2.0
|
| 82 |
+
assert obs.done is True
|
| 83 |
|
| 84 |
|
| 85 |
def test_block_benign_negative_reward(env):
|
| 86 |
benign = [s for s in env.scenarios if s.get("label") == "benign"]
|
| 87 |
if not benign:
|
| 88 |
pytest.skip("No benign scenarios available")
|
| 89 |
+
env.reset(scenario=benign[0])
|
| 90 |
+
action = PatchHawkAction(action_type=env.ACTION_BLOCK_PR)
|
| 91 |
+
obs = env.step(action)
|
| 92 |
+
assert obs.reward == -1.0
|
| 93 |
+
assert obs.done is True
|
| 94 |
|
| 95 |
|
| 96 |
def test_execute_sandbox_reward(env):
|
| 97 |
env.reset()
|
| 98 |
+
action = PatchHawkAction(action_type=env.ACTION_EXECUTE_SANDBOX)
|
| 99 |
+
obs = env.step(action)
|
| 100 |
+
assert obs.reward == 0.1
|
| 101 |
+
assert obs.done is False
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_analyze_no_reward(env):
|
| 105 |
+
env.reset()
|
| 106 |
+
action = PatchHawkAction(action_type=env.ACTION_ANALYZE)
|
| 107 |
+
obs = env.step(action)
|
| 108 |
+
assert obs.reward == 0.0
|
| 109 |
+
assert obs.done is False
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_request_review_terminates(env):
|
| 113 |
+
env.reset()
|
| 114 |
+
action = PatchHawkAction(action_type=env.ACTION_REQUEST_REVIEW)
|
| 115 |
+
obs = env.step(action)
|
| 116 |
+
assert obs.reward == 0.0
|
| 117 |
+
assert obs.done is True
|
| 118 |
|
| 119 |
|
| 120 |
+
def test_max_steps_penalty(env):
|
| 121 |
malicious = [s for s in env.scenarios if s.get("label") == "malicious"]
|
| 122 |
if not malicious:
|
| 123 |
pytest.skip("No malicious scenarios available")
|
| 124 |
+
env.reset(scenario=malicious[0])
|
| 125 |
+
action = PatchHawkAction(action_type=env.ACTION_ANALYZE)
|
| 126 |
+
obs = None
|
| 127 |
for _ in range(env.max_steps):
|
| 128 |
+
obs = env.step(action)
|
| 129 |
+
if obs.done:
|
|
|
|
| 130 |
break
|
| 131 |
# Last step on malicious without block/patch → -5.0
|
| 132 |
+
assert obs.reward == -5.0
|
| 133 |
+
assert obs.done is True
|
|
|
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
+
def test_episode_with_scenario_kwarg(env):
|
| 137 |
+
"""Verify that passing a scenario via kwargs works."""
|
|
|
|
| 138 |
scenario = {
|
| 139 |
"id": "test_custom",
|
| 140 |
"type": "functional",
|
|
|
|
| 144 |
"unit_test_code": None,
|
| 145 |
"attack_type": None,
|
| 146 |
}
|
| 147 |
+
obs = env.reset(scenario=scenario)
|
| 148 |
+
assert obs.code_snippet == "x = 42"
|
| 149 |
+
assert obs.metadata["scenario_id"] == "test_custom"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_step_counter_increments(env):
|
| 153 |
+
"""Verify step counter tracks correctly."""
|
| 154 |
+
env.reset()
|
| 155 |
+
for i in range(3):
|
| 156 |
+
action = PatchHawkAction(action_type=env.ACTION_ANALYZE)
|
| 157 |
+
env.step(action)
|
| 158 |
+
assert env.state.step_count == 3
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def test_close_resets_scenario(env):
|
| 162 |
+
"""close() clears episode state."""
|
| 163 |
+
env.reset()
|
| 164 |
+
env.close()
|
| 165 |
+
assert env.current_scenario is None
|
tests/test_sandbox.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
"""Tests for the 3-stage patch validation pipeline."""
|
| 2 |
|
| 3 |
-
import pytest
|
| 4 |
from patchhawk.agent.sandbox import validate_patch
|
| 5 |
|
| 6 |
|
|
|
|
| 1 |
"""Tests for the 3-stage patch validation pipeline."""
|
| 2 |
|
|
|
|
| 3 |
from patchhawk.agent.sandbox import validate_patch
|
| 4 |
|
| 5 |
|
validate-submission.sh
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# validate-submission.sh — OpenEnv Submission Validator
|
| 4 |
+
|
| 5 |
+
set -uo pipefail
|
| 6 |
+
|
| 7 |
+
DOCKER_BUILD_TIMEOUT=600
|
| 8 |
+
if [ -t 1 ]; then
|
| 9 |
+
RED='\033[0;31m'
|
| 10 |
+
GREEN='\033[0;32m'
|
| 11 |
+
YELLOW='\033[1;33m'
|
| 12 |
+
BOLD='\033[1m'
|
| 13 |
+
NC='\033[0m'
|
| 14 |
+
else
|
| 15 |
+
RED='' GREEN='' YELLOW='' BOLD='' NC=''
|
| 16 |
+
fi
|
| 17 |
+
|
| 18 |
+
run_with_timeout() {
|
| 19 |
+
local secs="$1"; shift
|
| 20 |
+
if command -v timeout &>/dev/null; then
|
| 21 |
+
timeout "$secs" "$@"
|
| 22 |
+
elif command -v gtimeout &>/dev/null; then
|
| 23 |
+
gtimeout "$secs" "$@"
|
| 24 |
+
else
|
| 25 |
+
"$@" &
|
| 26 |
+
local pid=$!
|
| 27 |
+
( sleep "$secs" && kill "$pid" 2>/dev/null ) &
|
| 28 |
+
local watcher=$!
|
| 29 |
+
wait "$pid" 2>/dev/null
|
| 30 |
+
local rc=$?
|
| 31 |
+
kill "$watcher" 2>/dev/null
|
| 32 |
+
wait "$watcher" 2>/dev/null
|
| 33 |
+
return $rc
|
| 34 |
+
fi
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
portable_mktemp() {
|
| 38 |
+
local prefix="${1:-validate}"
|
| 39 |
+
mktemp "${TMPDIR:-/tmp}/${prefix}-XXXXXX" 2>/dev/null || mktemp
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
CLEANUP_FILES=()
|
| 43 |
+
cleanup() { rm -f "${CLEANUP_FILES[@]+"${CLEANUP_FILES[@]}"}"; }
|
| 44 |
+
trap cleanup EXIT
|
| 45 |
+
|
| 46 |
+
PING_URL="${1:-}"
|
| 47 |
+
REPO_DIR="${2:-.}"
|
| 48 |
+
|
| 49 |
+
if [ -z "$PING_URL" ]; then
|
| 50 |
+
printf "Usage: %s <ping_url> [repo_dir]\n" "$0"
|
| 51 |
+
printf "\n"
|
| 52 |
+
printf " ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)\n"
|
| 53 |
+
printf " repo_dir Path to your repo (default: current directory)\n"
|
| 54 |
+
exit 1
|
| 55 |
+
fi
|
| 56 |
+
|
| 57 |
+
if ! REPO_DIR="$(cd "$REPO_DIR" 2>/dev/null && pwd)"; then
|
| 58 |
+
printf "Error: directory '%s' not found\n" "${2:-.}"
|
| 59 |
+
exit 1
|
| 60 |
+
fi
|
| 61 |
+
PING_URL="${PING_URL%/}"
|
| 62 |
+
export PING_URL
|
| 63 |
+
PASS=0
|
| 64 |
+
|
| 65 |
+
log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
|
| 66 |
+
pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
|
| 67 |
+
fail() { log "${RED}FAILED${NC} -- $1"; }
|
| 68 |
+
hint() { printf " ${YELLOW}Hint:${NC} %b\n" "$1"; }
|
| 69 |
+
stop_at() {
|
| 70 |
+
printf "\n"
|
| 71 |
+
printf "${RED}${BOLD}Validation stopped at %s.${NC} Fix the above before continuing.\n" "$1"
|
| 72 |
+
exit 1
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
printf "\n"
|
| 76 |
+
printf "${BOLD}========================================${NC}\n"
|
| 77 |
+
printf "${BOLD} OpenEnv Submission Validator${NC}\n"
|
| 78 |
+
printf "${BOLD}========================================${NC}\n"
|
| 79 |
+
log "Repo: $REPO_DIR"
|
| 80 |
+
log "Ping URL: $PING_URL"
|
| 81 |
+
printf "\n"
|
| 82 |
+
|
| 83 |
+
log "${BOLD}Step 1/3: Pinging HF Space${NC} ($PING_URL/reset) ..."
|
| 84 |
+
|
| 85 |
+
CURL_OUTPUT=$(portable_mktemp "validate-curl")
|
| 86 |
+
CLEANUP_FILES+=("$CURL_OUTPUT")
|
| 87 |
+
HTTP_CODE=$(curl -s -o "$CURL_OUTPUT" -w "%{http_code}" -X POST \
|
| 88 |
+
-H "Content-Type: application/json" -d '{}' \
|
| 89 |
+
"$PING_URL/reset" --max-time 30 2>"$CURL_OUTPUT" || printf "000")
|
| 90 |
+
|
| 91 |
+
if [ "$HTTP_CODE" = "200" ]; then
|
| 92 |
+
pass "HF Space is live and responds to /reset"
|
| 93 |
+
elif [ "$HTTP_CODE" = "000" ]; then
|
| 94 |
+
fail "HF Space not reachable (connection failed or timed out)"
|
| 95 |
+
hint "Check your network connection and that the Space is running."
|
| 96 |
+
hint "Try: curl -s -o /dev/null -w '%{http_code}' -X POST $PING_URL/reset"
|
| 97 |
+
stop_at "Step 1"
|
| 98 |
+
else
|
| 99 |
+
fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
|
| 100 |
+
hint "Make sure your Space is running and the URL is correct."
|
| 101 |
+
hint "Try opening $PING_URL in your browser first."
|
| 102 |
+
stop_at "Step 1"
|
| 103 |
+
fi
|
| 104 |
+
|
| 105 |
+
log "${BOLD}Step 2/3: Running docker build${NC} ..."
|
| 106 |
+
|
| 107 |
+
if ! command -v docker &>/dev/null; then
|
| 108 |
+
fail "docker command not found"
|
| 109 |
+
hint "Install Docker: https://docs.docker.com/get-docker/"
|
| 110 |
+
stop_at "Step 2"
|
| 111 |
+
fi
|
| 112 |
+
|
| 113 |
+
if [ -f "$REPO_DIR/Dockerfile" ]; then
|
| 114 |
+
DOCKER_CONTEXT="$REPO_DIR"
|
| 115 |
+
elif [ -f "$REPO_DIR/server/Dockerfile" ]; then
|
| 116 |
+
DOCKER_CONTEXT="$REPO_DIR/server"
|
| 117 |
+
else
|
| 118 |
+
fail "No Dockerfile found in repo root or server/ directory"
|
| 119 |
+
stop_at "Step 2"
|
| 120 |
+
fi
|
| 121 |
+
|
| 122 |
+
log " Found Dockerfile in $DOCKER_CONTEXT"
|
| 123 |
+
|
| 124 |
+
BUILD_OK=false
|
| 125 |
+
BUILD_OUTPUT=$(run_with_timeout "$DOCKER_BUILD_TIMEOUT" docker build "$DOCKER_CONTEXT" 2>&1) && BUILD_OK=true
|
| 126 |
+
|
| 127 |
+
if [ "$BUILD_OK" = true ]; then
|
| 128 |
+
pass "Docker build succeeded"
|
| 129 |
+
else
|
| 130 |
+
fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
|
| 131 |
+
printf "%s\n" "$BUILD_OUTPUT" | tail -20
|
| 132 |
+
stop_at "Step 2"
|
| 133 |
+
fi
|
| 134 |
+
|
| 135 |
+
log "${BOLD}Step 3/3: Running openenv validate${NC} ..."
|
| 136 |
+
|
| 137 |
+
if ! command -v openenv &>/dev/null; then
|
| 138 |
+
fail "openenv command not found"
|
| 139 |
+
hint "Install it: pip install openenv-core"
|
| 140 |
+
stop_at "Step 3"
|
| 141 |
+
fi
|
| 142 |
+
|
| 143 |
+
VALIDATE_OK=false
|
| 144 |
+
VALIDATE_OUTPUT=$(cd "$REPO_DIR" && openenv validate 2>&1) && VALIDATE_OK=true
|
| 145 |
+
|
| 146 |
+
if [ "$VALIDATE_OK" = true ]; then
|
| 147 |
+
pass "openenv validate passed"
|
| 148 |
+
[ -n "$VALIDATE_OUTPUT" ] && log " $VALIDATE_OUTPUT"
|
| 149 |
+
else
|
| 150 |
+
fail "openenv validate failed"
|
| 151 |
+
printf "%s\n" "$VALIDATE_OUTPUT"
|
| 152 |
+
stop_at "Step 3"
|
| 153 |
+
fi
|
| 154 |
+
|
| 155 |
+
printf "\n"
|
| 156 |
+
printf "${BOLD}========================================${NC}\n"
|
| 157 |
+
printf "${GREEN}${BOLD} All 3/3 checks passed!${NC}\n"
|
| 158 |
+
printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
|
| 159 |
+
printf "${BOLD}========================================${NC}\n"
|
| 160 |
+
printf "\n"
|
| 161 |
+
|
| 162 |
+
exit 0
|