Clean cruft files, fix README links after docs/ reorg, integrate Cursor's inference enhancements
Browse files- Delete notebook_builder.py (empty placeholder) and generate_submission_plots.py (superseded by generate_plots.py)
- Update README links pointing to moved blog files in docs/blog/
- Cursor edits to inference.py / models.py: added Pydantic-validated action parsing, cumulative reward + grader-breakdown logging, optional INFERENCE_SUMMARY_FILE, interactive mode flag — core env contract preserved
- Add conftest.py for pytest discovery
- .env.example +22 -4
- README.md +2 -2
- __init__.py +27 -2
- client.py +4 -1
- conftest.py +10 -0
- generate_submission_plots.py +0 -160
- inference.py +283 -71
- models.py +77 -2
- notebook_builder.py +0 -1
- pyproject.toml +6 -0
.env.example
CHANGED
|
@@ -1,7 +1,25 @@
|
|
| 1 |
# Required for inference.py (baseline agent)
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
HF_TOKEN=your-huggingface-token-here
|
| 5 |
|
| 6 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
ENV_URL=http://localhost:8000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Required for inference.py (baseline agent)
|
| 2 |
+
# HF_TOKEN is passed as api_key to the OpenAI client — use the token for
|
| 3 |
+
# whatever provider matches API_BASE_URL (Hugging Face, OpenRouter, etc.).
|
|
|
|
| 4 |
|
| 5 |
+
# Option A — Hugging Face router (defaults in code if env unset)
|
| 6 |
+
# API_BASE_URL=https://router.huggingface.co/v1
|
| 7 |
+
# MODEL_NAME=meta-llama/Llama-3.3-70B-Instruct
|
| 8 |
+
# HF_TOKEN=your-huggingface-token-here
|
| 9 |
+
|
| 10 |
+
# Option B — OpenRouter (e.g. Meta Llama 3.2 3B Instruct free)
|
| 11 |
+
# API_BASE_URL=https://openrouter.ai/api/v1
|
| 12 |
+
# MODEL_NAME=meta-llama/llama-3.2-3b-instruct:free
|
| 13 |
+
# HF_TOKEN=your-openrouter-key-here
|
| 14 |
+
|
| 15 |
+
# Optional: point inference at the local env
|
| 16 |
ENV_URL=http://localhost:8000
|
| 17 |
+
|
| 18 |
+
# Optional: append every raw LLM response to a file (debug / audit)
|
| 19 |
+
# INFERENCE_LOG_LLM=outputs/llm_raw.log
|
| 20 |
+
# Optional: append API errors (e.g. 429) when the client falls back to list_tools
|
| 21 |
+
# INFERENCE_LOG_API=outputs/api_errors.log
|
| 22 |
+
# Optional: wait for Enter between steps to avoid rate limits (or use: python inference.py -i)
|
| 23 |
+
# INFERENCE_INTERACTIVE=1
|
| 24 |
+
# INFERENCE_PAUSE=step # step = after each env step; scenario = only between easy/medium/hard
|
| 25 |
+
# INFERENCE_SUMMARY_FILE=outputs/reward_grader_summary.txt
|
README.md
CHANGED
|
@@ -422,9 +422,9 @@ Key research validating our design:
|
|
| 422 |
## Links & Resources
|
| 423 |
|
| 424 |
- **Live Environment**: https://huggingface.co/spaces/Sayuj63/Vapt-env
|
| 425 |
-
- **Blog Post**: [VAPT env: Teaching AI to Reason About Security](./VAPT_env_BLOG_POST_FINAL.md) — Read the full story
|
| 426 |
- **Training Notebook**: [VAPT_env_RL_Training_Colab.ipynb](./AISHA_RL_Training_Colab.ipynb)
|
| 427 |
- **Agent Comparison Script**: [generate_plots.py](./generate_plots.py)
|
| 428 |
-
- **Publication Guide**: [BLOG_PUBLICATION_GUIDE.md](./BLOG_PUBLICATION_GUIDE.md) — How to publish the blog post
|
| 429 |
- **Team**: Your Team Name — [Your Team Members]
|
| 430 |
- **Hackathon**: Meta PyTorch OpenEnv Hackathon India 2026
|
|
|
|
| 422 |
## Links & Resources
|
| 423 |
|
| 424 |
- **Live Environment**: https://huggingface.co/spaces/Sayuj63/Vapt-env
|
| 425 |
+
- **Blog Post**: [VAPT env: Teaching AI to Reason About Security](./docs/blog/VAPT_env_BLOG_POST_FINAL.md) — Read the full story
|
| 426 |
- **Training Notebook**: [VAPT_env_RL_Training_Colab.ipynb](./AISHA_RL_Training_Colab.ipynb)
|
| 427 |
- **Agent Comparison Script**: [generate_plots.py](./generate_plots.py)
|
| 428 |
+
- **Publication Guide**: [BLOG_PUBLICATION_GUIDE.md](./docs/blog/BLOG_PUBLICATION_GUIDE.md) — How to publish the blog post
|
| 429 |
- **Team**: Your Team Name — [Your Team Members]
|
| 430 |
- **Hackathon**: Meta PyTorch OpenEnv Hackathon India 2026
|
__init__.py
CHANGED
|
@@ -5,12 +5,37 @@
|
|
| 5 |
|
| 6 |
"""Security Audit Environment — AI-powered VAPT training."""
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
__all__ = [
|
|
|
|
| 12 |
"SecurityAuditAction",
|
| 13 |
"SecurityAuditObservation",
|
| 14 |
"SecurityAuditState",
|
| 15 |
"SecurityAuditEnv",
|
|
|
|
|
|
|
| 16 |
]
|
|
|
|
| 5 |
|
| 6 |
"""Security Audit Environment — AI-powered VAPT training."""
|
| 7 |
|
| 8 |
+
# Pytest (and other tools) may import this file as a top-level module when the repo
|
| 9 |
+
# root is treated as a "package" path. Relative imports need a parent package; fall
|
| 10 |
+
# back to same-directory imports so `pytest` still works. Normal `import security_audit_env`
|
| 11 |
+
# from an install uses the relative branch.
|
| 12 |
+
try:
|
| 13 |
+
from .client import SecurityAuditEnv
|
| 14 |
+
from .models import (
|
| 15 |
+
LLMJsonAction,
|
| 16 |
+
SecurityAuditAction,
|
| 17 |
+
SecurityAuditObservation,
|
| 18 |
+
SecurityAuditState,
|
| 19 |
+
extract_json_object_from_text,
|
| 20 |
+
parse_llm_action_text,
|
| 21 |
+
)
|
| 22 |
+
except ImportError: # pragma: no cover
|
| 23 |
+
from client import SecurityAuditEnv
|
| 24 |
+
from models import (
|
| 25 |
+
LLMJsonAction,
|
| 26 |
+
SecurityAuditAction,
|
| 27 |
+
SecurityAuditObservation,
|
| 28 |
+
SecurityAuditState,
|
| 29 |
+
extract_json_object_from_text,
|
| 30 |
+
parse_llm_action_text,
|
| 31 |
+
)
|
| 32 |
|
| 33 |
__all__ = [
|
| 34 |
+
"LLMJsonAction",
|
| 35 |
"SecurityAuditAction",
|
| 36 |
"SecurityAuditObservation",
|
| 37 |
"SecurityAuditState",
|
| 38 |
"SecurityAuditEnv",
|
| 39 |
+
"extract_json_object_from_text",
|
| 40 |
+
"parse_llm_action_text",
|
| 41 |
]
|
client.py
CHANGED
|
@@ -10,7 +10,10 @@ from typing import Any, Dict
|
|
| 10 |
from openenv.core import EnvClient
|
| 11 |
from openenv.core.client_types import StepResult
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
class SecurityAuditEnv(
|
|
|
|
| 10 |
from openenv.core import EnvClient
|
| 11 |
from openenv.core.client_types import StepResult
|
| 12 |
|
| 13 |
+
try:
|
| 14 |
+
from .models import SecurityAuditAction, SecurityAuditObservation, SecurityAuditState
|
| 15 |
+
except ImportError: # pragma: no cover
|
| 16 |
+
from models import SecurityAuditAction, SecurityAuditObservation, SecurityAuditState
|
| 17 |
|
| 18 |
|
| 19 |
class SecurityAuditEnv(
|
conftest.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# Pytest: repo root is also the `security_audit_env` package. Without this,
|
| 5 |
+
# some pytest versions try to import `./__init__.py` as a test module, which
|
| 6 |
+
# fails (relative imports need a parent package). Keep this file free of
|
| 7 |
+
# `tests/` imports to avoid import cycles.
|
| 8 |
+
|
| 9 |
+
# Ignore the package entrypoint at repo root; actual tests live in tests/ only.
|
| 10 |
+
collect_ignore = ["__init__.py"]
|
generate_submission_plots.py
DELETED
|
@@ -1,160 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Generate training plots for AISHA submission.
|
| 4 |
-
Simulates training results with realistic data.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
import os
|
| 8 |
-
import numpy as np
|
| 9 |
-
import matplotlib.pyplot as plt
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
|
| 12 |
-
# Create plots directory
|
| 13 |
-
Path("./plots").mkdir(exist_ok=True)
|
| 14 |
-
|
| 15 |
-
# Set random seed for reproducibility
|
| 16 |
-
np.random.seed(42)
|
| 17 |
-
|
| 18 |
-
# Simulate realistic training data
|
| 19 |
-
episodes = np.arange(1, 11)
|
| 20 |
-
|
| 21 |
-
# Baseline: random agent (low, noisy scores)
|
| 22 |
-
baseline_scores = np.array([0.18, 0.22, 0.19, 0.25, 0.21, 0.23, 0.20, 0.24, 0.22, 0.26])
|
| 23 |
-
|
| 24 |
-
# Pre-training: LLM agent without fine-tuning (moderate scores)
|
| 25 |
-
pretrain_scores = np.array([0.32, 0.35, 0.38, 0.36, 0.40, 0.39, 0.41, 0.38, 0.42, 0.40])
|
| 26 |
-
|
| 27 |
-
# Post-training: LLM agent after GRPO training (higher scores, less variance)
|
| 28 |
-
posttrain_scores = np.array([0.52, 0.55, 0.58, 0.56, 0.60, 0.59, 0.61, 0.58, 0.62, 0.60])
|
| 29 |
-
|
| 30 |
-
# Simulate training loss curve (decreasing over steps)
|
| 31 |
-
num_steps = 150
|
| 32 |
-
training_steps = np.arange(1, num_steps + 1)
|
| 33 |
-
# Loss starts high and decreases with some noise
|
| 34 |
-
base_loss = 2.0 * np.exp(-training_steps / 50)
|
| 35 |
-
noise = np.random.normal(0, 0.05, num_steps)
|
| 36 |
-
training_loss = np.maximum(base_loss + noise, 0.1)
|
| 37 |
-
|
| 38 |
-
print("=" * 70)
|
| 39 |
-
print("AISHA TRAINING SIMULATION - GENERATING SUBMISSION PLOTS")
|
| 40 |
-
print("=" * 70)
|
| 41 |
-
|
| 42 |
-
# Plot 1: Episode Rewards Comparison
|
| 43 |
-
print("\n[1/3] Generating reward_per_episode.png...")
|
| 44 |
-
fig, ax = plt.subplots(figsize=(12, 7))
|
| 45 |
-
|
| 46 |
-
ax.plot(episodes, baseline_scores, 'r--o', label='Random Agent (Baseline)',
|
| 47 |
-
linewidth=2.5, markersize=8, alpha=0.8)
|
| 48 |
-
ax.plot(episodes, pretrain_scores, 'b-s', label='LLM Agent (Pre-training)',
|
| 49 |
-
linewidth=2.5, markersize=8, alpha=0.8)
|
| 50 |
-
ax.plot(episodes, posttrain_scores, 'g-^', label='LLM Agent (Post-training GRPO)',
|
| 51 |
-
linewidth=2.5, markersize=8, alpha=0.8)
|
| 52 |
-
|
| 53 |
-
ax.set_xlabel('Episode', fontsize=14, fontweight='bold')
|
| 54 |
-
ax.set_ylabel('Total Reward (0.0 - 1.0)', fontsize=14, fontweight='bold')
|
| 55 |
-
ax.set_title('AISHA: Episode Reward — Baseline vs Trained Agent', fontsize=15, fontweight='bold')
|
| 56 |
-
ax.legend(fontsize=12, loc='lower right', framealpha=0.95)
|
| 57 |
-
ax.grid(True, alpha=0.3, linestyle='--')
|
| 58 |
-
ax.set_ylim(0, 1.0)
|
| 59 |
-
ax.set_xticks(episodes)
|
| 60 |
-
|
| 61 |
-
plt.tight_layout()
|
| 62 |
-
plt.savefig('./plots/reward_per_episode.png', dpi=150, bbox_inches='tight')
|
| 63 |
-
plt.close()
|
| 64 |
-
print(" ✓ Saved: ./plots/reward_per_episode.png")
|
| 65 |
-
|
| 66 |
-
# Plot 2: Training Loss Curve
|
| 67 |
-
print("[2/3] Generating training_loss.png...")
|
| 68 |
-
fig, ax = plt.subplots(figsize=(12, 7))
|
| 69 |
-
|
| 70 |
-
ax.plot(training_steps, training_loss, 'b-', linewidth=2, alpha=0.7, label='Training Loss')
|
| 71 |
-
|
| 72 |
-
# Add moving average
|
| 73 |
-
window = 10
|
| 74 |
-
moving_avg = np.convolve(training_loss, np.ones(window)/window, mode='valid')
|
| 75 |
-
ax.plot(range(window, num_steps + 1), moving_avg, 'r-', linewidth=2.5,
|
| 76 |
-
label=f'Moving Average (window={window})', alpha=0.9)
|
| 77 |
-
|
| 78 |
-
ax.set_xlabel('Training Step', fontsize=14, fontweight='bold')
|
| 79 |
-
ax.set_ylabel('Loss', fontsize=14, fontweight='bold')
|
| 80 |
-
ax.set_title('AISHA: Training Loss Curve (GRPO)', fontsize=15, fontweight='bold')
|
| 81 |
-
ax.legend(fontsize=12, loc='upper right', framealpha=0.95)
|
| 82 |
-
ax.grid(True, alpha=0.3, linestyle='--')
|
| 83 |
-
|
| 84 |
-
plt.tight_layout()
|
| 85 |
-
plt.savefig('./plots/training_loss.png', dpi=150, bbox_inches='tight')
|
| 86 |
-
plt.close()
|
| 87 |
-
print(" ✓ Saved: ./plots/training_loss.png")
|
| 88 |
-
|
| 89 |
-
# Plot 3: Performance Comparison Bar Chart
|
| 90 |
-
print("[3/3] Generating performance_comparison.png...")
|
| 91 |
-
fig, ax = plt.subplots(figsize=(10, 7))
|
| 92 |
-
|
| 93 |
-
agents = ['Random\nAgent', 'LLM\n(Pre-train)', 'LLM\n(Post-train)']
|
| 94 |
-
avgs = [
|
| 95 |
-
np.mean(baseline_scores),
|
| 96 |
-
np.mean(pretrain_scores),
|
| 97 |
-
np.mean(posttrain_scores)
|
| 98 |
-
]
|
| 99 |
-
colors = ['#e74c3c', '#3498db', '#2ecc71']
|
| 100 |
-
stds = [
|
| 101 |
-
np.std(baseline_scores),
|
| 102 |
-
np.std(pretrain_scores),
|
| 103 |
-
np.std(posttrain_scores)
|
| 104 |
-
]
|
| 105 |
-
|
| 106 |
-
bars = ax.bar(agents, avgs, color=colors, width=0.6, edgecolor='black',
|
| 107 |
-
linewidth=2, alpha=0.85, yerr=stds, capsize=10, error_kw={'linewidth': 2})
|
| 108 |
-
|
| 109 |
-
ax.set_ylabel('Average Episode Reward (0.0 - 1.0)', fontsize=14, fontweight='bold')
|
| 110 |
-
ax.set_title('AISHA: Average Performance Comparison', fontsize=15, fontweight='bold')
|
| 111 |
-
ax.set_ylim(0, 1.0)
|
| 112 |
-
ax.grid(True, alpha=0.3, axis='y', linestyle='--')
|
| 113 |
-
|
| 114 |
-
# Add value labels on bars
|
| 115 |
-
for bar, val, std in zip(bars, avgs, stds):
|
| 116 |
-
height = bar.get_height()
|
| 117 |
-
ax.text(bar.get_x() + bar.get_width()/2, height + std + 0.03,
|
| 118 |
-
f'{val:.3f}', ha='center', va='bottom', fontsize=13, fontweight='bold')
|
| 119 |
-
|
| 120 |
-
plt.tight_layout()
|
| 121 |
-
plt.savefig('./plots/performance_comparison.png', dpi=150, bbox_inches='tight')
|
| 122 |
-
plt.close()
|
| 123 |
-
print(" ✓ Saved: ./plots/performance_comparison.png")
|
| 124 |
-
|
| 125 |
-
# Print summary
|
| 126 |
-
print("\n" + "=" * 70)
|
| 127 |
-
print("TRAINING RESULTS SUMMARY")
|
| 128 |
-
print("=" * 70)
|
| 129 |
-
print(f"\nRandom Agent (Baseline):")
|
| 130 |
-
print(f" Average Score: {avgs[0]:.4f} ± {stds[0]:.4f}")
|
| 131 |
-
print(f" Min Score: {np.min(baseline_scores):.4f}")
|
| 132 |
-
print(f" Max Score: {np.max(baseline_scores):.4f}")
|
| 133 |
-
|
| 134 |
-
print(f"\nLLM Agent (Pre-training):")
|
| 135 |
-
print(f" Average Score: {avgs[1]:.4f} ± {stds[1]:.4f}")
|
| 136 |
-
print(f" Min Score: {np.min(pretrain_scores):.4f}")
|
| 137 |
-
print(f" Max Score: {np.max(pretrain_scores):.4f}")
|
| 138 |
-
|
| 139 |
-
print(f"\nLLM Agent (Post-training GRPO):")
|
| 140 |
-
print(f" Average Score: {avgs[2]:.4f} ± {stds[2]:.4f}")
|
| 141 |
-
print(f" Min Score: {np.min(posttrain_scores):.4f}")
|
| 142 |
-
print(f" Max Score: {np.max(posttrain_scores):.4f}")
|
| 143 |
-
|
| 144 |
-
improvement_pretrain = ((avgs[1] - avgs[0]) / avgs[0]) * 100
|
| 145 |
-
improvement_posttrain = ((avgs[2] - avgs[1]) / avgs[1]) * 100
|
| 146 |
-
improvement_total = ((avgs[2] - avgs[0]) / avgs[0]) * 100
|
| 147 |
-
|
| 148 |
-
print(f"\nImprovement:")
|
| 149 |
-
print(f" Pre-train vs Baseline: {improvement_pretrain:+.1f}%")
|
| 150 |
-
print(f" Post-train vs Pre-train: {improvement_posttrain:+.1f}%")
|
| 151 |
-
print(f" Post-train vs Baseline: {improvement_total:+.1f}%")
|
| 152 |
-
|
| 153 |
-
print(f"\nTraining Loss:")
|
| 154 |
-
print(f" Initial Loss: {training_loss[0]:.4f}")
|
| 155 |
-
print(f" Final Loss: {training_loss[-1]:.4f}")
|
| 156 |
-
print(f" Reduction: {((training_loss[0] - training_loss[-1]) / training_loss[0]) * 100:.1f}%")
|
| 157 |
-
|
| 158 |
-
print("\n" + "=" * 70)
|
| 159 |
-
print("✓ All plots generated successfully!")
|
| 160 |
-
print("=" * 70)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
inference.py
CHANGED
|
@@ -9,21 +9,35 @@ environment. Reads API credentials from environment variables.
|
|
| 9 |
ENV VARS (required):
|
| 10 |
API_BASE_URL — The API endpoint for the LLM
|
| 11 |
MODEL_NAME — The model identifier to use
|
| 12 |
-
HF_TOKEN —
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
-
import
|
| 16 |
import os
|
| 17 |
-
import re
|
| 18 |
import sys
|
| 19 |
import textwrap
|
| 20 |
-
from typing import Any, Dict, List, Optional
|
| 21 |
|
| 22 |
from openai import OpenAI
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# --- ENV VARS ---
|
| 25 |
-
|
| 26 |
-
|
|
|
|
| 27 |
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 28 |
|
| 29 |
if HF_TOKEN is None:
|
|
@@ -71,29 +85,92 @@ CRITICAL RULES:
|
|
| 71 |
""").strip()
|
| 72 |
|
| 73 |
|
| 74 |
-
def
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
text = response_text.strip()
|
| 80 |
-
text = re.sub(r"```json\s*", "", text)
|
| 81 |
-
text = re.sub(r"```\s*$", "", text)
|
| 82 |
-
text = text.strip()
|
| 83 |
|
|
|
|
| 84 |
try:
|
| 85 |
-
|
| 86 |
-
|
|
|
|
| 87 |
pass
|
| 88 |
|
| 89 |
-
match = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", text, re.DOTALL)
|
| 90 |
-
if match:
|
| 91 |
-
try:
|
| 92 |
-
return json.loads(match.group(0))
|
| 93 |
-
except json.JSONDecodeError:
|
| 94 |
-
pass
|
| 95 |
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def build_prompt(step: int, observation: Any, history: List[str], max_steps: int = 30) -> str:
|
|
@@ -144,11 +221,28 @@ def build_prompt(step: int, observation: Any, history: List[str], max_steps: int
|
|
| 144 |
return "\n".join(parts)
|
| 145 |
|
| 146 |
|
| 147 |
-
def run_scenario(
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
max_steps = SCENARIO_MAX_STEPS.get(scenario_id, 30)
|
|
|
|
| 152 |
|
| 153 |
print(f"\n{'='*60}")
|
| 154 |
print(f"Running scenario: {scenario_id} (max {max_steps} steps)")
|
|
@@ -162,13 +256,50 @@ def run_scenario(client: OpenAI, scenario_id: str, env_url: str) -> float:
|
|
| 162 |
total_steps = 0
|
| 163 |
success = False
|
| 164 |
last_error = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
try:
|
| 167 |
with SecurityAuditEnv(base_url=env_url).sync() as env:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
result = env.reset(scenario_id=scenario_id)
|
| 169 |
observation = result.observation
|
| 170 |
history: List[str] = []
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
for step in range(1, max_steps + 1):
|
| 173 |
if result.done:
|
| 174 |
break
|
|
@@ -190,28 +321,34 @@ def run_scenario(client: OpenAI, scenario_id: str, env_url: str) -> float:
|
|
| 190 |
)
|
| 191 |
response_text = completion.choices[0].message.content or ""
|
| 192 |
except Exception as exc:
|
|
|
|
| 193 |
last_error = str(exc)
|
| 194 |
response_text = '{"action_type": "list_tools"}'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
-
|
| 197 |
-
if
|
| 198 |
-
|
| 199 |
-
action_dict = {"action_type": "list_tools"}
|
| 200 |
-
|
| 201 |
-
action_type = action_dict.get("action_type", "list_tools")
|
| 202 |
-
tool_name = action_dict.get("tool_name")
|
| 203 |
-
arguments = action_dict.get("arguments", {})
|
| 204 |
-
|
| 205 |
-
action_str = action_type
|
| 206 |
-
if tool_name:
|
| 207 |
-
action_str += f"({tool_name})"
|
| 208 |
|
| 209 |
try:
|
| 210 |
-
action = SecurityAuditAction(
|
| 211 |
-
action_type=action_type,
|
| 212 |
-
tool_name=tool_name,
|
| 213 |
-
arguments=arguments,
|
| 214 |
-
)
|
| 215 |
result = env.step(action)
|
| 216 |
observation = result.observation
|
| 217 |
last_error = None
|
|
@@ -222,48 +359,72 @@ def run_scenario(client: OpenAI, scenario_id: str, env_url: str) -> float:
|
|
| 222 |
total_steps = step
|
| 223 |
# --- MANDATORY STDOUT: [STEP] ---
|
| 224 |
error_str = last_error.replace("\n", " ") if last_error else "null"
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
break
|
| 227 |
|
| 228 |
reward = result.reward or 0.0
|
| 229 |
all_rewards.append(reward)
|
| 230 |
total_steps = step
|
|
|
|
| 231 |
|
| 232 |
history.append(f"Step {step}: {action_str} → reward {reward:+.2f}")
|
| 233 |
|
| 234 |
# --- MANDATORY STDOUT: [STEP] ---
|
| 235 |
done_str = "true" if result.done else "false"
|
| 236 |
error_str = last_error.replace("\n", " ") if last_error else "null"
|
| 237 |
-
print(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
if result.done:
|
| 240 |
grades = getattr(observation, "metadata", {}) or {}
|
| 241 |
grades = grades.get("grades", {})
|
| 242 |
-
|
|
|
|
| 243 |
success = final_score > 0
|
| 244 |
break
|
| 245 |
-
else:
|
| 246 |
-
# Didn't finish — force report generation
|
| 247 |
-
try:
|
| 248 |
-
action = SecurityAuditAction(action_type="generate_report")
|
| 249 |
-
result = env.step(action)
|
| 250 |
-
reward = result.reward or 0.0
|
| 251 |
-
all_rewards.append(reward)
|
| 252 |
-
total_steps += 1
|
| 253 |
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
| 264 |
except Exception as exc:
|
| 265 |
last_error = str(exc)
|
| 266 |
finally:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
# --- MANDATORY STDOUT: [END] (always emitted, even on exception) ---
|
| 268 |
rewards_str = ",".join(f"{r:.2f}" for r in all_rewards)
|
| 269 |
success_str = "true" if success else "false"
|
|
@@ -272,19 +433,68 @@ def run_scenario(client: OpenAI, scenario_id: str, env_url: str) -> float:
|
|
| 272 |
return final_score
|
| 273 |
|
| 274 |
|
| 275 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
"""Run baseline inference across all scenarios."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
print("Security Audit Environment — Baseline Inference")
|
|
|
|
|
|
|
|
|
|
| 278 |
print(f"API: {API_BASE_URL}")
|
| 279 |
print(f"Model: {MODEL_NAME}")
|
| 280 |
|
| 281 |
llm_client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 282 |
env_url = os.getenv("ENV_URL", "http://localhost:8000")
|
| 283 |
|
| 284 |
-
scores = {}
|
| 285 |
-
for scenario_id in SCENARIOS:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
try:
|
| 287 |
-
score = run_scenario(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
scores[scenario_id] = score
|
| 289 |
except Exception as exc:
|
| 290 |
print(f" ERROR on {scenario_id}: {exc}")
|
|
@@ -293,9 +503,11 @@ def main():
|
|
| 293 |
print(f"\n{'='*60}")
|
| 294 |
print("BASELINE SCORES")
|
| 295 |
print(f"{'='*60}")
|
| 296 |
-
for sid
|
| 297 |
-
|
| 298 |
-
|
|
|
|
|
|
|
| 299 |
print(f" {'average':10s}: {avg:.4f}")
|
| 300 |
print(f"{'='*60}")
|
| 301 |
|
|
|
|
| 9 |
ENV VARS (required):
|
| 10 |
API_BASE_URL — The API endpoint for the LLM
|
| 11 |
MODEL_NAME — The model identifier to use
|
| 12 |
+
HF_TOKEN — API key (Hugging Face, OpenRouter, etc. — sent as client api_key)
|
| 13 |
+
|
| 14 |
+
Optional:
|
| 15 |
+
INFERENCE_LOG_LLM — If set, append each raw model response to this file path
|
| 16 |
+
INFERENCE_LOG_API — If set, append API errors and fallback-to-list_tools events
|
| 17 |
+
INFERENCE_INTERACTIVE — 1 / true: wait for Enter between steps (see --interactive)
|
| 18 |
+
INFERENCE_PAUSE — step (default) | scenario: what "interactive" pauses between
|
| 19 |
+
INFERENCE_SUMMARY_FILE — If set, append a short grader + stats block per scenario
|
| 20 |
"""
|
| 21 |
|
| 22 |
+
import argparse
|
| 23 |
import os
|
|
|
|
| 24 |
import sys
|
| 25 |
import textwrap
|
| 26 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 27 |
|
| 28 |
from openai import OpenAI
|
| 29 |
|
| 30 |
+
try:
|
| 31 |
+
from dotenv import load_dotenv
|
| 32 |
+
|
| 33 |
+
load_dotenv()
|
| 34 |
+
except ImportError:
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
# --- ENV VARS ---
|
| 38 |
+
# Defaults favour OpenRouter + Llama 3.2 3B; override with env or .env.
|
| 39 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://openrouter.ai/api/v1")
|
| 40 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/llama-3.2-3b-instruct:free")
|
| 41 |
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 42 |
|
| 43 |
if HF_TOKEN is None:
|
|
|
|
| 85 |
""").strip()
|
| 86 |
|
| 87 |
|
| 88 |
+
def _append_llm_log(path: str, scenario_id: str, step: int, text: str) -> None:
|
| 89 |
+
try:
|
| 90 |
+
with open(path, "a", encoding="utf-8") as f:
|
| 91 |
+
f.write(f"\n=== {scenario_id} step={step} ===\n{text}\n")
|
| 92 |
+
except OSError:
|
| 93 |
+
pass
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
+
def _append_api_log(path: str, scenario_id: str, step: int, text: str) -> None:
|
| 97 |
try:
|
| 98 |
+
with open(path, "a", encoding="utf-8") as f:
|
| 99 |
+
f.write(f"\n=== {scenario_id} step={step} API ===\n{text.rstrip()}\n")
|
| 100 |
+
except OSError:
|
| 101 |
pass
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
+
def _append_summary_file(path: str, text: str) -> None:
|
| 105 |
+
try:
|
| 106 |
+
with open(path, "a", encoding="utf-8") as f:
|
| 107 |
+
f.write(text)
|
| 108 |
+
if not text.endswith("\n"):
|
| 109 |
+
f.write("\n")
|
| 110 |
+
except OSError:
|
| 111 |
+
pass
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _format_grader_block(scenario_id: str, grades: Dict[str, Any], episode_step_reward_sum: float) -> str:
|
| 115 |
+
"""Human-readable grader output (where your final 'reward' / score comes from)."""
|
| 116 |
+
lines = [
|
| 117 |
+
"",
|
| 118 |
+
f"{'='*60}",
|
| 119 |
+
f" REWARD / GRADER BREAKDOWN — scenario: {scenario_id}",
|
| 120 |
+
f"{'='*60}",
|
| 121 |
+
f" final_score (0–1, main benchmark): {grades.get('final_score', 0.0):.4f}",
|
| 122 |
+
f" sum of per-step rewards (episode): {episode_step_reward_sum:.4f}",
|
| 123 |
+
f" true positives / total vulns: {grades.get('true_positives', 0)}/{grades.get('total_vulnerabilities', 0)} (detection_rate={grades.get('detection_rate', 0.0):.2f})",
|
| 124 |
+
f" hosts examined / total hosts: {grades.get('hosts_examined', 0)}/{grades.get('total_hosts', 0)} (coverage={grades.get('coverage', 0.0):.2f})",
|
| 125 |
+
f" false positives (penalty): {grades.get('false_positives', 0)} (fp_penalty -{grades.get('fp_penalty', 0.0):.2f})",
|
| 126 |
+
f" severity / classification: {grades.get('severity_accuracy', 0.0):.2f} / {grades.get('classification_accuracy', 0.0):.2f}",
|
| 127 |
+
f" report quality: {grades.get('report_quality', 0.0):.2f}",
|
| 128 |
+
f"{'='*60}",
|
| 129 |
+
]
|
| 130 |
+
return "\n".join(lines) + "\n"
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _format_zero_score_hint(
|
| 134 |
+
n_list_tools: int,
|
| 135 |
+
n_api_errors: int,
|
| 136 |
+
total_steps: int,
|
| 137 |
+
) -> str:
|
| 138 |
+
parts = [
|
| 139 |
+
" HINT: final_score is 0 when no findings match the scenario, or coverage is near zero.",
|
| 140 |
+
]
|
| 141 |
+
if n_list_tools >= max(1, total_steps - 1) and total_steps > 0:
|
| 142 |
+
parts.append(
|
| 143 |
+
" → Most steps were 'list_tools' (no discovery). Use use_tool (network_scan, web_crawl) then submit_finding."
|
| 144 |
+
)
|
| 145 |
+
if n_api_errors > 0:
|
| 146 |
+
parts.append(
|
| 147 |
+
f" → {n_api_errors} LLM API call(s) failed (see INFERENCE_LOG_API or stderr); responses may be fallbacks, not the model."
|
| 148 |
+
)
|
| 149 |
+
return "\n".join(parts) + "\n"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _env_bool(name: str) -> bool:
|
| 153 |
+
return os.getenv(name, "").lower() in ("1", "true", "yes", "on")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _wait_interactive(
|
| 157 |
+
message: str,
|
| 158 |
+
) -> str:
|
| 159 |
+
"""Block until the user accepts the next action. Returns a short status for logging."""
|
| 160 |
+
if not sys.stdin.isatty():
|
| 161 |
+
return "skipped (no tty)"
|
| 162 |
+
try:
|
| 163 |
+
return input(message).strip().lower() or "ok"
|
| 164 |
+
except EOFError:
|
| 165 |
+
return "eof"
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _config_interactive() -> Tuple[bool, str]:
|
| 169 |
+
"""(interactive, pause) where pause is 'step' or 'scenario'."""
|
| 170 |
+
pause = os.getenv("INFERENCE_PAUSE", "step").lower().strip()
|
| 171 |
+
if pause not in ("step", "scenario"):
|
| 172 |
+
pause = "step"
|
| 173 |
+
return _env_bool("INFERENCE_INTERACTIVE"), pause
|
| 174 |
|
| 175 |
|
| 176 |
def build_prompt(step: int, observation: Any, history: List[str], max_steps: int = 30) -> str:
|
|
|
|
| 221 |
return "\n".join(parts)
|
| 222 |
|
| 223 |
|
| 224 |
+
def run_scenario(
|
| 225 |
+
client: OpenAI,
|
| 226 |
+
scenario_id: str,
|
| 227 |
+
env_url: str,
|
| 228 |
+
*,
|
| 229 |
+
interactive: bool = False,
|
| 230 |
+
pause: str = "step",
|
| 231 |
+
) -> float:
|
| 232 |
+
"""Run the agent on one scenario and return the final score.
|
| 233 |
+
|
| 234 |
+
If ``interactive`` and ``pause == "step"``, wait for Enter after each step
|
| 235 |
+
(before the next LLM call) to space out API traffic and avoid rate limits.
|
| 236 |
+
If ``pause == "scenario"``, only :func:`main` pauses between scenarios.
|
| 237 |
+
"""
|
| 238 |
+
from security_audit_env import (
|
| 239 |
+
SecurityAuditAction,
|
| 240 |
+
SecurityAuditEnv,
|
| 241 |
+
parse_llm_action_text,
|
| 242 |
+
)
|
| 243 |
|
| 244 |
max_steps = SCENARIO_MAX_STEPS.get(scenario_id, 30)
|
| 245 |
+
api_log = os.getenv("INFERENCE_LOG_API")
|
| 246 |
|
| 247 |
print(f"\n{'='*60}")
|
| 248 |
print(f"Running scenario: {scenario_id} (max {max_steps} steps)")
|
|
|
|
| 256 |
total_steps = 0
|
| 257 |
success = False
|
| 258 |
last_error = None
|
| 259 |
+
user_quit_scenario = False
|
| 260 |
+
last_grades: Optional[Dict[str, Any]] = None
|
| 261 |
+
n_list_tools = 0
|
| 262 |
+
n_api_errors = 0
|
| 263 |
+
summary_path = os.getenv("INFERENCE_SUMMARY_FILE")
|
| 264 |
|
| 265 |
try:
|
| 266 |
with SecurityAuditEnv(base_url=env_url).sync() as env:
|
| 267 |
+
if interactive and pause == "step" and sys.stdin.isatty():
|
| 268 |
+
u = _wait_interactive(
|
| 269 |
+
f"\n>>> Starting '{scenario_id}'. Press Enter to run the first step (LLM call), or 'q' + Enter to skip this scenario.\n> "
|
| 270 |
+
)
|
| 271 |
+
if u == "q":
|
| 272 |
+
return 0.0
|
| 273 |
+
|
| 274 |
result = env.reset(scenario_id=scenario_id)
|
| 275 |
observation = result.observation
|
| 276 |
history: List[str] = []
|
| 277 |
|
| 278 |
+
def _do_force_report() -> None:
|
| 279 |
+
nonlocal result, all_rewards, total_steps, final_score, success, last_error, observation, last_grades
|
| 280 |
+
try:
|
| 281 |
+
act = SecurityAuditAction(action_type="generate_report")
|
| 282 |
+
result = env.step(act)
|
| 283 |
+
reward = result.reward or 0.0
|
| 284 |
+
all_rewards.append(reward)
|
| 285 |
+
total_steps = total_steps + 1
|
| 286 |
+
_ts = total_steps
|
| 287 |
+
_cum = sum(all_rewards)
|
| 288 |
+
print(
|
| 289 |
+
f"[STEP] step={_ts} action=generate_report reward={reward:.2f} "
|
| 290 |
+
f"cum={_cum:.2f} done={str(result.done).lower()} error=null",
|
| 291 |
+
flush=True,
|
| 292 |
+
)
|
| 293 |
+
observation = result.observation
|
| 294 |
+
grades = getattr(observation, "metadata", {}) or {}
|
| 295 |
+
grades = grades.get("grades", {})
|
| 296 |
+
last_grades = grades if isinstance(grades, dict) and grades else None
|
| 297 |
+
final_score = grades.get("final_score", reward) if last_grades else (reward or 0.0)
|
| 298 |
+
success = final_score > 0
|
| 299 |
+
except Exception as exc:
|
| 300 |
+
final_score = 0.0
|
| 301 |
+
last_error = str(exc)
|
| 302 |
+
|
| 303 |
for step in range(1, max_steps + 1):
|
| 304 |
if result.done:
|
| 305 |
break
|
|
|
|
| 321 |
)
|
| 322 |
response_text = completion.choices[0].message.content or ""
|
| 323 |
except Exception as exc:
|
| 324 |
+
n_api_errors += 1
|
| 325 |
last_error = str(exc)
|
| 326 |
response_text = '{"action_type": "list_tools"}'
|
| 327 |
+
err_line = f"[API error — using fallback list_tools] {type(exc).__name__}: {exc}"
|
| 328 |
+
if api_log:
|
| 329 |
+
_append_api_log(api_log, scenario_id, step, err_line)
|
| 330 |
+
else:
|
| 331 |
+
print(f" {err_line}", flush=True)
|
| 332 |
+
|
| 333 |
+
log_path = os.getenv("INFERENCE_LOG_LLM")
|
| 334 |
+
if log_path and response_text:
|
| 335 |
+
_append_llm_log(log_path, scenario_id, step, response_text)
|
| 336 |
+
|
| 337 |
+
llm_action, json_err = parse_llm_action_text(response_text)
|
| 338 |
+
if llm_action is None:
|
| 339 |
+
last_error = json_err or "Could not parse LLM action JSON"
|
| 340 |
+
action = SecurityAuditAction(action_type="list_tools")
|
| 341 |
+
else:
|
| 342 |
+
last_error = None
|
| 343 |
+
action = llm_action.to_security_audit_action()
|
| 344 |
+
if action.action_type == "list_tools":
|
| 345 |
+
n_list_tools += 1
|
| 346 |
|
| 347 |
+
action_str = action.action_type
|
| 348 |
+
if action.tool_name:
|
| 349 |
+
action_str += f"({action.tool_name})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
result = env.step(action)
|
| 353 |
observation = result.observation
|
| 354 |
last_error = None
|
|
|
|
| 359 |
total_steps = step
|
| 360 |
# --- MANDATORY STDOUT: [STEP] ---
|
| 361 |
error_str = last_error.replace("\n", " ") if last_error else "null"
|
| 362 |
+
_c = sum(all_rewards)
|
| 363 |
+
print(
|
| 364 |
+
f"[STEP] step={step} action={action_str} reward={reward:.2f} "
|
| 365 |
+
f"cum={_c:.2f} done=false error={error_str}",
|
| 366 |
+
flush=True,
|
| 367 |
+
)
|
| 368 |
break
|
| 369 |
|
| 370 |
reward = result.reward or 0.0
|
| 371 |
all_rewards.append(reward)
|
| 372 |
total_steps = step
|
| 373 |
+
_cum = sum(all_rewards)
|
| 374 |
|
| 375 |
history.append(f"Step {step}: {action_str} → reward {reward:+.2f}")
|
| 376 |
|
| 377 |
# --- MANDATORY STDOUT: [STEP] ---
|
| 378 |
done_str = "true" if result.done else "false"
|
| 379 |
error_str = last_error.replace("\n", " ") if last_error else "null"
|
| 380 |
+
print(
|
| 381 |
+
f"[STEP] step={step} action={action_str} reward={reward:.2f} "
|
| 382 |
+
f"cum={_cum:.2f} done={done_str} error={error_str}",
|
| 383 |
+
flush=True,
|
| 384 |
+
)
|
| 385 |
|
| 386 |
if result.done:
|
| 387 |
grades = getattr(observation, "metadata", {}) or {}
|
| 388 |
grades = grades.get("grades", {})
|
| 389 |
+
last_grades = grades if isinstance(grades, dict) and grades else None
|
| 390 |
+
final_score = grades.get("final_score", reward) if last_grades else 0.0
|
| 391 |
success = final_score > 0
|
| 392 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
|
| 394 |
+
if interactive and pause == "step" and sys.stdin.isatty() and not result.done:
|
| 395 |
+
u2 = _wait_interactive(
|
| 396 |
+
f"\n>>> {scenario_id} step {step}/{max_steps} done. "
|
| 397 |
+
"Press Enter for the next LLM call, or 'q' + Enter to end this scenario (a report will be generated).\n> "
|
| 398 |
+
)
|
| 399 |
+
if u2 == "q":
|
| 400 |
+
user_quit_scenario = True
|
| 401 |
+
break
|
| 402 |
+
else:
|
| 403 |
+
# No break — ran all steps without terminal done: force report
|
| 404 |
+
_do_force_report()
|
| 405 |
+
if user_quit_scenario:
|
| 406 |
+
_do_force_report()
|
| 407 |
except Exception as exc:
|
| 408 |
last_error = str(exc)
|
| 409 |
finally:
|
| 410 |
+
if last_grades is not None:
|
| 411 |
+
_sm = _format_grader_block(scenario_id, last_grades, sum(all_rewards))
|
| 412 |
+
print(_sm, flush=True)
|
| 413 |
+
if summary_path:
|
| 414 |
+
_append_summary_file(summary_path, _sm)
|
| 415 |
+
elif total_steps > 0:
|
| 416 |
+
_mini = (
|
| 417 |
+
f"\n (No grader report in metadata — score may be unset. "
|
| 418 |
+
f"Steps={total_steps} list_tools_steps≈{n_list_tools} api_errors={n_api_errors})\n"
|
| 419 |
+
)
|
| 420 |
+
print(_mini, flush=True)
|
| 421 |
+
if summary_path:
|
| 422 |
+
_append_summary_file(summary_path, _mini)
|
| 423 |
+
if final_score == 0.0 and (last_grades is not None or total_steps > 0):
|
| 424 |
+
_hint = _format_zero_score_hint(n_list_tools, n_api_errors, total_steps)
|
| 425 |
+
print(_hint, flush=True)
|
| 426 |
+
if summary_path:
|
| 427 |
+
_append_summary_file(summary_path, _hint)
|
| 428 |
# --- MANDATORY STDOUT: [END] (always emitted, even on exception) ---
|
| 429 |
rewards_str = ",".join(f"{r:.2f}" for r in all_rewards)
|
| 430 |
success_str = "true" if success else "false"
|
|
|
|
| 433 |
return final_score
|
| 434 |
|
| 435 |
|
| 436 |
+
def _parse_args() -> argparse.Namespace:
|
| 437 |
+
p = argparse.ArgumentParser(
|
| 438 |
+
description="Run the baseline LLM agent on SecurityAuditEnv.",
|
| 439 |
+
)
|
| 440 |
+
p.add_argument(
|
| 441 |
+
"-i",
|
| 442 |
+
"--interactive",
|
| 443 |
+
action="store_true",
|
| 444 |
+
help="Wait for your input between LLM steps (or between scenarios) to space out API calls and reduce rate limits",
|
| 445 |
+
)
|
| 446 |
+
p.add_argument(
|
| 447 |
+
"--pause",
|
| 448 |
+
choices=["step", "scenario"],
|
| 449 |
+
default=None,
|
| 450 |
+
help="With --interactive: 'step' pauses after each environment step; 'scenario' only between easy/medium/hard",
|
| 451 |
+
)
|
| 452 |
+
return p.parse_args()
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def main() -> None:
|
| 456 |
"""Run baseline inference across all scenarios."""
|
| 457 |
+
args = _parse_args()
|
| 458 |
+
env_inter = args.interactive or _env_bool("INFERENCE_INTERACTIVE")
|
| 459 |
+
pause = args.pause or os.getenv("INFERENCE_PAUSE", "step")
|
| 460 |
+
if pause not in ("step", "scenario"):
|
| 461 |
+
pause = "step"
|
| 462 |
+
|
| 463 |
print("Security Audit Environment — Baseline Inference")
|
| 464 |
+
if env_inter:
|
| 465 |
+
print("Mode: INTERACTIVE (you control the pace; stdin must be a TTY)")
|
| 466 |
+
print(f" Pause: {pause} (INFERENCE_PAUSE, or --pause)")
|
| 467 |
print(f"API: {API_BASE_URL}")
|
| 468 |
print(f"Model: {MODEL_NAME}")
|
| 469 |
|
| 470 |
llm_client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 471 |
env_url = os.getenv("ENV_URL", "http://localhost:8000")
|
| 472 |
|
| 473 |
+
scores: Dict[str, float] = {}
|
| 474 |
+
for i, scenario_id in enumerate(SCENARIOS):
|
| 475 |
+
if env_inter and pause == "scenario" and sys.stdin.isatty():
|
| 476 |
+
if i == 0:
|
| 477 |
+
nxt0 = _wait_interactive(
|
| 478 |
+
f"\n>>> Press Enter to start the first scenario ('{scenario_id}'), or 'q' + Enter to cancel.\n> "
|
| 479 |
+
)
|
| 480 |
+
if nxt0 == "q":
|
| 481 |
+
print("(Cancelled.)", flush=True)
|
| 482 |
+
return
|
| 483 |
+
else:
|
| 484 |
+
nxt = _wait_interactive(
|
| 485 |
+
f"\n>>> Previous scenario(s) finished. Press Enter to start '{scenario_id}', or 'q' + Enter to stop the run.\n> "
|
| 486 |
+
)
|
| 487 |
+
if nxt == "q":
|
| 488 |
+
print("(Stopping — remaining scenarios skipped.)", flush=True)
|
| 489 |
+
break
|
| 490 |
try:
|
| 491 |
+
score = run_scenario(
|
| 492 |
+
llm_client,
|
| 493 |
+
scenario_id,
|
| 494 |
+
env_url,
|
| 495 |
+
interactive=env_inter,
|
| 496 |
+
pause=pause,
|
| 497 |
+
)
|
| 498 |
scores[scenario_id] = score
|
| 499 |
except Exception as exc:
|
| 500 |
print(f" ERROR on {scenario_id}: {exc}")
|
|
|
|
| 503 |
print(f"\n{'='*60}")
|
| 504 |
print("BASELINE SCORES")
|
| 505 |
print(f"{'='*60}")
|
| 506 |
+
for sid in SCENARIOS:
|
| 507 |
+
if sid in scores:
|
| 508 |
+
print(f" {sid:10s}: {scores[sid]:.4f}")
|
| 509 |
+
rans = [scores[k] for k in SCENARIOS if k in scores]
|
| 510 |
+
avg = sum(rans) / len(rans) if rans else 0.0
|
| 511 |
print(f" {'average':10s}: {avg:.4f}")
|
| 512 |
print(f"{'='*60}")
|
| 513 |
|
models.py
CHANGED
|
@@ -10,10 +10,12 @@ Simulates real-world VAPT (Vulnerability Assessment & Penetration Testing)
|
|
| 10 |
engagements where an AI agent audits infrastructure for security compliance.
|
| 11 |
"""
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
|
| 15 |
from openenv.core.env_server.types import Action, Observation, State
|
| 16 |
-
from pydantic import Field
|
| 17 |
|
| 18 |
|
| 19 |
class SecurityAuditAction(Action):
|
|
@@ -41,6 +43,79 @@ class SecurityAuditAction(Action):
|
|
| 41 |
)
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
class SecurityAuditObservation(Observation):
|
| 45 |
"""Observation returned after each step.
|
| 46 |
|
|
|
|
| 10 |
engagements where an AI agent audits infrastructure for security compliance.
|
| 11 |
"""
|
| 12 |
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
| 16 |
|
| 17 |
from openenv.core.env_server.types import Action, Observation, State
|
| 18 |
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
| 19 |
|
| 20 |
|
| 21 |
class SecurityAuditAction(Action):
|
|
|
|
| 43 |
)
|
| 44 |
|
| 45 |
|
| 46 |
+
class LLMJsonAction(BaseModel):
|
| 47 |
+
"""Wire JSON for one model turn, validated before ``SecurityAuditAction``.
|
| 48 |
+
|
| 49 |
+
Unknown top-level keys are ignored so minor format drift does not fail parsing.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
| 53 |
+
|
| 54 |
+
action_type: Literal["list_tools", "use_tool", "submit_finding", "generate_report"] = Field(
|
| 55 |
+
...,
|
| 56 |
+
description="Which environment action to take",
|
| 57 |
+
)
|
| 58 |
+
tool_name: Optional[str] = Field(
|
| 59 |
+
default=None,
|
| 60 |
+
description="Tool name when action_type is use_tool",
|
| 61 |
+
)
|
| 62 |
+
arguments: Dict[str, Any] = Field(
|
| 63 |
+
default_factory=dict,
|
| 64 |
+
description="Arguments for use_tool or fields for submit_finding",
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
def to_security_audit_action(self) -> SecurityAuditAction:
|
| 68 |
+
return SecurityAuditAction(
|
| 69 |
+
action_type=self.action_type,
|
| 70 |
+
tool_name=self.tool_name,
|
| 71 |
+
arguments=self.arguments,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def extract_json_object_from_text(raw: str) -> Optional[Dict[str, Any]]:
|
| 76 |
+
"""Return the first JSON object from model text, or None."""
|
| 77 |
+
if not (raw and raw.strip()):
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
text = raw.strip()
|
| 81 |
+
text = re.sub(r"```json\s*", "", text)
|
| 82 |
+
text = re.sub(r"```\s*$", "", text, flags=re.MULTILINE)
|
| 83 |
+
text = text.strip()
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
val = json.loads(text)
|
| 87 |
+
except json.JSONDecodeError:
|
| 88 |
+
val = None
|
| 89 |
+
|
| 90 |
+
if isinstance(val, dict):
|
| 91 |
+
return val
|
| 92 |
+
|
| 93 |
+
match = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", text, re.DOTALL)
|
| 94 |
+
if match:
|
| 95 |
+
try:
|
| 96 |
+
v2 = json.loads(match.group(0))
|
| 97 |
+
except json.JSONDecodeError:
|
| 98 |
+
return None
|
| 99 |
+
if isinstance(v2, dict):
|
| 100 |
+
return v2
|
| 101 |
+
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def parse_llm_action_text(raw: str) -> Tuple[Optional[LLMJsonAction], Optional[str]]:
|
| 106 |
+
"""Parse and validate one action from a chat message.
|
| 107 |
+
|
| 108 |
+
Returns (model, None) on success, or (None, error_message) on failure.
|
| 109 |
+
"""
|
| 110 |
+
data = extract_json_object_from_text(raw)
|
| 111 |
+
if data is None:
|
| 112 |
+
return None, "Could not extract a JSON object from model response"
|
| 113 |
+
try:
|
| 114 |
+
return LLMJsonAction.model_validate(data), None
|
| 115 |
+
except ValidationError as exc:
|
| 116 |
+
return None, str(exc)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
class SecurityAuditObservation(Observation):
|
| 120 |
"""Observation returned after each step.
|
| 121 |
|
notebook_builder.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
# Placeholder
|
|
|
|
|
|
pyproject.toml
CHANGED
|
@@ -8,6 +8,11 @@
|
|
| 8 |
requires = ["setuptools>=45", "wheel"]
|
| 9 |
build-backend = "setuptools.build_meta"
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
[project]
|
| 12 |
name = "openenv-security_audit_env"
|
| 13 |
version = "0.1.0"
|
|
@@ -19,6 +24,7 @@ dependencies = [
|
|
| 19 |
# "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
|
| 20 |
"openenv-core[core]>=0.2.3",
|
| 21 |
"openai>=1.0.0",
|
|
|
|
| 22 |
]
|
| 23 |
|
| 24 |
[project.optional-dependencies]
|
|
|
|
| 8 |
requires = ["setuptools>=45", "wheel"]
|
| 9 |
build-backend = "setuptools.build_meta"
|
| 10 |
|
| 11 |
+
# Only run tests from tests/ — avoids collecting repo-root __init__.py (package) as a module.
|
| 12 |
+
[tool.pytest.ini_options]
|
| 13 |
+
testpaths = ["tests"]
|
| 14 |
+
pythonpath = ["."]
|
| 15 |
+
|
| 16 |
[project]
|
| 17 |
name = "openenv-security_audit_env"
|
| 18 |
version = "0.1.0"
|
|
|
|
| 24 |
# "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
|
| 25 |
"openenv-core[core]>=0.2.3",
|
| 26 |
"openai>=1.0.0",
|
| 27 |
+
"python-dotenv>=1.0.0",
|
| 28 |
]
|
| 29 |
|
| 30 |
[project.optional-dependencies]
|