Sayuj63 commited on
Commit
7d752b8
·
1 Parent(s): a510141

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

Files changed (10) hide show
  1. .env.example +22 -4
  2. README.md +2 -2
  3. __init__.py +27 -2
  4. client.py +4 -1
  5. conftest.py +10 -0
  6. generate_submission_plots.py +0 -160
  7. inference.py +283 -71
  8. models.py +77 -2
  9. notebook_builder.py +0 -1
  10. pyproject.toml +6 -0
.env.example CHANGED
@@ -1,7 +1,25 @@
1
  # Required for inference.py (baseline agent)
2
- API_BASE_URL=https://router.huggingface.co/v1
3
- MODEL_NAME=meta-llama/Llama-3.3-70B-Instruct
4
- HF_TOKEN=your-huggingface-token-here
5
 
6
- # Optional: point inference at a different env URL
 
 
 
 
 
 
 
 
 
 
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
- from .client import SecurityAuditEnv
9
- from .models import SecurityAuditAction, SecurityAuditObservation, SecurityAuditState
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- from .models import SecurityAuditAction, SecurityAuditObservation, SecurityAuditState
 
 
 
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 — Your Hugging Face / API key
 
 
 
 
 
 
 
13
  """
14
 
15
- import json
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
- API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
26
- MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.3-70B-Instruct")
 
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 parse_action(response_text: str) -> Optional[Dict[str, Any]]:
75
- """Extract a JSON action from the LLM's response."""
76
- if not response_text:
77
- return None
 
 
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
- return json.loads(text)
86
- except json.JSONDecodeError:
 
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
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(client: OpenAI, scenario_id: str, env_url: str) -> float:
148
- """Run the agent on one scenario and return the final score."""
149
- from security_audit_env import SecurityAuditEnv, SecurityAuditAction
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- action_dict = parse_action(response_text)
197
- if not action_dict:
198
- last_error = "Could not parse LLM response as JSON"
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
- print(f"[STEP] step={step} action={action_str} reward={reward:.2f} done=false error={error_str}", flush=True)
 
 
 
 
 
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(f"[STEP] step={step} action={action_str} reward={reward:.2f} done={done_str} error={error_str}", flush=True)
 
 
 
 
238
 
239
  if result.done:
240
  grades = getattr(observation, "metadata", {}) or {}
241
  grades = grades.get("grades", {})
242
- final_score = grades.get("final_score", reward)
 
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
- done_str = "true" if result.done else "false"
255
- print(f"[STEP] step={total_steps} action=generate_report reward={reward:.2f} done={done_str} error=null", flush=True)
256
-
257
- grades = getattr(result.observation, "metadata", {}) or {}
258
- grades = grades.get("grades", {})
259
- final_score = grades.get("final_score", 0.0)
260
- success = final_score > 0
261
- except Exception as exc:
262
- final_score = 0.0
263
- last_error = str(exc)
 
 
 
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 main():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(llm_client, scenario_id, env_url)
 
 
 
 
 
 
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, score in scores.items():
297
- print(f" {sid:10s}: {score:.4f}")
298
- avg = sum(scores.values()) / len(scores) if scores else 0.0
 
 
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
- from typing import Any, Dict, List, Literal, Optional
 
 
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]