zephO-O commited on
Commit
add7295
Β·
verified Β·
1 Parent(s): 2d3f17c

Upload 18 files

Browse files
Files changed (18) hide show
  1. .dockerignore +34 -0
  2. .env.example +14 -0
  3. .gitattributes +35 -35
  4. .gitignore +35 -0
  5. Dockerfile +28 -0
  6. README.md +106 -12
  7. env.py +739 -0
  8. grader.py +403 -0
  9. inference.py +323 -0
  10. models.py +110 -0
  11. openenv.yaml +87 -0
  12. pyproject.toml +27 -0
  13. requirements.txt +18 -0
  14. server/__init__.py +1 -0
  15. server/__pycache__/app.cpython-314.pyc +0 -0
  16. server/app.py +28 -0
  17. test_api.py +211 -0
  18. test_grader.py +264 -0
.dockerignore ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git metadata β€” no need inside the image
2
+ .git/
3
+ .gitignore
4
+ .gitattributes
5
+
6
+ # Python cache β€” rebuilt inside the image
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.pyo
10
+
11
+ # Package manager lock files β€” not needed at runtime
12
+ uv.lock
13
+ poetry.lock
14
+
15
+ # IDE / OS noise
16
+ .vscode/
17
+ .idea/
18
+ .DS_Store
19
+
20
+ # Test files β€” not needed in production image
21
+ test_*.py
22
+ tests/
23
+
24
+ # Inference agent β€” client-side only, not needed in server image
25
+ inference.py
26
+
27
+ # Environment variable files β€” may contain secrets
28
+ .env*
29
+
30
+ # Inference run outputs
31
+ results_*.json
32
+
33
+ # Docs
34
+ README.md
.env.example ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PhishGuard-Env – environment variable reference
2
+ # Copy this file to .env and fill in your values.
3
+ # Never commit the actual .env file (it's in .gitignore).
4
+
5
+ # ── LLM API credentials (one of these is required for inference.py) ───────────
6
+ HF_TOKEN=hf_your_token_here
7
+ # OPENAI_API_KEY=sk-your_key_here # alternative to HF_TOKEN
8
+
9
+ # ── LLM / API settings ────────────────────────────────────────────────────────
10
+ API_BASE_URL=https://router.huggingface.co/v1
11
+ MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
12
+
13
+ # ── Environment server URL (used by inference.py to reach env.py) ─────────────
14
+ ENV_BASE_URL=http://localhost:7860
.gitattributes CHANGED
@@ -1,35 +1,35 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .eggs/
11
+
12
+ # Virtual environments
13
+ .venv/
14
+ venv/
15
+ env/
16
+ .env
17
+
18
+ # Package managers
19
+ uv.lock
20
+ poetry.lock
21
+
22
+ # IDE / editor
23
+ .vscode/
24
+ .idea/
25
+ *.swp
26
+ *.swo
27
+ .DS_Store
28
+
29
+ # Test / coverage
30
+ .pytest_cache/
31
+ .coverage
32
+ htmlcov/
33
+
34
+ # Inference run outputs
35
+ results_*.json
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BUG FIX: pin full patch version for reproducible image builds.
2
+ # `python:3.10-slim` floats to whatever the latest 3.10.x is at build time.
3
+ FROM python:3.10.14-slim
4
+
5
+ WORKDIR /app
6
+
7
+ # Install dependencies before copying source so Docker layer cache is reused
8
+ # on code-only changes.
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir --upgrade pip && \
11
+ pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY . .
14
+
15
+ # BUG FIX: run as a non-root user β€” running as root in a container is a
16
+ # security risk and violates the principle of least privilege.
17
+ RUN adduser --disabled-password --gecos "" appuser
18
+ USER appuser
19
+
20
+ EXPOSE 7860
21
+
22
+ # BUG FIX: declare a HEALTHCHECK so HF Spaces / orchestrators can detect
23
+ # if the server has crashed and restart the container automatically.
24
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
25
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')" \
26
+ || exit 1
27
+
28
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,12 +1,106 @@
1
- ---
2
- title: Phishing Env 2
3
- emoji: πŸ“ˆ
4
- colorFrom: red
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: Side project
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PhishGuard-Env
2
+
3
+ An OpenEnv-compliant SOC analyst simulation environment for benchmarking LLM-based email triage agents.
4
+
5
+ ## Overview
6
+
7
+ PhishGuard-Env presents an agent with phishing, malware, BEC, spam, and safe emails. The agent must classify each one using a four-action triage system. Rewards are graded continuously in `(0.0, 1.0)` β€” never 0, never 1 β€” and a health system penalises critical mistakes.
8
+
9
+ ## Difficulty Levels
10
+
11
+ | Level | Scenarios | Tasks |
12
+ |--------|-----------------|-------|
13
+ | easy | lv1–lv3 | 3 |
14
+ | medium | lv4–lv7 | 4 |
15
+ | hard | lv8–lv10 | 3 |
16
+
17
+ ## Action Space
18
+
19
+ | Action | When to use |
20
+ |---------------|--------------------------------------------------|
21
+ | `MARK_SAFE` | Confirmed legitimate email β€” deliver to inbox |
22
+ | `MOVE_TO_SPAM`| Bulk / unsolicited mail, no active threat |
23
+ | `QUARANTINE` | Suspicious but unconfirmed β€” hold for review |
24
+ | `BLOCK_DOMAIN`| Confirmed phishing / BEC / malware source |
25
+
26
+ ## Quickstart
27
+
28
+ ### 1. Clone and install
29
+
30
+ ```bash
31
+ git clone https://github.com/your-username/phishguard-env
32
+ cd phishguard-env
33
+ pip install -r requirements.txt
34
+ ```
35
+
36
+ ### 2. Configure credentials
37
+
38
+ ```bash
39
+ cp .env.example .env
40
+ # Edit .env and set HF_TOKEN or OPENAI_API_KEY
41
+ ```
42
+
43
+ ### 3. Start the environment server
44
+
45
+ ```bash
46
+ python env.py
47
+ # Server starts on http://localhost:7860
48
+ ```
49
+
50
+ ### 4. Run the inference agent
51
+
52
+ ```bash
53
+ # All three levels
54
+ python inference.py
55
+
56
+ # Single level
57
+ python inference.py --level hard
58
+
59
+ # Custom output path
60
+ python inference.py --output my_run.json
61
+ ```
62
+
63
+ ### 5. Run tests
64
+
65
+ ```bash
66
+ pytest test_grader.py -v
67
+ ```
68
+
69
+ ## Docker
70
+
71
+ ```bash
72
+ docker build -t phishguard-env .
73
+ docker run -p 7860:7860 -e HF_TOKEN=hf_... phishguard-env
74
+ ```
75
+
76
+ ## API Reference
77
+
78
+ ### `POST /reset`
79
+ Start a new episode at the chosen difficulty level. The request body is **optional** β€” if omitted, defaults to `"easy"`.
80
+
81
+ ```json
82
+ { "level": "easy" }
83
+ ```
84
+
85
+ ### `POST /step`
86
+ Submit one triage action.
87
+
88
+ ```json
89
+ { "action": "BLOCK_DOMAIN", "reasoning": "Domain registered 3 days ago with SPF fail." }
90
+ ```
91
+
92
+ ### `GET /state`
93
+ Read-only snapshot of current environment state.
94
+
95
+ ### `GET /health`
96
+ Liveness probe for HF Spaces / load-balancers.
97
+
98
+ ## Environment Variables
99
+
100
+ | Variable | Default | Description |
101
+ |-------------------|--------------------------------------|--------------------------------|
102
+ | `HF_TOKEN` | β€” | Hugging Face API key |
103
+ | `OPENAI_API_KEY` | β€” | OpenAI-compatible API key |
104
+ | `API_BASE_URL` | `https://router.huggingface.co/v1` | LLM API base URL |
105
+ | `MODEL_NAME` | `Qwen/Qwen2.5-72B-Instruct` | Model to use for inference |
106
+ | `ENV_BASE_URL` | `http://localhost:7860` | Environment server URL |
env.py ADDED
@@ -0,0 +1,739 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ env.py – PhishGuard-Env | FastAPI Environment Server
3
+ =======================================================
4
+
5
+ ARCHITECTURE ROLE
6
+ -----------------
7
+ This file IS the environment. It runs as a persistent FastAPI server on
8
+ Hugging Face Spaces (port 7860). The inference agent (inference.py) is a
9
+ separate process that interacts with it exclusively through HTTP β€” it NEVER
10
+ imports this module directly.
11
+
12
+ Endpoints
13
+ ---------
14
+ POST /reset β†’ reset for a chosen difficulty level; returns first email observation
15
+ POST /step β†’ submit one triage action; returns (obs, reward, done, info)
16
+ GET /state β†’ read-only snapshot of health, score, task index
17
+ GET /health β†’ liveness probe for HF Spaces / load-balancers
18
+
19
+ LEVEL DESIGN
20
+ ------------
21
+ easy β†’ lv1, lv2, lv3 (3 tasks)
22
+ medium β†’ lv4, lv5, lv6, lv7 (4 tasks)
23
+ hard β†’ lv8, lv9, lv10 (3 tasks)
24
+
25
+ State variables
26
+ ---------------
27
+ current_task_idx : int – index into the active scenario list
28
+ health : int – lives remaining (starts at 3)
29
+ score : float – cumulative reward for this episode
30
+ task_scores : list – per-step reward history
31
+
32
+ Reward contract
33
+ ---------------
34
+ All rewards are sourced from grader.py and strictly in the open interval
35
+ (0.0, 1.0). No endpoint ever returns 0 or 1.
36
+
37
+ Health drain
38
+ ------------
39
+ HEALTH_DRAIN_THRESHOLD = 0.15 (imported from grader).
40
+ Any step reward below this threshold costs one life.
41
+ Security Breach (0.02) β†’ –1 life
42
+ Business Disruption (0.05) β†’ –1 life
43
+ Wrong Procedure (0.10) β†’ –1 life
44
+ Cautious / partial (β‰₯ 0.35) β†’ no life loss
45
+
46
+ Thread safety
47
+ -------------
48
+ _env is a singleton shared across all FastAPI requests. The asyncio.Lock
49
+ `_env_lock` serialises access to _env so concurrent /step or /reset calls
50
+ cannot race on current_task_idx, health, score, or task_scores.
51
+
52
+ OpenEnv validator compliance
53
+ -----------------------------
54
+ Every normal /step response includes:
55
+ task_id : str – the scenario id (e.g. "lv3") so the validator can
56
+ correlate decisions to tasks.
57
+ is_correct : bool – True when reward >= R_PERFECT, so the validator
58
+ can count "tasks with graders" (β‰₯ 3 required).
59
+
60
+ BUG FIXES (v1.0.2 β†’ v1.0.3)
61
+ -----------------------------
62
+ β€’ Thread-safety: asyncio.Lock added around all _env mutations.
63
+ β€’ total_tasks in /reset now correctly reports len(LEVEL_MAP[level])
64
+ (the count for the chosen level only) instead of len(_env.scenarios)
65
+ which could include stale data from a previous reset.
66
+ """
67
+
68
+ from __future__ import annotations
69
+
70
+ import asyncio
71
+ import logging
72
+ import random
73
+ from contextlib import asynccontextmanager
74
+ from typing import List, Optional
75
+
76
+ from fastapi import FastAPI, HTTPException
77
+ from fastapi.middleware.cors import CORSMiddleware
78
+
79
+ from models import PhishAction, ResetRequest, StepResponse, ResetResponse
80
+
81
+ # ── Logging ───────────────────────────────────────────────────────────────────
82
+ logging.basicConfig(
83
+ level=logging.INFO,
84
+ format="%(asctime)s | %(levelname)s | %(message)s",
85
+ datefmt="%H:%M:%S",
86
+ )
87
+ log = logging.getLogger("phishguard.env")
88
+
89
+ # ── OpenEnv base-class (graceful degradation) ─────────────────────────────────
90
+ try:
91
+ from openenv import OpenEnv
92
+ except ImportError:
93
+ try:
94
+ from openenv.core import OpenEnv # type: ignore
95
+ except ImportError:
96
+ OpenEnv = object # Runs as a plain Python class if openenv is absent
97
+
98
+ # ── Grader imports ────────────────────────────────────────────────────────────
99
+ from grader import (
100
+ R_BREACH,
101
+ R_PERFECT,
102
+ HEALTH_DRAIN_THRESHOLD,
103
+ calculate_overall_score,
104
+ grade_action,
105
+ grade_performance,
106
+ GRADERS,
107
+ SCENARIO_LOADERS,
108
+ )
109
+
110
+ VERSION = "1.0.3"
111
+
112
+ # ═════════════════════════════════════════════════════════════════════════════
113
+ # SCENARIO DEFINITIONS (lv1 β†’ lv10)
114
+ # ═════════════════════════════════════════════════════════════════════════════
115
+
116
+ SCENARIOS: List[dict] = [
117
+ # ── EASY (lv1–lv3) ───────────────────────────────────────────────────────
118
+ {
119
+ "id": "lv1",
120
+ "level": "easy",
121
+ "type": "SPAM",
122
+ "data": {
123
+ "sender": "win@lotto.net",
124
+ "subject": "Claim $1M Prize – Congratulations!",
125
+ "body": (
126
+ "You have been selected. "
127
+ "Click now to claim your prize before it expires."
128
+ ),
129
+ "links": ["http://bit.ly/123abc"],
130
+ "has_attachments": False,
131
+ "spf_record": "fail",
132
+ "dmarc_record": "none",
133
+ "urgency_level": "high",
134
+ "confidence_hint": "SIEM: no prior communication history with this domain",
135
+ },
136
+ "correct": "MOVE_TO_SPAM",
137
+ },
138
+ {
139
+ "id": "lv2",
140
+ "level": "easy",
141
+ "type": "PHISH",
142
+ "data": {
143
+ "sender": "support@googIe.com",
144
+ "subject": "Urgent Security Alert – Verify Your Account",
145
+ "body": (
146
+ "We detected suspicious activity on your account. "
147
+ "Verify your identity immediately."
148
+ ),
149
+ "links": ["http://googIe-verify.com/login"],
150
+ "has_attachments": False,
151
+ "spf_record": "softfail",
152
+ "dmarc_record": "fail",
153
+ "urgency_level": "critical",
154
+ "confidence_hint": "Gateway: domain registered 3 days ago, 0 prior sends",
155
+ },
156
+ "correct": "BLOCK_DOMAIN",
157
+ },
158
+ {
159
+ "id": "lv3",
160
+ "level": "easy",
161
+ "type": "SAFE",
162
+ "data": {
163
+ "sender": "boss@company.com",
164
+ "subject": "Team Meeting Tomorrow at 9 AM",
165
+ "body": (
166
+ "Hi team, please be ready for our weekly sync at 9 AM "
167
+ "in the main boardroom."
168
+ ),
169
+ "links": [],
170
+ "has_attachments": False,
171
+ "spf_record": "pass",
172
+ "dmarc_record": "pass",
173
+ "urgency_level": "low",
174
+ "confidence_hint": "SIEM: sender in address book for 2+ years, 200+ prior emails",
175
+ },
176
+ "correct": "MARK_SAFE",
177
+ },
178
+
179
+ # ── MEDIUM (lv4–lv7) ─────────────────────────────────────────────────────
180
+ {
181
+ "id": "lv4",
182
+ "level": "medium",
183
+ "type": "MALWARE",
184
+ "data": {
185
+ "sender": "it@internal-help.com",
186
+ "subject": "Critical Security Patch – Action Required Immediately",
187
+ "body": (
188
+ "A critical vulnerability has been discovered. "
189
+ "Run fix.exe to patch your system now."
190
+ ),
191
+ "links": ["http://internal-help.com/fix.exe"],
192
+ "has_attachments": True,
193
+ "spf_record": "fail",
194
+ "dmarc_record": "none",
195
+ "urgency_level": "critical",
196
+ "confidence_hint": "AV: attachment flagged as suspicious PE binary (confidence 87%)",
197
+ },
198
+ "correct": "QUARANTINE",
199
+ },
200
+ {
201
+ "id": "lv5",
202
+ "level": "medium",
203
+ "type": "SAFE",
204
+ "data": {
205
+ "sender": "hr@yourcompany.com",
206
+ "subject": "Updated Holiday Schedule – Please Review",
207
+ "body": (
208
+ "Hi all, the updated holiday list is now live on the HR portal. "
209
+ "No action needed."
210
+ ),
211
+ "links": ["https://portal.yourcompany.com/holidays"],
212
+ "has_attachments": False,
213
+ "spf_record": "pass",
214
+ "dmarc_record": "pass",
215
+ "urgency_level": "low",
216
+ "confidence_hint": (
217
+ "SIEM: bulk-sent to all-staff distribution list, "
218
+ "normal recurring pattern"
219
+ ),
220
+ },
221
+ "correct": "MARK_SAFE",
222
+ },
223
+ {
224
+ "id": "lv6",
225
+ "level": "medium",
226
+ "type": "BEC",
227
+ "data": {
228
+ "sender": "ceo@executive-mail.com",
229
+ "subject": "URGENT – Wire Transfer Required Today",
230
+ "body": (
231
+ "I'm in a meeting and cannot be reached by phone. "
232
+ "Wire $5,000 to the account below immediately."
233
+ ),
234
+ "links": [],
235
+ "has_attachments": False,
236
+ "spf_record": "pass",
237
+ "dmarc_record": "none",
238
+ "urgency_level": "critical",
239
+ "confidence_hint": "SIEM: first-ever email from this domain to any company inbox",
240
+ },
241
+ "correct": "QUARANTINE",
242
+ },
243
+ {
244
+ "id": "lv7",
245
+ "level": "medium",
246
+ "type": "PHISH",
247
+ "data": {
248
+ "sender": "notify@docs-share.net",
249
+ "subject": "You Have a New Shared Document",
250
+ "body": (
251
+ "Your colleague shared a file with you. "
252
+ "Click the link below to view it in your browser."
253
+ ),
254
+ "links": ["https://docs-verify.net/s/1x9az"],
255
+ "has_attachments": False,
256
+ "spf_record": "softfail",
257
+ "dmarc_record": "fail",
258
+ "urgency_level": "medium",
259
+ "confidence_hint": (
260
+ "Gateway: destination link redirects through 2 URL shorteners "
261
+ "before landing"
262
+ ),
263
+ },
264
+ "correct": "BLOCK_DOMAIN",
265
+ },
266
+
267
+ # ── HARD (lv8–lv10) ──────────────────────────────────────────────────────
268
+ {
269
+ "id": "lv8",
270
+ "level": "hard",
271
+ "type": "MALWARE",
272
+ "data": {
273
+ "sender": "payroll@hr-dept.net",
274
+ "subject": "Your Bank Details Form – Action Required",
275
+ "body": (
276
+ "Please review and complete the attached payroll update form "
277
+ "before Friday."
278
+ ),
279
+ "links": [],
280
+ "has_attachments": True,
281
+ "spf_record": "fail",
282
+ "dmarc_record": "fail",
283
+ "urgency_level": "high",
284
+ "confidence_hint": (
285
+ "AV: attachment is a macro-enabled Excel file (.xlsm), "
286
+ "macros unsigned"
287
+ ),
288
+ },
289
+ "correct": "QUARANTINE",
290
+ },
291
+ {
292
+ "id": "lv9",
293
+ "level": "hard",
294
+ "type": "PHISH",
295
+ "data": {
296
+ "sender": "mfa@office365-auth.com",
297
+ "subject": "Action Required: Update Your MFA Settings Now",
298
+ "body": (
299
+ "Your multi-factor authentication settings must be updated. "
300
+ "Scan the QR code in the attached image to stay compliant."
301
+ ),
302
+ "links": [],
303
+ "has_attachments": True,
304
+ "spf_record": "softfail",
305
+ "dmarc_record": "none",
306
+ "urgency_level": "critical",
307
+ "confidence_hint": "Gateway: QR code URL resolves to known credential-harvesting page",
308
+ },
309
+ "correct": "QUARANTINE",
310
+ },
311
+ {
312
+ "id": "lv10",
313
+ "level": "hard",
314
+ "type": "BEC",
315
+ "data": {
316
+ "sender": "partner@trusted-firm.com",
317
+ "subject": "Updated Project Specifications – Download Required by EOD",
318
+ "body": (
319
+ "Please find the revised project specs at the link below. "
320
+ "Deadline is tomorrow morning."
321
+ ),
322
+ "links": ["https://trusted-partner.com/files/project_specs_final.zip"],
323
+ "has_attachments": False,
324
+ "spf_record": "pass",
325
+ "dmarc_record": "pass",
326
+ "urgency_level": "high",
327
+ "confidence_hint": (
328
+ "Threat Intel: trusted-firm.com added to IOC feed 6 hours ago "
329
+ "β€” possible domain compromise"
330
+ ),
331
+ },
332
+ "correct": "BLOCK_DOMAIN",
333
+ },
334
+ ]
335
+
336
+ # ── Level β†’ scenario IDs mapping ─────────────────────────────────────────────
337
+ LEVEL_MAP: dict[str, list[str]] = {
338
+ "easy": ["lv1", "lv2", "lv3"],
339
+ "medium": ["lv4", "lv5", "lv6", "lv7"],
340
+ "hard": ["lv8", "lv9", "lv10"],
341
+ }
342
+
343
+ _SCENARIO_BY_ID: dict[str, dict] = {s["id"]: s for s in SCENARIOS}
344
+
345
+
346
+ # ═════════════════════════════════════════════════════════════════════════════
347
+ # ENVIRONMENT CLASS
348
+ # ═════════════════════════════════════════════════════════════════════════════
349
+
350
+ class PhishGuardEnv(OpenEnv):
351
+ """
352
+ OpenEnv-compliant simulation environment for SOC analyst LLM benchmarking.
353
+
354
+ State
355
+ -----
356
+ current_task_idx : int – pointer into the active (shuffled) scenario list
357
+ health : int – lives remaining (3 β†’ 0)
358
+ score : float – cumulative reward for this episode
359
+ task_scores : list – per-step reward history
360
+ active_level : str – current difficulty level
361
+ scenarios : list – scenarios loaded for the current level
362
+ """
363
+
364
+ MAX_HEALTH: int = 3
365
+
366
+ def __init__(self) -> None:
367
+ self.scenarios: List[dict] = []
368
+ self.current_task_idx: int = 0
369
+ self.health: int = self.MAX_HEALTH
370
+ self.score: float = 0.0
371
+ self.task_scores: List[float] = []
372
+ self.active_level: str = "easy"
373
+ # Initialise with easy level so the env is never empty on startup.
374
+ self._load_level("easy")
375
+
376
+ # ── Internal helpers ──────────────────────────────────────────────────────
377
+
378
+ def _load_level(self, level: str) -> None:
379
+ """
380
+ Filter and shuffle scenarios for the given difficulty level.
381
+ Resets all state counters.
382
+
383
+ Uses SCENARIO_LOADERS from grader.py (mirrors Focus-AI's
384
+ TASK_LOADERS pattern) so scenario-to-level mapping is
385
+ centralised in the grader module.
386
+ """
387
+ level = level.lower()
388
+ if level not in LEVEL_MAP:
389
+ raise ValueError(
390
+ f"Unknown level '{level}'. Valid choices: easy | medium | hard"
391
+ )
392
+ # Use SCENARIO_LOADERS if available, fall back to LEVEL_MAP
393
+ if level in SCENARIO_LOADERS:
394
+ ids = SCENARIO_LOADERS[level]()
395
+ else:
396
+ ids = LEVEL_MAP[level]
397
+ subset = [dict(_SCENARIO_BY_ID[sid]) for sid in ids]
398
+ random.shuffle(subset)
399
+
400
+ self.active_level = level
401
+ self.scenarios = subset
402
+ self.current_task_idx = 0
403
+ self.health = self.MAX_HEALTH
404
+ self.score = 0.0
405
+ self.task_scores = []
406
+
407
+ def _is_over(self) -> bool:
408
+ return self.health <= 0 or self.current_task_idx >= len(self.scenarios)
409
+
410
+ # ── Public API ────────────────────────────────────────────────────────────
411
+
412
+ def reset(self, level: str = "easy") -> dict:
413
+ """
414
+ Reset the environment for a new episode at the given difficulty level.
415
+
416
+ Returns the first email observation dict.
417
+
418
+ Raises
419
+ ------
420
+ ValueError
421
+ If the level is unknown or maps to zero scenarios.
422
+ """
423
+ self._load_level(level)
424
+ if not self.scenarios:
425
+ raise ValueError(f"No scenarios found for level '{level}'")
426
+ first_task = self.scenarios[self.current_task_idx]
427
+ log.info(
428
+ "Episode reset | level=%s | first_scenario=%s | total=%d",
429
+ self.active_level,
430
+ first_task["id"],
431
+ len(self.scenarios),
432
+ )
433
+ return first_task["data"]
434
+
435
+ def step(self, action_str: str) -> tuple:
436
+ """
437
+ Advance the simulation by one triage decision.
438
+
439
+ Returns
440
+ -------
441
+ (obs, reward, done, info)
442
+ """
443
+ # ── Guard: episode already over ───────────────────────────────────────
444
+ if self._is_over():
445
+ return None, R_BREACH, True, {
446
+ "task_id": None,
447
+ "task_group": None,
448
+ "is_correct": False,
449
+ "health": self.health,
450
+ "feedback": "Episode already ended. Call /reset to start a new one.",
451
+ "score": round(self.score, 4),
452
+ "task_scores": list(self.task_scores),
453
+ }
454
+
455
+ # ── Resolve current scenario ──────────────────────────────────────────
456
+ current_task = self.scenarios[self.current_task_idx]
457
+ task_id = current_task["id"]
458
+
459
+ # ── Grade the action ──────────────────────────────────────────────────
460
+ reward, verdict_msg = grade_action(
461
+ action_str,
462
+ current_task["correct"],
463
+ current_task["type"],
464
+ )
465
+
466
+ self.score += reward
467
+ self.task_scores.append(reward)
468
+
469
+ log.info(
470
+ "Step | level=%s | task=%s | action=%s | reward=%.4f | verdict=%s",
471
+ self.active_level,
472
+ task_id,
473
+ action_str.strip().upper(),
474
+ reward,
475
+ verdict_msg,
476
+ )
477
+
478
+ # ── Health drain ──────────────────────────────────────────────────────
479
+ if reward < HEALTH_DRAIN_THRESHOLD:
480
+ self.health -= 1
481
+ feedback = (
482
+ f"⚠️ CRITICAL ERROR: {verdict_msg} "
483
+ f"| Health remaining: {self.health}/{self.MAX_HEALTH}"
484
+ )
485
+ else:
486
+ feedback = f"βœ… Analysis accepted: {verdict_msg}"
487
+
488
+ # ── Advance task pointer ──────────────────────────────────────────────
489
+ done = False
490
+ self.current_task_idx += 1
491
+
492
+ if self.health <= 0:
493
+ done = True
494
+ feedback = "❌ TERMINATED: Too many critical failures β€” health depleted."
495
+
496
+ if self.current_task_idx >= len(self.scenarios):
497
+ done = True
498
+ if self.health > 0:
499
+ feedback = (
500
+ f"πŸ† SUCCESS: All {len(self.scenarios)} "
501
+ f"{self.active_level.upper()} scenarios completed."
502
+ )
503
+
504
+ # ── Next observation ──────────────────────────────────────────────────
505
+ obs = (
506
+ self.scenarios[self.current_task_idx]["data"]
507
+ if not self._is_over()
508
+ else None
509
+ )
510
+
511
+ return obs, reward, done, {
512
+ "task_id": task_id,
513
+ "task_group": current_task["level"],
514
+ "is_correct": reward >= R_PERFECT,
515
+ "health": self.health,
516
+ "feedback": feedback,
517
+ "score": round(self.score, 4),
518
+ "task_scores": list(self.task_scores),
519
+ }
520
+
521
+
522
+ # ═════════════════════════════════════════════════════════════════════════════
523
+ # FASTAPI APPLICATION
524
+ # ═════════════════════════════════════════════════════════════════════════════
525
+
526
+ # Singleton environment instance β€” shared across all requests.
527
+ _env = PhishGuardEnv()
528
+ # BUG FIX: asyncio.Lock serialises /reset and /step so concurrent requests
529
+ # cannot race on _env's mutable state (current_task_idx, health, score, etc.).
530
+ _env_lock = asyncio.Lock()
531
+
532
+
533
+ @asynccontextmanager
534
+ async def lifespan(app: FastAPI):
535
+ log.info("PhishGuard-Env %s starting on port 7860.", VERSION)
536
+ yield
537
+ log.info("PhishGuard-Env shutting down.")
538
+
539
+
540
+ app = FastAPI(
541
+ title="PhishGuard-Env",
542
+ description=(
543
+ "OpenEnv-compliant SOC analyst simulation environment. "
544
+ "Exposes /reset, /step, /state, and /health for LLM agent benchmarking."
545
+ ),
546
+ version=VERSION,
547
+ lifespan=lifespan,
548
+ )
549
+
550
+ app.add_middleware(
551
+ CORSMiddleware,
552
+ allow_origins=["*"],
553
+ allow_methods=["GET", "POST"],
554
+ allow_headers=["*"],
555
+ )
556
+
557
+
558
+ # ── Liveness probe ────────────────────────────────────────────────────────────
559
+ @app.get("/health", tags=["Meta"])
560
+ async def health_probe() -> dict:
561
+ """Liveness probe β€” HF Spaces and load-balancers call this endpoint."""
562
+ return {"status": "ok", "env": "PhishGuard-Env", "version": VERSION}
563
+
564
+
565
+ # ── Reset ─────────────────────────────────────────────────────────────────────
566
+ @app.post("/reset", tags=["Environment"])
567
+ async def reset(request: Optional[ResetRequest] = None) -> ResetResponse:
568
+ """
569
+ Reset the environment for a new episode at the chosen difficulty level.
570
+
571
+ Body (optional): { "level": "easy" | "medium" | "hard" }
572
+ If no body is provided, defaults to "easy".
573
+ """
574
+ level = (request.level if request else "easy").lower()
575
+ if level not in LEVEL_MAP:
576
+ raise HTTPException(
577
+ status_code=422,
578
+ detail=f"Invalid level '{level}'. Must be one of: easy | medium | hard",
579
+ )
580
+
581
+ async with _env_lock:
582
+ obs = _env.reset(level=level)
583
+ first_scenario = _env.scenarios[_env.current_task_idx]
584
+ active_level = _env.active_level
585
+
586
+ return ResetResponse(
587
+ observation=obs,
588
+ task_id=first_scenario["id"],
589
+ task_group=first_scenario["level"],
590
+ level=active_level,
591
+ # BUG FIX: was len(_env.scenarios) which could be stale;
592
+ # now reads directly from LEVEL_MAP for the requested level.
593
+ total_tasks=len(LEVEL_MAP[level]),
594
+ )
595
+
596
+
597
+ # ── Step ──────────────────────────────────────────────────────────────────────
598
+ @app.post("/step", tags=["Environment"])
599
+ async def step(action: PhishAction) -> StepResponse:
600
+ """
601
+ Submit one triage action and receive the next observation + reward.
602
+
603
+ Body: { "action": "MARK_SAFE | MOVE_TO_SPAM | QUARANTINE | BLOCK_DOMAIN",
604
+ "reasoning": "optional" }
605
+ """
606
+ action_str = action.action.strip().upper()[:64]
607
+
608
+ async with _env_lock:
609
+ obs, reward, done, info = _env.step(action_str)
610
+
611
+ return StepResponse(
612
+ observation=obs,
613
+ reward=reward,
614
+ done=done,
615
+ task_id=info["task_id"],
616
+ is_correct=info["is_correct"],
617
+ info=info,
618
+ )
619
+
620
+
621
+ # ── State ────────────────────────────���────────────────────────────────────────
622
+ @app.get("/state", tags=["Environment"])
623
+ async def state() -> dict:
624
+ """Read-only snapshot of the current environment state."""
625
+ async with _env_lock:
626
+ overall = calculate_overall_score(_env.task_scores)
627
+ return {
628
+ "level": _env.active_level,
629
+ "health": _env.health,
630
+ "score": round(_env.score, 4),
631
+ "overall_score": overall,
632
+ "task_index": _env.current_task_idx,
633
+ "total_tasks": len(_env.scenarios),
634
+ "task_scores": list(_env.task_scores),
635
+ }
636
+
637
+
638
+ # ── Tasks ─────────────────────────────────────────────────────────────────────
639
+ @app.get("/tasks", tags=["Environment"])
640
+ async def tasks() -> dict:
641
+ """List all tasks with their IDs, difficulty, and correct actions."""
642
+ return {
643
+ "tasks": [
644
+ {
645
+ "task_id": s["id"],
646
+ "difficulty": s["level"],
647
+ "type": s["type"],
648
+ "correct": s["correct"],
649
+ }
650
+ for s in SCENARIOS
651
+ ]
652
+ }
653
+
654
+
655
+ # ── Grader ────────────────────────────────────────────────────────────────────
656
+ @app.post("/grader", tags=["Environment"])
657
+ async def grader(request: dict) -> dict:
658
+ """
659
+ Grade a triage action for a specific task without running a full episode.
660
+ The OpenEnv validator calls this endpoint to verify graders are working.
661
+
662
+ Body: { "task_id": "lv1", "action": "MOVE_TO_SPAM" }
663
+ """
664
+ task_id = request.get("task_id", "lv1")
665
+ action = request.get("action", "QUARANTINE")
666
+
667
+ scenario = next((s for s in SCENARIOS if s["id"] == task_id), None)
668
+ if scenario is None:
669
+ raise HTTPException(
670
+ status_code=404,
671
+ detail=f"Task '{task_id}' not found. Valid IDs: {[s['id'] for s in SCENARIOS]}",
672
+ )
673
+
674
+ reward, message = grade_action(action, scenario["correct"], scenario["type"])
675
+
676
+ return {
677
+ "task_id": task_id,
678
+ "action": action,
679
+ "reward": reward,
680
+ "is_correct": reward >= R_PERFECT,
681
+ "message": message,
682
+ }
683
+
684
+
685
+ # ── Grade by Difficulty ───────────────────────────────────────────────────────
686
+ # Mirrors Focus-AI's GRADERS dict pattern β€” allows grading an entire
687
+ # difficulty level by passing metrics, just like Focus-AI's env.py uses
688
+ # GRADERS[difficulty](metrics) at episode end.
689
+ @app.post("/grade/{difficulty}", tags=["Grading"])
690
+ async def grade_difficulty(difficulty: str, metrics: dict) -> dict:
691
+ """
692
+ Grade a full episode for a specific difficulty level using the
693
+ deterministic grader function.
694
+
695
+ This mirrors Focus-AI's GRADERS[difficulty](metrics) pattern.
696
+
697
+ Path param: difficulty = easy | medium | hard
698
+ Body: metrics dict (e.g. {"total_tasks": 3, "correct_actions": 2, ...})
699
+ """
700
+ difficulty = difficulty.lower()
701
+ if difficulty not in GRADERS:
702
+ raise HTTPException(
703
+ status_code=422,
704
+ detail=f"Invalid difficulty '{difficulty}'. Must be one of: {list(GRADERS.keys())}",
705
+ )
706
+
707
+ score = GRADERS[difficulty](metrics)
708
+ return {
709
+ "difficulty": difficulty,
710
+ "score": score,
711
+ "metrics": metrics,
712
+ }
713
+
714
+
715
+ # ── Aggregate Performance Grade ──────────────────────────────────────────────
716
+ @app.post("/grade/performance", tags=["Grading"])
717
+ async def grade_perf(metrics: dict) -> dict:
718
+ """
719
+ Cross-difficulty aggregate grader for leaderboard ranking.
720
+ Mirrors Focus-AI's grade_performance() function.
721
+ """
722
+ score = grade_performance(metrics)
723
+ return {
724
+ "difficulty": "aggregate",
725
+ "score": score,
726
+ "metrics": metrics,
727
+ }
728
+
729
+
730
+ # ── Entry point ───────────────────────────────────────────────────────────────
731
+ if __name__ == "__main__":
732
+ import uvicorn
733
+ uvicorn.run(
734
+ "server.app:app",
735
+ host="0.0.0.0",
736
+ port=7860,
737
+ reload=False,
738
+ log_level="info",
739
+ )
grader.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ grader.py – PhishGuard-Env SOC Triage Scoring Logic
3
+ ====================================================
4
+
5
+ REWARD CONTRACT β†’ OPEN INTERVAL (0.0, 1.0)
6
+ ---------------------------------------------
7
+ All rewards are STRICTLY greater than 0 and STRICTLY less than 1.
8
+ The endpoints 0 and 1 are NEVER returned. This is a hard invariant
9
+ enforced by the constant table below and by calculate_overall_score().
10
+
11
+ Why open-interval?
12
+ β€’ 1.0 saturates the leaderboard and implies a theoretically perfect agent.
13
+ β€’ 0.0 is indistinguishable from a missing data-point in an RL pipeline.
14
+ β€’ Every decision carries a non-zero gradient signal so training never dies.
15
+
16
+ REWARD TABLE
17
+ ────────────────────────────────────────────────────────────────────────────
18
+ Constant Value Outcome / Rationale
19
+ ─────────────────────────────────────────────────────────────────────────
20
+ R_PERFECT 0.95 Exact match β€” near-ideal; headroom for 1.0
21
+ R_MALWARE_QUARANTINE 0.75 MALWARE β†’ QUARANTINE (textbook isolation)
22
+ R_PHISH_BEC_QUARANTINE 0.60 PHISH/BEC β†’ QUARANTINE (domain still live)
23
+ R_SPAM_BLOCK 0.40 SPAM β†’ BLOCK_DOMAIN (over-escalation)
24
+ R_SPAM_QUARANTINE 0.35 SPAM β†’ QUARANTINE (lighter over-escalation)
25
+ R_WRONG_PROCEDURE 0.10 Wrong action, no direct security/ops harm
26
+ R_DISRUPTION 0.05 SAFE email blocked β€” operational cost
27
+ R_BREACH 0.02 Threat allowed into inbox β€” catastrophic
28
+
29
+ HEALTH-DRAIN THRESHOLD
30
+ ────────────────────────────────────────────────────────────────────────────
31
+ HEALTH_DRAIN_THRESHOLD = 0.15
32
+ reward < 0.15 β†’ agent loses one life.
33
+
34
+ PASS_THRESHOLD
35
+ ────────────────────────────────────────────────────────────────────────────
36
+ PASS_THRESHOLD = 0.50
37
+
38
+ LEVEL CONTEXT (from env.py)
39
+ ────────────────────────────────────────────────────────────────────────────
40
+ easy β†’ lv1 (SPAM), lv2 (PHISH), lv3 (SAFE)
41
+ medium β†’ lv4 (MALWARE), lv5 (SAFE), lv6 (BEC), lv7 (PHISH)
42
+ hard β†’ lv8 (MALWARE), lv9 (PHISH), lv10 (BEC)
43
+
44
+ VALID AGENT ACTIONS
45
+ ────────────────────────────────────────────────────────────────────────────
46
+ MARK_SAFE – deliver to inbox
47
+ MOVE_TO_SPAM – bulk / unsolicited mail
48
+ QUARANTINE – hold for analyst review
49
+ BLOCK_DOMAIN – perimeter block
50
+
51
+ BUG FIX (v1.0.2 β†’ v1.0.3)
52
+ ────────────────────────────────────────────────────────────────────────────
53
+ SPAM added to _THREAT_TYPES so MARK_SAFE on any threat drains health.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import logging
59
+ from typing import Tuple
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+ __all__ = [
64
+ "R_PERFECT",
65
+ "R_MALWARE_QUARANTINE",
66
+ "R_PHISH_BEC_QUARANTINE",
67
+ "R_SPAM_BLOCK",
68
+ "R_SPAM_QUARANTINE",
69
+ "R_WRONG_PROCEDURE",
70
+ "R_DISRUPTION",
71
+ "R_BREACH",
72
+ "R_PARTIAL",
73
+ "PASS_THRESHOLD",
74
+ "HEALTH_DRAIN_THRESHOLD",
75
+ "grade_action",
76
+ "grade_easy",
77
+ "grade_medium",
78
+ "grade_hard",
79
+ "grade_performance",
80
+ "calculate_overall_score",
81
+ "GRADERS",
82
+ "SCENARIO_LOADERS",
83
+ ]
84
+
85
+
86
+ # ══════════════════════════════════════════════════════════════════════════════
87
+ # REWARD CONSTANTS
88
+ # ══════════════════════════════════════════════════════════════════════════════
89
+
90
+ R_PERFECT = 0.95
91
+ R_MALWARE_QUARANTINE = 0.75
92
+ R_PHISH_BEC_QUARANTINE = 0.60
93
+ R_SPAM_BLOCK = 0.40
94
+ R_SPAM_QUARANTINE = 0.35
95
+ R_WRONG_PROCEDURE = 0.10
96
+ R_DISRUPTION = 0.05
97
+ R_BREACH = 0.02
98
+
99
+ R_PARTIAL = R_MALWARE_QUARANTINE
100
+ PASS_THRESHOLD = 0.50
101
+ HEALTH_DRAIN_THRESHOLD = 0.15
102
+
103
+ _THREAT_TYPES = frozenset({"PHISH", "BEC", "MALWARE", "SPAM"})
104
+ _BLOCKED_MOVES = frozenset({"BLOCK_DOMAIN", "QUARANTINE", "MOVE_TO_SPAM"})
105
+ _VALID_ACTIONS = frozenset({"MARK_SAFE", "MOVE_TO_SPAM", "QUARANTINE", "BLOCK_DOMAIN"})
106
+
107
+
108
+ # ══════════════════════════════════════════════════════════════════════════════
109
+ # GRADE_ACTION
110
+ # ══════════════════════════════════════════════════════════════════════════════
111
+
112
+ def grade_action(
113
+ agent_output: str,
114
+ expected_output: str,
115
+ email_type: str,
116
+ ) -> Tuple[float, str]:
117
+ """
118
+ Grade one SOC triage decision and return a reward in (0.0, 1.0).
119
+
120
+ Parameters
121
+ ----------
122
+ agent_output : Raw action string from the LLM agent (case-insensitive).
123
+ expected_output : Ground-truth correct action for this scenario.
124
+ email_type : Threat category β€” PHISH | BEC | MALWARE | SPAM | SAFE.
125
+
126
+ Returns
127
+ -------
128
+ (reward, message)
129
+ reward : float strictly in (0.0, 1.0)
130
+ message : short verdict string for logs and agent feedback.
131
+ """
132
+ agent_action = agent_output.strip().upper()
133
+ expected_action = expected_output.strip().upper()
134
+ etype = email_type.strip().upper()
135
+
136
+ # Step 1: Reject unrecognised tokens
137
+ if agent_action not in _VALID_ACTIONS:
138
+ return (
139
+ R_WRONG_PROCEDURE,
140
+ f"INVALID_ACTION: '{agent_action}' is not a recognised triage action β€” "
141
+ f"must be one of: {', '.join(sorted(_VALID_ACTIONS))}",
142
+ )
143
+
144
+ # Step 2: Perfect match
145
+ if agent_action == expected_action:
146
+ return R_PERFECT, "PERFECT_TRIAGE: Correct action taken"
147
+
148
+ # Step 3: Security Breach
149
+ if etype in _THREAT_TYPES and agent_action == "MARK_SAFE":
150
+ return (
151
+ R_BREACH,
152
+ f"SECURITY_BREACH: {etype} threat delivered to inbox unimpeded β€” "
153
+ "catastrophic failure; source remains active",
154
+ )
155
+
156
+ # Step 4: Business Disruption
157
+ if etype == "SAFE" and agent_action in _BLOCKED_MOVES:
158
+ return (
159
+ R_DISRUPTION,
160
+ "BUSINESS_DISRUPTION: Legitimate communication was incorrectly blocked β€” "
161
+ "operational impact; sender trust degraded",
162
+ )
163
+
164
+ # Step 5: Partial credit
165
+
166
+ # MALWARE β†’ QUARANTINE
167
+ if etype == "MALWARE" and agent_action == "QUARANTINE":
168
+ return (
169
+ R_MALWARE_QUARANTINE,
170
+ "CAUTIOUS: Malware isolated via QUARANTINE β€” strong containment; "
171
+ "no further propagation risk detected",
172
+ )
173
+
174
+ # PHISH/BEC β†’ QUARANTINE
175
+ if etype in {"PHISH", "BEC"} and agent_action == "QUARANTINE":
176
+ return (
177
+ R_PHISH_BEC_QUARANTINE,
178
+ f"UNDER_RESPONSE: {etype} quarantined but source domain still active β€” "
179
+ "prefer BLOCK_DOMAIN to prevent further phishing delivery attempts",
180
+ )
181
+
182
+ # SPAM β†’ BLOCK_DOMAIN
183
+ if etype == "SPAM" and agent_action == "BLOCK_DOMAIN":
184
+ return (
185
+ R_SPAM_BLOCK,
186
+ "OVER_ESCALATION: BLOCK_DOMAIN is disproportionate for SPAM β€” "
187
+ "prefer MOVE_TO_SPAM to preserve block-list resources",
188
+ )
189
+
190
+ # SPAM β†’ QUARANTINE
191
+ if etype == "SPAM" and agent_action == "QUARANTINE":
192
+ return (
193
+ R_SPAM_QUARANTINE,
194
+ "OVER_ESCALATION: QUARANTINE wastes analyst capacity on SPAM β€” "
195
+ "prefer MOVE_TO_SPAM for bulk unsolicited mail",
196
+ )
197
+
198
+ # Step 6: Catch-all wrong procedure
199
+ return (
200
+ R_WRONG_PROCEDURE,
201
+ f"INCORRECT_PROCEDURE: '{agent_action}' does not match security policy "
202
+ f"for {etype} email (expected: {expected_action}) β€” "
203
+ "review triage guidelines",
204
+ )
205
+
206
+
207
+ # ══════════════════════════════════════════════════════════════════════════════
208
+ # CALCULATE_OVERALL_SCORE
209
+ # ══════════════════════════════════════════════════════════════════════════════
210
+
211
+ def calculate_overall_score(task_scores: list) -> float:
212
+ """
213
+ Compute the final benchmark score from a list of per-step rewards.
214
+ Result is clamped to [R_BREACH, R_PERFECT].
215
+ Empty list returns R_BREACH.
216
+ """
217
+ if not task_scores:
218
+ return R_BREACH
219
+
220
+ raw_avg = sum(task_scores) / len(task_scores)
221
+ clamped = max(R_BREACH, min(R_PERFECT, raw_avg))
222
+ return round(clamped, 4)
223
+
224
+
225
+ # ══════════════════════════════════════════════════════════════════════════════
226
+ # SCORE SAFETY HELPERS
227
+ # ══════════════════════════════════════════════════════════════════════════════
228
+
229
+ def _safe_score(raw: float) -> float:
230
+ """
231
+ Map any float to the open interval (R_BREACH, R_PERFECT).
232
+
233
+ Mirrors Focus-AI's safe_score() pattern:
234
+ safe_score(raw) = LOWER + (UPPER - LOWER) * clamp(raw, 0, 1)
235
+ where LOWER = R_BREACH (0.02), UPPER = R_PERFECT (0.95).
236
+
237
+ Guarantees:
238
+ raw = 0.0 -> 0.02 (> 0, never equals 0)
239
+ raw = 1.0 -> 0.95 (< 1, never equals 1)
240
+ """
241
+ raw = float(raw)
242
+ if raw < 0.0:
243
+ raw = 0.0
244
+ elif raw > 1.0:
245
+ raw = 1.0
246
+ result = R_BREACH + (R_PERFECT - R_BREACH) * raw
247
+ result = round(result, 6)
248
+ assert 0.0 < result < 1.0, (
249
+ f"_safe_score VIOLATION: raw={raw!r} produced result={result!r} "
250
+ f"which is not strictly inside (0, 1)"
251
+ )
252
+ return result
253
+
254
+
255
+ def _safe_ratio(num: float, den: float) -> float:
256
+ """Safe division clamped to [0, 1]."""
257
+ if den <= 0:
258
+ return 0.0
259
+ return max(0.0, min(1.0, num / den))
260
+
261
+
262
+ # ══════════════════════════════════════════════════════════════════════════════
263
+ # OPENENV GRADERS
264
+ # Called by the OpenEnv validator β€” one function per difficulty level.
265
+ # Signature: grade_X(metrics: dict) -> float strictly in (0, 1)
266
+ # ══════════════════════════════════════════════════════════════════════════════
267
+
268
+ def grade_easy(metrics: dict) -> float:
269
+ """
270
+ Grader for easy tasks (lv1-lv3): SPAM, PHISH, SAFE.
271
+ Scoring: 60% correct action + 40% threat identification accuracy.
272
+ """
273
+ total = max(1, metrics.get("total_tasks", metrics.get("total", 3)))
274
+ correct = metrics.get("correct_actions", metrics.get("is_correct", 0))
275
+ on_time = metrics.get("on_time", metrics.get("completed", correct))
276
+
277
+ if isinstance(correct, bool):
278
+ correct = int(correct)
279
+ if isinstance(on_time, bool):
280
+ on_time = int(on_time)
281
+
282
+ raw = (
283
+ 0.60 * _safe_ratio(correct, total)
284
+ + 0.40 * _safe_ratio(on_time, total)
285
+ )
286
+ return _safe_score(raw)
287
+
288
+
289
+ def grade_medium(metrics: dict) -> float:
290
+ """
291
+ Grader for medium tasks (lv4-lv7): MALWARE, SAFE HR, BEC, PHISH.
292
+ Scoring: 40% correct + 35% on-time detection + 25% escalation quality.
293
+ """
294
+ total = max(1, metrics.get("total_tasks", metrics.get("total", 4)))
295
+ correct = metrics.get("correct_actions", metrics.get("is_correct", 0))
296
+ on_time = metrics.get("on_time", metrics.get("completed", correct))
297
+ steps = max(1, metrics.get("total_steps", metrics.get("steps", 4)))
298
+ good_esc = metrics.get("good_escalation", metrics.get("reward", correct))
299
+
300
+ if isinstance(correct, bool): correct = int(correct)
301
+ if isinstance(on_time, bool): on_time = int(on_time)
302
+ if isinstance(good_esc, bool): good_esc = int(good_esc)
303
+
304
+ raw = (
305
+ 0.40 * _safe_ratio(correct, total)
306
+ + 0.35 * _safe_ratio(on_time, total)
307
+ + 0.25 * _safe_ratio(good_esc, steps)
308
+ )
309
+ return _safe_score(raw)
310
+
311
+
312
+ def grade_hard(metrics: dict) -> float:
313
+ """
314
+ Grader for hard tasks (lv8-lv10): MALWARE macro, QR phishing, BEC domain.
315
+ Scoring: 35% correct + 30% threat accuracy + 20% escalation + 15% priority.
316
+ """
317
+ total = max(1, metrics.get("total_tasks", metrics.get("total", 3)))
318
+ correct = metrics.get("correct_actions", metrics.get("is_correct", 0))
319
+ on_time = metrics.get("on_time", metrics.get("completed", correct))
320
+ steps = max(1, metrics.get("total_steps", metrics.get("steps", 3)))
321
+ good_esc = metrics.get("good_escalation", metrics.get("reward", correct))
322
+ hi_pri = metrics.get("high_priority_correct", correct)
323
+
324
+ if isinstance(correct, bool): correct = int(correct)
325
+ if isinstance(on_time, bool): on_time = int(on_time)
326
+ if isinstance(good_esc, bool): good_esc = int(good_esc)
327
+ if isinstance(hi_pri, bool): hi_pri = int(hi_pri)
328
+
329
+ raw = (
330
+ 0.35 * _safe_ratio(correct, total)
331
+ + 0.30 * _safe_ratio(on_time, total)
332
+ + 0.20 * _safe_ratio(good_esc, steps)
333
+ + 0.15 * _safe_ratio(hi_pri, max(1, correct))
334
+ )
335
+ return _safe_score(raw)
336
+
337
+
338
+ def grade_performance(metrics: dict) -> float:
339
+ """
340
+ Aggregate grader used for cross-difficulty scoring.
341
+
342
+ Mirrors Focus-AI's grade_performance() pattern β€” provides a single
343
+ unified score across all difficulty levels for leaderboard ranking.
344
+
345
+ Scoring: 40% correct actions + 30% on-time + 20% escalation + 10% priority.
346
+ """
347
+ total = max(1, metrics.get("total_tasks", metrics.get("total", 1)))
348
+ correct = metrics.get("correct_actions", metrics.get("is_correct", 0))
349
+ on_time = metrics.get("on_time", metrics.get("completed", correct))
350
+ steps = max(1, metrics.get("total_steps", metrics.get("steps", 1)))
351
+ good_esc = metrics.get("good_escalation", metrics.get("reward", correct))
352
+
353
+ if isinstance(correct, bool): correct = int(correct)
354
+ if isinstance(on_time, bool): on_time = int(on_time)
355
+ if isinstance(good_esc, bool): good_esc = int(good_esc)
356
+
357
+ raw = (
358
+ 0.40 * _safe_ratio(correct, total)
359
+ + 0.30 * _safe_ratio(on_time, total)
360
+ + 0.20 * _safe_ratio(good_esc, steps)
361
+ + 0.10 * _safe_ratio(correct, steps)
362
+ )
363
+ return _safe_score(raw)
364
+
365
+
366
+ # ══════════════════════════════════════════════════════════════════════════════
367
+ # GRADERS DICT (mirrors Focus-AI's GRADERS pattern)
368
+ # Maps difficulty level β†’ grader function for easy programmatic lookup.
369
+ # ══════════════════════════════════════════════════════════════════════════════
370
+
371
+ GRADERS = {
372
+ "easy": grade_easy,
373
+ "medium": grade_medium,
374
+ "hard": grade_hard,
375
+ }
376
+
377
+
378
+ # ══════════════════════════════════════════════════════════════════════════════
379
+ # SCENARIO LOADERS (mirrors Focus-AI's TASK_LOADERS pattern)
380
+ # Maps difficulty level β†’ callable that returns the scenario list for that level.
381
+ # Used by env.py to load scenarios without hard-coding level names.
382
+ # ══════════════════════════════════════════════════════════════════════════════
383
+
384
+ def _get_easy_scenarios() -> list:
385
+ """Return scenario IDs for easy difficulty."""
386
+ return ["lv1", "lv2", "lv3"]
387
+
388
+
389
+ def _get_medium_scenarios() -> list:
390
+ """Return scenario IDs for medium difficulty."""
391
+ return ["lv4", "lv5", "lv6", "lv7"]
392
+
393
+
394
+ def _get_hard_scenarios() -> list:
395
+ """Return scenario IDs for hard difficulty."""
396
+ return ["lv8", "lv9", "lv10"]
397
+
398
+
399
+ SCENARIO_LOADERS = {
400
+ "easy": _get_easy_scenarios,
401
+ "medium": _get_medium_scenarios,
402
+ "hard": _get_hard_scenarios,
403
+ }
inference.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference.py – PhishGuard-Env Baseline Inference Script
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sys
11
+ import textwrap
12
+ import time
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ import requests
17
+ from openai import OpenAI
18
+
19
+ from grader import PASS_THRESHOLD
20
+
21
+ # ─────────────────────────────────────────────────────────────────────────────
22
+ # Configuration
23
+ # ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY")
26
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
27
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
28
+ ENV_BASE_URL = os.getenv("ENV_BASE_URL", "http://localhost:7860").rstrip("/")
29
+
30
+ MAX_STEPS_PER_LEVEL = 15
31
+ HTTP_MAX_RETRIES = 3
32
+ HTTP_BACKOFF_BASE = 1.5
33
+
34
+ # ─────────────────────────────────────────────────────────────────────────────
35
+ # LLM client
36
+ # ─────────────────────────────────────────────────────────────────────────────
37
+
38
+ if not API_KEY:
39
+ print("[ERROR] No API key found. Set HF_TOKEN or OPENAI_API_KEY.", flush=True)
40
+ sys.exit(1)
41
+
42
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
43
+
44
+ SYSTEM_PROMPT = textwrap.dedent("""
45
+ You are a SOC (Security Operations Centre) Analyst triaging incoming emails.
46
+
47
+ Analyse the email data provided and respond ONLY with valid JSON in this exact format:
48
+ {"action": "<ACTION>", "reasoning": "<one sentence technical justification>"}
49
+
50
+ Valid actions:
51
+ - MARK_SAFE : Deliver to inbox (confirmed legitimate email)
52
+ - MOVE_TO_SPAM : Bulk/unsolicited mail with no active threat
53
+ - QUARANTINE : Hold for analyst review (suspicious but unconfirmed)
54
+ - BLOCK_DOMAIN : Block sender domain at perimeter (confirmed phishing/malware source)
55
+
56
+ Signal interpretation:
57
+ - SPF fail + DMARC fail + urgency + links β†’ likely PHISH or MALWARE β†’ BLOCK_DOMAIN or QUARANTINE
58
+ - Known sender, SPF pass, DMARC pass, no suspicious links β†’ likely SAFE β†’ MARK_SAFE
59
+ - Bulk unsolicited with no malicious payload β†’ SPAM β†’ MOVE_TO_SPAM
60
+ - Wire transfer / CEO fraud / financial urgency from unknown domain β†’ BEC β†’ QUARANTINE
61
+ - Malware attachment confirmed by AV β†’ QUARANTINE (isolate, do not deliver)
62
+ - Confirmed phishing domain β†’ BLOCK_DOMAIN (sever attack vector)
63
+
64
+ confidence_hint field:
65
+ - This is a contextual signal from your SIEM, mail gateway, or threat-intel feed.
66
+ - It is intentionally noisy β€” treat it as one data-point, not ground truth.
67
+ - If it directly contradicts other signals (SPF, DMARC, links), weigh all evidence.
68
+ """).strip()
69
+
70
+
71
+ # ─────────────────────────────────────────────────────────────────────────────
72
+ # HTTP helpers
73
+ # ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ _session = requests.Session()
76
+
77
+
78
+ def _post(endpoint: str, payload: dict) -> dict:
79
+ url = f"{ENV_BASE_URL}{endpoint}"
80
+ last_exc: Optional[Exception] = None
81
+ for attempt in range(HTTP_MAX_RETRIES):
82
+ try:
83
+ resp = _session.post(url, json=payload, timeout=30)
84
+ resp.raise_for_status()
85
+ return resp.json()
86
+ except (requests.ConnectionError, requests.Timeout) as exc:
87
+ last_exc = exc
88
+ wait = HTTP_BACKOFF_BASE ** attempt
89
+ print(f" [WARN] POST {endpoint} failed (attempt {attempt+1}): {exc} β€” retrying in {wait:.1f}s", flush=True)
90
+ time.sleep(wait)
91
+ except requests.HTTPError as exc:
92
+ if exc.response is not None and exc.response.status_code < 500:
93
+ raise
94
+ last_exc = exc
95
+ wait = HTTP_BACKOFF_BASE ** attempt
96
+ print(f" [WARN] POST {endpoint} server error (attempt {attempt+1}): {exc} β€” retrying in {wait:.1f}s", flush=True)
97
+ time.sleep(wait)
98
+ raise RuntimeError(f"POST {endpoint} failed after {HTTP_MAX_RETRIES} attempts: {last_exc}")
99
+
100
+
101
+ def _get(endpoint: str) -> dict:
102
+ url = f"{ENV_BASE_URL}{endpoint}"
103
+ last_exc: Optional[Exception] = None
104
+ for attempt in range(HTTP_MAX_RETRIES):
105
+ try:
106
+ resp = _session.get(url, timeout=10)
107
+ resp.raise_for_status()
108
+ return resp.json()
109
+ except (requests.ConnectionError, requests.Timeout, requests.HTTPError) as exc:
110
+ last_exc = exc
111
+ wait = HTTP_BACKOFF_BASE ** attempt
112
+ print(f" [WARN] GET {endpoint} failed (attempt {attempt+1}): {exc} β€” retrying in {wait:.1f}s", flush=True)
113
+ time.sleep(wait)
114
+ raise RuntimeError(f"GET {endpoint} failed after {HTTP_MAX_RETRIES} attempts: {last_exc}")
115
+
116
+
117
+ # ─────────────────────────────────────────────────────────────────────────────
118
+ # LLM action selection
119
+ # ─────────────────────────────────────────────────────────────────────────────
120
+
121
+ def _choose_action(observation: Dict[str, Any]) -> tuple[str, str]:
122
+ try:
123
+ completion = client.chat.completions.create(
124
+ model=MODEL_NAME,
125
+ messages=[
126
+ {"role": "system", "content": SYSTEM_PROMPT},
127
+ {"role": "user", "content": json.dumps(observation, indent=2)},
128
+ ],
129
+ response_format={"type": "json_object"},
130
+ temperature=0,
131
+ max_tokens=256,
132
+ )
133
+ parsed = json.loads(completion.choices[0].message.content)
134
+ action = parsed.get("action", "QUARANTINE").strip().upper()
135
+ reasoning = parsed.get("reasoning", "")
136
+ return action, reasoning
137
+ except Exception as exc:
138
+ print(f" [WARN] LLM error: {exc} β€” defaulting to QUARANTINE", flush=True)
139
+ return "QUARANTINE", "Parse error β€” safe fallback"
140
+
141
+
142
+ # ─────────────────────────────────────────────────────────────────────────────
143
+ # Run one level
144
+ # ─────────────────────────────────────────────────────────────────────────────
145
+
146
+ def run_level(level: str) -> Dict[str, Any]:
147
+ print(f"\n{'='*60}", flush=True)
148
+ print(f" LEVEL: {level.upper()}", flush=True)
149
+ print(f"{'='*60}", flush=True)
150
+
151
+ reset_resp = _post("/reset", {"level": level})
152
+ obs = reset_resp["observation"]
153
+ total_tasks = reset_resp["total_tasks"]
154
+ print(f" Tasks in this level: {total_tasks}", flush=True)
155
+
156
+ steps: List[dict] = []
157
+ step_num = 0
158
+ done = False
159
+ step_resp: Dict[str, Any] = {}
160
+
161
+ while not done and step_num < MAX_STEPS_PER_LEVEL:
162
+ step_num += 1
163
+ task_id = reset_resp["task_id"] if step_num == 1 else step_resp.get("task_id", "?")
164
+ print(f"\n Step {step_num} | task={task_id}", flush=True)
165
+
166
+ action, reasoning = _choose_action(obs)
167
+ print(f" -> Action : {action}", flush=True)
168
+ print(f" -> Reasoning: {reasoning[:80]}", flush=True)
169
+
170
+ step_resp = _post("/step", {"action": action, "reasoning": reasoning})
171
+ reward = step_resp["reward"]
172
+ done = step_resp["done"]
173
+ is_correct = step_resp["is_correct"]
174
+ info = step_resp.get("info", {})
175
+
176
+ print(f" <- Reward : {reward:.4f} | correct={is_correct} | done={done}", flush=True)
177
+ print(f" <- Feedback : {info.get('feedback', '')[:100]}", flush=True)
178
+
179
+ # Structured [STEP] log for validator output parsing
180
+ print(json.dumps({
181
+ "[STEP]": True,
182
+ "task_id": step_resp.get("task_id", task_id),
183
+ "action": action,
184
+ "reward": reward,
185
+ "is_correct": is_correct,
186
+ }), flush=True)
187
+
188
+ steps.append({
189
+ "step": step_num,
190
+ "task_id": step_resp.get("task_id", task_id),
191
+ "action": action,
192
+ "reward": reward,
193
+ "is_correct": is_correct,
194
+ "reasoning": reasoning,
195
+ })
196
+
197
+ obs = step_resp.get("observation")
198
+ if obs is None and not done:
199
+ print(" [WARN] obs is None but done=False β€” breaking.", flush=True)
200
+ break
201
+
202
+ state = _get("/state")
203
+ overall = state.get("overall_score", 0.0)
204
+
205
+ print(f"\n {'-'*50}", flush=True)
206
+ print(f" Level {level.upper()} complete | steps={step_num} | overall_score={overall:.4f}", flush=True)
207
+
208
+ return {
209
+ "level": level,
210
+ "total_tasks": total_tasks,
211
+ "steps": steps,
212
+ "overall_score": overall,
213
+ }
214
+
215
+
216
+ # ─────────────────────────────────────────────────────────────────────────────
217
+ # Main
218
+ # ─────────────────────────────────────────────────────────────────────────────
219
+
220
+ def main() -> None:
221
+ parser = argparse.ArgumentParser(description="PhishGuard-Env Baseline Inference")
222
+ parser.add_argument(
223
+ "--level",
224
+ choices=["easy", "medium", "hard"],
225
+ default=None,
226
+ help="Run a single difficulty level instead of all three.",
227
+ )
228
+ parser.add_argument(
229
+ "--output",
230
+ default=None,
231
+ help="Path to write JSON results.",
232
+ )
233
+ args = parser.parse_args()
234
+
235
+ levels_to_run = [args.level] if args.level else ["easy", "medium", "hard"]
236
+ output_path = args.output or f"results_{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.json"
237
+
238
+ # Structured [START] log for validator
239
+ print(json.dumps({
240
+ "[START]": True,
241
+ "model": MODEL_NAME,
242
+ "env": ENV_BASE_URL,
243
+ "levels": levels_to_run,
244
+ }), flush=True)
245
+
246
+ try:
247
+ health = _get("/health")
248
+ print(f" server status: {health.get('status', 'unknown')}", flush=True)
249
+ except Exception as exc:
250
+ print(f"[ERROR] Cannot reach environment server at {ENV_BASE_URL}: {exc}", flush=True)
251
+ print(" Make sure `python env.py` is running in another terminal.", flush=True)
252
+ sys.exit(1)
253
+
254
+ results: List[Dict[str, Any]] = []
255
+ for level in levels_to_run:
256
+ result = run_level(level)
257
+ results.append(result)
258
+ time.sleep(1)
259
+
260
+ total_steps = sum(len(r["steps"]) for r in results)
261
+ total_correct = sum(s["is_correct"] for r in results for s in r["steps"])
262
+ weighted_sum = sum(r["overall_score"] * r["total_tasks"] for r in results)
263
+ total_tasks = sum(r["total_tasks"] for r in results)
264
+ avg_score = weighted_sum / total_tasks if total_tasks else 0.0
265
+
266
+ print(f"\n{'='*60}", flush=True)
267
+ print(f" BASELINE SUMMARY", flush=True)
268
+ print(f"{'='*60}", flush=True)
269
+ print(f" Total steps : {total_steps}", flush=True)
270
+ print(f" Correct steps : {total_correct}", flush=True)
271
+ print(f" Weighted score: {avg_score:.4f} (pass threshold: {PASS_THRESHOLD})", flush=True)
272
+ for r in results:
273
+ print(f" {r['level']:8s} score: {r['overall_score']:.4f} ({r['total_tasks']} tasks)", flush=True)
274
+
275
+ success = avg_score >= PASS_THRESHOLD
276
+
277
+ # KEY FIX: task_id = level name ("easy" | "medium" | "hard")
278
+ # This MUST match the task IDs in openenv.yaml so the validator
279
+ # can find the grader for each task. lv1/lv2/lv3 are NOT in openenv.yaml.
280
+ all_tasks: List[Dict[str, Any]] = []
281
+ for r in results:
282
+ level_correct = sum(1 for s in r["steps"] if s["is_correct"])
283
+ all_tasks.append({
284
+ "task_id": r["level"],
285
+ "is_correct": level_correct > 0,
286
+ "reward": r["overall_score"],
287
+ "level": r["level"],
288
+ "steps": r["steps"],
289
+ })
290
+
291
+ run_summary = {
292
+ "timestamp": datetime.now(timezone.utc).isoformat(),
293
+ "model": MODEL_NAME,
294
+ "env": ENV_BASE_URL,
295
+ "levels": levels_to_run,
296
+ "total_steps": total_steps,
297
+ "total_correct": total_correct,
298
+ "avg_score": round(avg_score, 4),
299
+ "pass_threshold": PASS_THRESHOLD,
300
+ "success": success,
301
+ "tasks": all_tasks,
302
+ "level_results": results,
303
+ }
304
+
305
+ try:
306
+ with open(output_path, "w", encoding="utf-8") as fh:
307
+ json.dump(run_summary, fh, indent=2)
308
+ print(f"\n Results saved -> {output_path}", flush=True)
309
+ except OSError as exc:
310
+ print(f"\n [WARN] Could not save results: {exc}", flush=True)
311
+
312
+ # Structured [END] log for validator
313
+ print(json.dumps({
314
+ "[END]": True,
315
+ "success": success,
316
+ "steps": total_steps,
317
+ "score": round(avg_score, 4),
318
+ "tasks": all_tasks,
319
+ }), flush=True)
320
+
321
+
322
+ if __name__ == "__main__":
323
+ main()
models.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models.py – PhishGuard-Env Pydantic Models
3
+ ==========================================
4
+
5
+ Typed request / response schemas used by env.py (FastAPI).
6
+
7
+ PhishAction : Body schema for POST /step
8
+ StepResponse : Response schema for POST /step (OpenEnv grader compliance)
9
+ ResetResponse : Response schema for POST /reset
10
+
11
+ BUG FIX (v1.0.2 β†’ v1.0.3)
12
+ ────────────────────────────────────────────────────────────────────────────
13
+ StepResponse.task_id was typed as `str` but the episode-already-over guard
14
+ branch in env.py returns task_id=None. Pydantic would raise a validation
15
+ error on every post-episode /step call.
16
+ Fix: task_id is now Optional[str] with a default of None.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ from pydantic import BaseModel, Field
24
+
25
+
26
+ # ─────────────────────────────────────────────────────────────────────────────
27
+ # REQUEST MODELS
28
+ # ─────────────────────────────────────────────────────────────────────────────
29
+
30
+ class PhishAction(BaseModel):
31
+ """
32
+ Action submitted by the agent to POST /step.
33
+
34
+ Fields
35
+ ------
36
+ action : One of MARK_SAFE | MOVE_TO_SPAM | QUARANTINE | BLOCK_DOMAIN
37
+ reasoning : Optional one-sentence technical justification (for logging).
38
+ """
39
+ action: str = Field(
40
+ max_length=64,
41
+ description="Triage decision. Must be exactly one of: "
42
+ "MARK_SAFE | MOVE_TO_SPAM | QUARANTINE | BLOCK_DOMAIN"
43
+ )
44
+ reasoning: Optional[str] = Field(
45
+ default=None,
46
+ description="One-sentence technical justification for the triage decision",
47
+ )
48
+
49
+
50
+ class ResetRequest(BaseModel):
51
+ """Body schema for POST /reset."""
52
+ level: str = Field(
53
+ default="easy",
54
+ description="Difficulty level: easy | medium | hard",
55
+ )
56
+
57
+
58
+ # ─────────────────────────────────────────────────────────────────────────────
59
+ # RESPONSE MODELS (OpenEnv spec β€” all fields required by validator)
60
+ # ─────────────────────────────────────────────────────────────────────────────
61
+
62
+ class StepResponse(BaseModel):
63
+ """
64
+ Full response for POST /step.
65
+
66
+ The OpenEnv validator inspects `task_id` and `is_correct` on every step
67
+ to count how many distinct tasks have been graded.
68
+
69
+ task_id is Optional[str] (not str) because the episode-already-over guard
70
+ branch returns None β€” a non-optional field would cause a Pydantic
71
+ ValidationError on every post-episode call.
72
+ """
73
+ observation: Optional[Dict[str, Any]] = Field(
74
+ description="Next email dict, or null when the episode is done"
75
+ )
76
+ reward: float = Field(
77
+ description="Step reward strictly in (0.0, 1.0)"
78
+ )
79
+ done: bool = Field(
80
+ description="True when all scenarios are complete or health reaches 0"
81
+ )
82
+ task_id: Optional[str] = Field( # BUG FIX: was `str`, must be Optional
83
+ default=None,
84
+ description="Scenario ID e.g. 'lv3' β€” required by OpenEnv validator"
85
+ )
86
+ is_correct: bool = Field(
87
+ description="True when reward >= R_PERFECT (0.95)"
88
+ )
89
+ info: Dict[str, Any] = Field(
90
+ description="Full grader info payload"
91
+ )
92
+
93
+
94
+ class ResetResponse(BaseModel):
95
+ """Response for POST /reset."""
96
+ observation: Dict[str, Any] = Field(
97
+ description="First email observation for this episode"
98
+ )
99
+ task_id: str = Field(
100
+ description="ID of the first scenario in this episode"
101
+ )
102
+ task_group: str = Field(
103
+ description="Difficulty level of the first scenario: easy | medium | hard"
104
+ )
105
+ level: str = Field(
106
+ description="Active difficulty level for this episode"
107
+ )
108
+ total_tasks: int = Field(
109
+ description="Total number of scenarios in this level"
110
+ )
openenv.yaml ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PhishGuard-Env – OpenEnv Manifest
2
+ # Each task must declare a grader so the validator can verify scoring.
3
+ # Pattern mirrors Focus-AI's openenv.yaml with per-difficulty graders + aggregate.
4
+ id: phishguard-env
5
+ name: "PhishGuard-Env β€” SOC Analyst Phishing Triage"
6
+ description: >
7
+ A real-world RL environment where an AI agent acts as a SOC analyst
8
+ triaging phishing, malware, BEC, spam, and safe emails.
9
+ The agent must classify each email using a four-action triage system.
10
+ Rewards are graded continuously in (0.0, 1.0).
11
+
12
+ entry_point: env:app
13
+
14
+ tasks:
15
+ - id: easy
16
+ name: easy_triage
17
+ grader: "grader:grade_easy"
18
+ description: >
19
+ Basic triage β€” high confidence signals, clear indicators.
20
+ Covers SPAM detection, basic PHISH blocking, and SAFE email verification.
21
+ Scoring: 60% correct action + 40% threat identification accuracy.
22
+
23
+ - id: medium
24
+ name: medium_triage
25
+ grader: "grader:grade_medium"
26
+ description: >
27
+ Intermediate triage β€” mixed signals, some ambiguity.
28
+ Covers MALWARE quarantine, HR safe emails, BEC wire fraud, shared-doc phishing.
29
+ Scoring: 40% correct action + 35% on-time detection + 25% escalation quality.
30
+
31
+ - id: hard
32
+ name: hard_triage
33
+ grader: "grader:grade_hard"
34
+ description: >
35
+ Advanced triage β€” low confidence signals, sophisticated attacks.
36
+ Covers macro malware, QR-code phishing, BEC domain compromise.
37
+ Scoring: 35% correct action + 30% threat accuracy + 20% escalation + 15% priority.
38
+
39
+ - id: performance
40
+ name: aggregate_performance
41
+ grader: "grader:grade_performance"
42
+ description: >
43
+ Cross-difficulty aggregate grader for leaderboard ranking.
44
+ Provides a single unified score across all difficulty levels.
45
+ Scoring: 40% correct actions + 30% on-time + 20% escalation + 10% priority.
46
+
47
+ observation_space:
48
+ type: object
49
+ fields:
50
+ sender: string
51
+ subject: string
52
+ body: string
53
+ links: list
54
+ has_attachments: boolean
55
+ spf_record: "string β€” pass | softfail | fail"
56
+ dmarc_record: "string β€” pass | fail | none"
57
+ urgency_level: "string β€” low | medium | high | critical"
58
+ confidence_hint: string
59
+
60
+ action_space:
61
+ type: string
62
+ allowed_actions:
63
+ - MARK_SAFE
64
+ - MOVE_TO_SPAM
65
+ - QUARANTINE
66
+ - BLOCK_DOMAIN
67
+
68
+ reward:
69
+ type: continuous
70
+ range: [0.02, 0.95]
71
+ description: >
72
+ All rewards strictly inside open interval (0, 1).
73
+ R_PERFECT=0.95, R_BREACH=0.02. Health drains on reward < 0.15.
74
+
75
+ scoring:
76
+ description: >
77
+ Each task's final score is STRICTLY inside the open interval (0, 1).
78
+ The _safe_score() function guarantees: 0.02 ≀ score ≀ 0.95.
79
+ Minimum achievable: 0.02 (breach / nothing done).
80
+ Maximum achievable: 0.95 (perfect episode).
81
+ formula: "_safe_score(raw) = 0.02 + 0.93 * clamp(raw, 0.0, 1.0)"
82
+
83
+ tags:
84
+ - cybersecurity
85
+ - openenv
86
+ - phishing
87
+ - soc
pyproject.toml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "phishguard-env"
7
+ version = "1.0.3"
8
+ description = "A SOC Analyst simulation environment for phishing email triage."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+
12
+ dependencies = [
13
+ "openenv",
14
+ "pydantic>=2.0,<3.0",
15
+ "fastapi>=0.111,<1.0",
16
+ "uvicorn[standard]>=0.29,<1.0",
17
+ "openai>=1.30,<2.0",
18
+ "requests>=2.31,<3.0",
19
+ "python-dotenv>=1.0,<2.0",
20
+ ]
21
+
22
+ [project.scripts]
23
+ phishguard-server = "server.app:main"
24
+
25
+ [project.urls]
26
+ Homepage = "https://huggingface.co/spaces/Scalar-hackathon/Phishing-env"
27
+ Repository = "https://github.com/og-arin/phishguard-env"
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PhishGuard-Env dependencies
2
+ # Pinned to minor versions for reproducibility.
3
+ # Update pins deliberately β€” do not let pip float to latest.
4
+
5
+ # ── Environment server ────────────────────────────────────────────────────────
6
+ fastapi>=0.111,<1.0
7
+ uvicorn[standard]>=0.29,<1.0
8
+ pydantic>=2.0,<3.0
9
+
10
+ # ── OpenEnv framework ─────────────────────────────────────────────────────────
11
+ openenv
12
+
13
+ # ── Inference agent ───────────────────────────────────────────────────────────
14
+ openai>=1.30,<2.0
15
+ requests>=2.31,<3.0 # BUG FIX: was missing; inference.py imports requests
16
+
17
+ # ── Utilities ─────────────────────────────────────────────────────────────────
18
+ python-dotenv>=1.0,<2.0
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # server package β€” makes `server.app` importable as a module.
server/__pycache__/app.cpython-314.pyc ADDED
Binary file (978 Bytes). View file
 
server/app.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server/app.py - OpenEnv Validator Entry Point
3
+ """
4
+
5
+ import sys
6
+ import os
7
+
8
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
+
10
+ from env import app
11
+
12
+ __all__ = ["app"]
13
+
14
+
15
+ def main():
16
+ """Entry point for [project.scripts] and direct execution."""
17
+ import uvicorn
18
+ uvicorn.run(
19
+ "server.app:app",
20
+ host="0.0.0.0",
21
+ port=7860,
22
+ reload=False,
23
+ log_level="info",
24
+ )
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
test_api.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ test_api.py – Integration tests for FastAPI endpoints
3
+ ======================================================
4
+
5
+ Run with: pytest test_api.py -v
6
+
7
+ Tests the four endpoints (/health, /reset, /step, /state) using FastAPI's
8
+ TestClient (synchronous wrapper around httpx). No external LLM or
9
+ network calls are needed β€” these hit the in-process ASGI app directly.
10
+
11
+ Coverage
12
+ --------
13
+ β€’ GET /health β†’ 200, fields present
14
+ β€’ POST /reset β†’ 200 with body, 200 without body, 422 on bad level
15
+ β€’ POST /step β†’ 200, reward in (0,1), done flag, episode-over guard
16
+ β€’ GET /state β†’ 200, expected keys present
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import pytest
22
+ from fastapi.testclient import TestClient
23
+
24
+ from env import app
25
+
26
+
27
+ # ─────────────────────────────────────────────────────────────────────────────
28
+ # Fixtures
29
+ # ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ @pytest.fixture()
32
+ def client():
33
+ """Yield a fresh TestClient; environment state is shared (singleton)."""
34
+ with TestClient(app) as c:
35
+ yield c
36
+
37
+
38
+ # ═════════════════════════════════════════════════════════════════════════════
39
+ # GET /health
40
+ # ═════════════════════════════════════════════════════════════════════════════
41
+
42
+ class TestHealth:
43
+ def test_health_returns_200(self, client):
44
+ resp = client.get("/health")
45
+ assert resp.status_code == 200
46
+
47
+ def test_health_has_status_ok(self, client):
48
+ data = client.get("/health").json()
49
+ assert data["status"] == "ok"
50
+
51
+ def test_health_has_version(self, client):
52
+ data = client.get("/health").json()
53
+ assert "version" in data
54
+
55
+
56
+ # ═════════════════════════════════════════════════════════════════════════════
57
+ # POST /reset
58
+ # ═════════════════════════════════════════════════════════════════════════════
59
+
60
+ class TestReset:
61
+ def test_reset_with_body(self, client):
62
+ resp = client.post("/reset", json={"level": "easy"})
63
+ assert resp.status_code == 200
64
+ data = resp.json()
65
+ assert "observation" in data
66
+ assert "task_id" in data
67
+ assert data["level"] == "easy"
68
+ assert data["total_tasks"] == 3
69
+
70
+ def test_reset_without_body(self, client):
71
+ """The OpenEnv validator sends POST /reset with no body."""
72
+ resp = client.post("/reset")
73
+ assert resp.status_code == 200
74
+ data = resp.json()
75
+ assert data["level"] == "easy"
76
+ assert "observation" in data
77
+
78
+ def test_reset_medium(self, client):
79
+ resp = client.post("/reset", json={"level": "medium"})
80
+ assert resp.status_code == 200
81
+ data = resp.json()
82
+ assert data["level"] == "medium"
83
+ assert data["total_tasks"] == 4
84
+
85
+ def test_reset_hard(self, client):
86
+ resp = client.post("/reset", json={"level": "hard"})
87
+ assert resp.status_code == 200
88
+ data = resp.json()
89
+ assert data["level"] == "hard"
90
+ assert data["total_tasks"] == 3
91
+
92
+ def test_reset_invalid_level(self, client):
93
+ resp = client.post("/reset", json={"level": "nightmare"})
94
+ assert resp.status_code == 422
95
+
96
+ def test_reset_returns_observation_fields(self, client):
97
+ data = client.post("/reset", json={"level": "easy"}).json()
98
+ obs = data["observation"]
99
+ assert "sender" in obs
100
+ assert "subject" in obs
101
+ assert "body" in obs
102
+ assert "spf_record" in obs
103
+
104
+ def test_reset_returns_task_id(self, client):
105
+ data = client.post("/reset", json={"level": "easy"}).json()
106
+ assert data["task_id"].startswith("lv")
107
+
108
+ def test_reset_returns_task_group(self, client):
109
+ data = client.post("/reset", json={"level": "easy"}).json()
110
+ assert data["task_group"] == "easy"
111
+
112
+
113
+ # ═════════════════════════════════════════════════════════════════════════════
114
+ # POST /step
115
+ # ═════════════════════════���═══════════════════════════════════════════════════
116
+
117
+ class TestStep:
118
+ def test_step_returns_200(self, client):
119
+ client.post("/reset", json={"level": "easy"})
120
+ resp = client.post("/step", json={"action": "MARK_SAFE"})
121
+ assert resp.status_code == 200
122
+
123
+ def test_step_has_required_fields(self, client):
124
+ client.post("/reset", json={"level": "easy"})
125
+ data = client.post("/step", json={"action": "QUARANTINE"}).json()
126
+ assert "observation" in data
127
+ assert "reward" in data
128
+ assert "done" in data
129
+ assert "task_id" in data
130
+ assert "is_correct" in data
131
+ assert "info" in data
132
+
133
+ def test_step_reward_in_open_interval(self, client):
134
+ client.post("/reset", json={"level": "easy"})
135
+ data = client.post("/step", json={"action": "MARK_SAFE"}).json()
136
+ assert 0.0 < data["reward"] < 1.0
137
+
138
+ def test_step_with_reasoning(self, client):
139
+ client.post("/reset", json={"level": "easy"})
140
+ resp = client.post("/step", json={
141
+ "action": "QUARANTINE",
142
+ "reasoning": "Suspicious sender domain",
143
+ })
144
+ assert resp.status_code == 200
145
+
146
+ def test_full_easy_episode(self, client):
147
+ """Run all 3 easy tasks and verify done=True at the end."""
148
+ client.post("/reset", json={"level": "easy"})
149
+ done = False
150
+ steps = 0
151
+ while not done and steps < 10:
152
+ data = client.post("/step", json={"action": "QUARANTINE"}).json()
153
+ done = data["done"]
154
+ steps += 1
155
+ assert done is True
156
+ assert steps <= 5 # easy has 3 tasks; should never exceed that
157
+
158
+ def test_step_after_episode_done(self, client):
159
+ """Steps after episode ends should return done=True gracefully."""
160
+ client.post("/reset", json={"level": "easy"})
161
+ # Exhaust all tasks
162
+ for _ in range(5):
163
+ resp = client.post("/step", json={"action": "QUARANTINE"})
164
+ # Extra step after episode is over
165
+ data = client.post("/step", json={"action": "MARK_SAFE"}).json()
166
+ assert data["done"] is True
167
+
168
+ def test_step_invalid_action_still_200(self, client):
169
+ """Invalid actions are graded as WRONG_PROCEDURE, not rejected with 4xx."""
170
+ client.post("/reset", json={"level": "easy"})
171
+ resp = client.post("/step", json={"action": "DELETE_EVERYTHING"})
172
+ assert resp.status_code == 200
173
+ data = resp.json()
174
+ assert data["reward"] == 0.10 # R_WRONG_PROCEDURE
175
+
176
+
177
+ # ═════════════════════════════════════════════════════════════════════════════
178
+ # GET /state
179
+ # ═════════════════════════════════════════════════════════════════════════════
180
+
181
+ class TestState:
182
+ def test_state_returns_200(self, client):
183
+ resp = client.get("/state")
184
+ assert resp.status_code == 200
185
+
186
+ def test_state_has_expected_keys(self, client):
187
+ client.post("/reset", json={"level": "easy"})
188
+ data = client.get("/state").json()
189
+ assert "active_level" in data
190
+ assert "current_task_idx" in data
191
+ assert "health" in data
192
+ assert "score" in data
193
+ assert "task_scores" in data
194
+ assert "scenarios_total" in data
195
+ assert "overall_score" in data
196
+
197
+ def test_state_after_reset(self, client):
198
+ client.post("/reset", json={"level": "medium"})
199
+ data = client.get("/state").json()
200
+ assert data["active_level"] == "medium"
201
+ assert data["health"] == 3
202
+ assert data["score"] == 0.0
203
+ assert data["task_scores"] == []
204
+ assert data["current_task_idx"] == 0
205
+
206
+ def test_state_after_step(self, client):
207
+ client.post("/reset", json={"level": "easy"})
208
+ client.post("/step", json={"action": "QUARANTINE"})
209
+ data = client.get("/state").json()
210
+ assert data["current_task_idx"] == 1
211
+ assert len(data["task_scores"]) == 1
test_grader.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ test_grader.py – Unit tests for grader.py
3
+ ==========================================
4
+
5
+ Run with: pytest test_grader.py -v
6
+
7
+ These tests cover every branch of grade_action() and the edge-cases of
8
+ calculate_overall_score(). They also act as a regression guard β€” any
9
+ change to a reward constant will immediately fail the assertion that was
10
+ relying on it, forcing an intentional review.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import pytest
16
+
17
+ from grader import (
18
+ HEALTH_DRAIN_THRESHOLD,
19
+ PASS_THRESHOLD,
20
+ R_BREACH,
21
+ R_DISRUPTION,
22
+ R_MALWARE_QUARANTINE,
23
+ R_PARTIAL,
24
+ R_PERFECT,
25
+ R_PHISH_BEC_QUARANTINE,
26
+ R_SPAM_BLOCK,
27
+ R_SPAM_QUARANTINE,
28
+ R_WRONG_PROCEDURE,
29
+ calculate_overall_score,
30
+ grade_action,
31
+ )
32
+
33
+
34
+ # ═════════════════════════════════════════════════════════════════════════════
35
+ # CONSTANT SANITY CHECKS
36
+ # ═════════════════════════════════════════════════════════════════════════════
37
+
38
+ class TestRewardConstants:
39
+ def test_all_rewards_in_open_interval(self):
40
+ for r in [R_PERFECT, R_MALWARE_QUARANTINE, R_PHISH_BEC_QUARANTINE,
41
+ R_SPAM_BLOCK, R_SPAM_QUARANTINE, R_WRONG_PROCEDURE,
42
+ R_DISRUPTION, R_BREACH]:
43
+ assert 0.0 < r < 1.0, f"Reward {r} violates open-interval contract"
44
+
45
+ def test_reward_ordering(self):
46
+ assert R_BREACH < R_DISRUPTION < R_WRONG_PROCEDURE
47
+ assert R_WRONG_PROCEDURE < HEALTH_DRAIN_THRESHOLD
48
+ assert HEALTH_DRAIN_THRESHOLD < R_SPAM_QUARANTINE
49
+ assert R_SPAM_QUARANTINE < R_SPAM_BLOCK
50
+ assert R_SPAM_BLOCK < R_PHISH_BEC_QUARANTINE
51
+ assert R_PHISH_BEC_QUARANTINE < R_MALWARE_QUARANTINE
52
+ assert R_MALWARE_QUARANTINE < R_PERFECT
53
+
54
+ def test_partial_alias(self):
55
+ assert R_PARTIAL == R_MALWARE_QUARANTINE
56
+
57
+ def test_health_drain_covers_breach_disruption_wrong(self):
58
+ assert R_BREACH < HEALTH_DRAIN_THRESHOLD
59
+ assert R_DISRUPTION < HEALTH_DRAIN_THRESHOLD
60
+ assert R_WRONG_PROCEDURE < HEALTH_DRAIN_THRESHOLD
61
+
62
+ def test_cautious_scores_never_drain_health(self):
63
+ assert R_SPAM_QUARANTINE >= HEALTH_DRAIN_THRESHOLD
64
+ assert R_SPAM_BLOCK >= HEALTH_DRAIN_THRESHOLD
65
+ assert R_PHISH_BEC_QUARANTINE >= HEALTH_DRAIN_THRESHOLD
66
+ assert R_MALWARE_QUARANTINE >= HEALTH_DRAIN_THRESHOLD
67
+
68
+
69
+ # ═════════════════════════════════════════════════════════════════════════════
70
+ # GRADE_ACTION β€” PERFECT MATCH
71
+ # ═════════════════════════════════════════════════════════════════════════════
72
+
73
+ class TestPerfectTriage:
74
+ @pytest.mark.parametrize("action,etype", [
75
+ ("MOVE_TO_SPAM", "SPAM"),
76
+ ("BLOCK_DOMAIN", "PHISH"),
77
+ ("MARK_SAFE", "SAFE"),
78
+ ("QUARANTINE", "MALWARE"),
79
+ ("QUARANTINE", "BEC"),
80
+ ("BLOCK_DOMAIN", "BEC"),
81
+ ("QUARANTINE", "PHISH"),
82
+ ])
83
+ def test_perfect_match(self, action, etype):
84
+ reward, msg = grade_action(action, action, etype)
85
+ assert reward == R_PERFECT
86
+ assert "PERFECT_TRIAGE" in msg
87
+
88
+ def test_case_insensitive_action(self):
89
+ reward, _ = grade_action("move_to_spam", "MOVE_TO_SPAM", "SPAM")
90
+ assert reward == R_PERFECT
91
+
92
+ def test_case_insensitive_type(self):
93
+ reward, _ = grade_action("MARK_SAFE", "MARK_SAFE", "safe")
94
+ assert reward == R_PERFECT
95
+
96
+
97
+ # ═════════════════════════════════════════════════════════════════════════════
98
+ # GRADE_ACTION β€” SECURITY BREACH
99
+ # ═════════════════════════════════════════════════════════════════════════════
100
+
101
+ class TestSecurityBreach:
102
+ @pytest.mark.parametrize("etype", ["PHISH", "BEC", "MALWARE", "SPAM"])
103
+ def test_mark_safe_on_any_threat_is_breach(self, etype):
104
+ """BUG FIX coverage: SPAM + MARK_SAFE must return R_BREACH (not R_WRONG_PROCEDURE)."""
105
+ reward, msg = grade_action("MARK_SAFE", "BLOCK_DOMAIN", etype)
106
+ assert reward == R_BREACH
107
+ assert "SECURITY_BREACH" in msg
108
+
109
+ def test_spam_mark_safe_is_breach_not_wrong_procedure(self):
110
+ """Regression: the original code excluded SPAM from _THREAT_TYPES."""
111
+ reward, _ = grade_action("MARK_SAFE", "MOVE_TO_SPAM", "SPAM")
112
+ assert reward == R_BREACH, (
113
+ "SPAM + MARK_SAFE must be R_BREACH β€” SPAM was excluded from "
114
+ "_THREAT_TYPES in the v1.0.2 bug"
115
+ )
116
+ assert reward < HEALTH_DRAIN_THRESHOLD # must drain health
117
+
118
+
119
+ # ═════════════════════════════════════════════════════════════════════════════
120
+ # GRADE_ACTION β€” BUSINESS DISRUPTION
121
+ # ═════════════════════════════════════════════════════════════════════════════
122
+
123
+ class TestBusinessDisruption:
124
+ @pytest.mark.parametrize("blocking_action", ["BLOCK_DOMAIN", "QUARANTINE", "MOVE_TO_SPAM"])
125
+ def test_blocking_safe_email_is_disruption(self, blocking_action):
126
+ reward, msg = grade_action(blocking_action, "MARK_SAFE", "SAFE")
127
+ assert reward == R_DISRUPTION
128
+ assert "BUSINESS_DISRUPTION" in msg
129
+
130
+
131
+ # ═════════════════════════════════════════════════════════════════════════════
132
+ # GRADE_ACTION β€” PARTIAL CREDIT
133
+ # ═════════════════════════════════════════════════════════════════════════════
134
+
135
+ class TestPartialCredit:
136
+ def test_malware_quarantine(self):
137
+ reward, msg = grade_action("QUARANTINE", "BLOCK_DOMAIN", "MALWARE")
138
+ assert reward == R_MALWARE_QUARANTINE
139
+ assert "CAUTIOUS" in msg
140
+
141
+ def test_phish_quarantine(self):
142
+ reward, msg = grade_action("QUARANTINE", "BLOCK_DOMAIN", "PHISH")
143
+ assert reward == R_PHISH_BEC_QUARANTINE
144
+ assert "UNDER_RESPONSE" in msg
145
+
146
+ def test_bec_quarantine(self):
147
+ reward, msg = grade_action("QUARANTINE", "BLOCK_DOMAIN", "BEC")
148
+ assert reward == R_PHISH_BEC_QUARANTINE
149
+
150
+ def test_spam_block_domain(self):
151
+ reward, msg = grade_action("BLOCK_DOMAIN", "MOVE_TO_SPAM", "SPAM")
152
+ assert reward == R_SPAM_BLOCK
153
+ assert "OVER_ESCALATION" in msg
154
+
155
+ def test_spam_quarantine(self):
156
+ reward, msg = grade_action("QUARANTINE", "MOVE_TO_SPAM", "SPAM")
157
+ assert reward == R_SPAM_QUARANTINE
158
+ assert "OVER_ESCALATION" in msg
159
+
160
+ @pytest.mark.parametrize("action", ["QUARANTINE", "BLOCK_DOMAIN", "MOVE_TO_SPAM"])
161
+ def test_partial_credits_never_drain_health(self, action):
162
+ reward, _ = grade_action(action, "MOVE_TO_SPAM", "SPAM")
163
+ assert reward >= HEALTH_DRAIN_THRESHOLD
164
+
165
+
166
+ # ═════════════════════════════════════════════════════════════════════════════
167
+ # GRADE_ACTION β€” WRONG PROCEDURE / INVALID
168
+ # ═════════════════════════════════════════════════════════════════════════════
169
+
170
+ class TestWrongProcedure:
171
+ def test_malware_block_domain_is_wrong_procedure(self):
172
+ """lv4 / lv8: expected QUARANTINE, agent picks BLOCK_DOMAIN."""
173
+ reward, msg = grade_action("BLOCK_DOMAIN", "QUARANTINE", "MALWARE")
174
+ assert reward == R_WRONG_PROCEDURE
175
+ assert "INCORRECT_PROCEDURE" in msg
176
+
177
+ def test_invalid_action_token(self):
178
+ reward, msg = grade_action("DELETE", "QUARANTINE", "MALWARE")
179
+ assert reward == R_WRONG_PROCEDURE
180
+ assert "INVALID_ACTION" in msg
181
+
182
+ def test_invalid_action_drains_health(self):
183
+ reward, _ = grade_action("NONSENSE", "QUARANTINE", "PHISH")
184
+ assert reward < HEALTH_DRAIN_THRESHOLD
185
+
186
+
187
+ # ═════════════════════════════════════════════════════════════════════════════
188
+ # CALCULATE_OVERALL_SCORE
189
+ # ═════════════════════════════════════════════════════════════════════════════
190
+
191
+ class TestCalculateOverallScore:
192
+ def test_empty_list_returns_r_breach(self):
193
+ assert calculate_overall_score([]) == R_BREACH
194
+
195
+ def test_all_perfect_returns_r_perfect(self):
196
+ assert calculate_overall_score([R_PERFECT, R_PERFECT, R_PERFECT]) == R_PERFECT
197
+
198
+ def test_result_never_exceeds_r_perfect(self):
199
+ assert calculate_overall_score([1.0, 1.0]) == R_PERFECT
200
+
201
+ def test_result_never_below_r_breach(self):
202
+ assert calculate_overall_score([0.0, 0.0]) == R_BREACH
203
+
204
+ def test_easy_all_perfect(self):
205
+ scores = [R_PERFECT] * 3
206
+ assert calculate_overall_score(scores) == R_PERFECT
207
+
208
+ def test_medium_mixed(self):
209
+ scores = [R_PERFECT, R_BREACH, R_PERFECT, R_PHISH_BEC_QUARANTINE]
210
+ result = calculate_overall_score(scores)
211
+ expected = round((R_PERFECT + R_BREACH + R_PERFECT + R_PHISH_BEC_QUARANTINE) / 4, 4)
212
+ assert result == expected
213
+
214
+ def test_hard_mostly_bad(self):
215
+ scores = [R_BREACH, R_DISRUPTION, R_PERFECT]
216
+ result = calculate_overall_score(scores)
217
+ assert result < PASS_THRESHOLD
218
+
219
+ def test_returns_four_decimal_places(self):
220
+ result = calculate_overall_score([R_PERFECT, R_BREACH])
221
+ assert result == round(result, 4)
222
+
223
+ def test_single_perfect_step(self):
224
+ assert calculate_overall_score([R_PERFECT]) == R_PERFECT
225
+
226
+ def test_single_breach_step(self):
227
+ assert calculate_overall_score([R_BREACH]) == R_BREACH
228
+
229
+
230
+ # ═════════════════════════════════════════════════════════════════════════════
231
+ # EDGE CASES β€” INPUT NORMALIZATION
232
+ # ═════════════════════════════════════════════════════════════════════════════
233
+
234
+ class TestInputNormalization:
235
+ def test_whitespace_padded_action(self):
236
+ """Actions with leading/trailing spaces should still match."""
237
+ reward, msg = grade_action(" MARK_SAFE ", "MARK_SAFE", "SAFE")
238
+ assert reward == R_PERFECT
239
+ assert "PERFECT_TRIAGE" in msg
240
+
241
+ def test_mixed_case_email_type_phish(self):
242
+ reward, _ = grade_action("MARK_SAFE", "BLOCK_DOMAIN", "Phish")
243
+ assert reward == R_BREACH
244
+
245
+ def test_mixed_case_email_type_bec(self):
246
+ reward, _ = grade_action("QUARANTINE", "BLOCK_DOMAIN", "bEc")
247
+ assert reward == R_PHISH_BEC_QUARANTINE
248
+
249
+ def test_tab_in_action(self):
250
+ reward, _ = grade_action("\tQUARANTINE\t", "QUARANTINE", "MALWARE")
251
+ assert reward == R_PERFECT
252
+
253
+ def test_empty_action_string(self):
254
+ reward, msg = grade_action("", "QUARANTINE", "MALWARE")
255
+ assert reward == R_WRONG_PROCEDURE
256
+ assert "INVALID_ACTION" in msg
257
+
258
+ def test_calculate_overall_score_single_breach(self):
259
+ assert calculate_overall_score([R_BREACH]) == R_BREACH
260
+
261
+ def test_calculate_overall_score_all_disruption(self):
262
+ result = calculate_overall_score([R_DISRUPTION] * 5)
263
+ assert result == R_DISRUPTION
264
+