Spaces:
Sleeping
Sleeping
Upload 18 files
Browse files- .dockerignore +34 -0
- .env.example +14 -0
- .gitattributes +35 -35
- .gitignore +35 -0
- Dockerfile +28 -0
- README.md +106 -12
- env.py +739 -0
- grader.py +403 -0
- inference.py +323 -0
- models.py +110 -0
- openenv.yaml +87 -0
- pyproject.toml +27 -0
- requirements.txt +18 -0
- server/__init__.py +1 -0
- server/__pycache__/app.cpython-314.pyc +0 -0
- server/app.py +28 -0
- test_api.py +211 -0
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|