Spaces:
Sleeping
Sleeping
Update env.py
Browse files
env.py
CHANGED
|
@@ -1,46 +1,6 @@
|
|
| 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 |
-
GET / β root metadata: lists task types, version, and task registry
|
| 15 |
-
POST /reset β reset for a chosen difficulty level; returns first email observation
|
| 16 |
-
POST /step β submit one triage action; returns (obs, reward, done, info)
|
| 17 |
-
GET /state β read-only snapshot of health, score, task index
|
| 18 |
-
GET /health β liveness probe for HF Spaces / load-balancers
|
| 19 |
-
|
| 20 |
-
LEVEL DESIGN
|
| 21 |
-
------------
|
| 22 |
-
easy β lv1, lv2, lv3 (SPAM, PHISH, SAFE)
|
| 23 |
-
medium β lv4, lv5, lv6, lv7 (MALWARE, SAFE, BEC, PHISH)
|
| 24 |
-
hard β lv8, lv9, lv10 (MALWARE, PHISH, BEC)
|
| 25 |
-
|
| 26 |
-
TASK REGISTRY (3 graded tasks β satisfies OpenEnv validator)
|
| 27 |
-
-------------------------------------------------------------
|
| 28 |
-
task_phish β PHISH scenarios (credential harvesting, typosquat)
|
| 29 |
-
task_bec β BEC scenarios (wire fraud, supply-chain compromise)
|
| 30 |
-
task_internal β SAFE / MALWARE scenarios (internal mail & malware triage)
|
| 31 |
-
|
| 32 |
-
FIX APPLIED
|
| 33 |
-
-----------
|
| 34 |
-
The OpenEnv validator calls POST /reset with an EMPTY body (no JSON at all).
|
| 35 |
-
The previous endpoint signature was:
|
| 36 |
-
async def reset(request: ResetRequest)
|
| 37 |
-
FastAPI requires a body when the parameter is not Optional, so it returned:
|
| 38 |
-
HTTP 422 Unprocessable Entity
|
| 39 |
-
{"detail":[{"type":"missing","loc":["body"],"msg":"Field required"}]}
|
| 40 |
-
|
| 41 |
-
Fix: make the request body Optional. When None, default to level="easy".
|
| 42 |
-
async def reset(request: Optional[ResetRequest] = None)
|
| 43 |
-
This is the ONLY change from the live repo.
|
| 44 |
"""
|
| 45 |
|
| 46 |
from __future__ import annotations
|
|
@@ -80,8 +40,18 @@ from grader import (
|
|
| 80 |
HEALTH_DRAIN_THRESHOLD,
|
| 81 |
calculate_overall_score,
|
| 82 |
grade_action,
|
|
|
|
| 83 |
)
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
# SCENARIO DEFINITIONS (lv1 β lv10)
|
| 87 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -313,12 +283,13 @@ LEVEL_MAP: dict[str, list[str]] = {
|
|
| 313 |
|
| 314 |
_SCENARIO_BY_ID: dict[str, dict] = {s["id"]: s for s in SCENARIOS}
|
| 315 |
|
|
|
|
| 316 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 317 |
# REQUEST SCHEMA
|
| 318 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 319 |
|
| 320 |
class ResetRequest(BaseModel):
|
| 321 |
-
level: str = "easy"
|
| 322 |
|
| 323 |
|
| 324 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -344,8 +315,8 @@ class PhishGuardEnv(OpenEnv):
|
|
| 344 |
raise ValueError(
|
| 345 |
f"Unknown level '{level}'. Valid choices: easy | medium | hard"
|
| 346 |
)
|
| 347 |
-
ids
|
| 348 |
-
subset
|
| 349 |
remaining = [s for s in SCENARIOS if s["id"] not in ids]
|
| 350 |
subset.extend(remaining)
|
| 351 |
random.shuffle(subset)
|
|
@@ -370,9 +341,14 @@ class PhishGuardEnv(OpenEnv):
|
|
| 370 |
return first_task["data"]
|
| 371 |
|
| 372 |
def step(self, action_str: str) -> tuple:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
if self._is_over():
|
| 374 |
return None, R_BREACH, True, {
|
| 375 |
"task_id": None,
|
|
|
|
| 376 |
"is_correct": False,
|
| 377 |
"health": self.health,
|
| 378 |
"feedback": "Episode already ended. Call /reset to start a new one.",
|
|
@@ -380,18 +356,12 @@ class PhishGuardEnv(OpenEnv):
|
|
| 380 |
"task_scores": list(self.task_scores),
|
| 381 |
}
|
| 382 |
|
| 383 |
-
current_task
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
"SPAM": "task_spam",
|
| 390 |
-
"PHISH": "task_phishing",
|
| 391 |
-
"SAFE": "task_safe",
|
| 392 |
-
"MALWARE": "task_malware",
|
| 393 |
-
"BEC": "task_bec"
|
| 394 |
-
}.get(current_task["type"].upper(), current_task["id"])
|
| 395 |
|
| 396 |
reward, verdict_msg = grade_action(
|
| 397 |
action_str,
|
|
@@ -404,7 +374,11 @@ class PhishGuardEnv(OpenEnv):
|
|
| 404 |
|
| 405 |
log.info(
|
| 406 |
"Step | level=%s | task=%s | action=%s | reward=%.4f | verdict=%s",
|
| 407 |
-
self.active_level,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
)
|
| 409 |
|
| 410 |
if reward < HEALTH_DRAIN_THRESHOLD:
|
|
@@ -438,8 +412,8 @@ class PhishGuardEnv(OpenEnv):
|
|
| 438 |
)
|
| 439 |
|
| 440 |
return obs, reward, done, {
|
| 441 |
-
"task_id": semantic_task_id,
|
| 442 |
-
"task_group":
|
| 443 |
"is_correct": reward >= R_PERFECT,
|
| 444 |
"health": self.health,
|
| 445 |
"feedback": feedback,
|
|
@@ -470,7 +444,7 @@ app = FastAPI(
|
|
| 470 |
),
|
| 471 |
version="3.1.0",
|
| 472 |
lifespan=lifespan,
|
| 473 |
-
docs_url="/docs",
|
| 474 |
redoc_url="/redoc",
|
| 475 |
)
|
| 476 |
|
|
@@ -483,15 +457,13 @@ app.add_middleware(
|
|
| 483 |
)
|
| 484 |
|
| 485 |
|
|
|
|
| 486 |
@app.get("/", tags=["Meta"])
|
| 487 |
async def root_metadata() -> dict:
|
| 488 |
"""
|
| 489 |
-
Root metadata
|
| 490 |
-
|
| 491 |
-
Returns a JSON document listing all task types registered in this
|
| 492 |
-
environment so the validator can confirm there are β₯3 graded tasks.
|
| 493 |
"""
|
| 494 |
-
from grader import TASK_REGISTRY
|
| 495 |
return {
|
| 496 |
"name": "PhishGuard-Env",
|
| 497 |
"version": "3.1.0",
|
|
@@ -500,60 +472,39 @@ async def root_metadata() -> dict:
|
|
| 500 |
"port": 7860,
|
| 501 |
"tasks": [
|
| 502 |
{
|
| 503 |
-
"id":
|
| 504 |
-
"description": "Identify phishing emails (credential harvesting, typosquat domains) and block at perimeter.",
|
| 505 |
-
"grader": "grader.grade_easy",
|
| 506 |
-
},
|
| 507 |
-
{
|
| 508 |
-
"id": "task_bec",
|
| 509 |
-
"description": "Detect Business Email Compromise (wire fraud, CEO impersonation, supply-chain attacks).",
|
| 510 |
-
"grader": "grader.grade_medium",
|
| 511 |
-
},
|
| 512 |
-
{
|
| 513 |
-
"id": "task_internal",
|
| 514 |
-
"description": "Triage internal / malware emails: pass safe mail, quarantine malware payloads.",
|
| 515 |
-
"grader": "grader.grade_hard",
|
| 516 |
-
},
|
| 517 |
-
],
|
| 518 |
-
"task_registry": {
|
| 519 |
-
task_id: {
|
| 520 |
"description": meta["description"],
|
| 521 |
-
"
|
| 522 |
}
|
| 523 |
for task_id, meta in TASK_REGISTRY.items()
|
| 524 |
-
|
| 525 |
-
"levels":
|
| 526 |
"endpoints": {
|
| 527 |
-
"reset":
|
| 528 |
-
"step":
|
| 529 |
-
"state":
|
| 530 |
-
"health":"GET /health",
|
| 531 |
-
"docs":
|
| 532 |
},
|
| 533 |
}
|
| 534 |
|
| 535 |
|
|
|
|
| 536 |
@app.get("/health", tags=["Meta"])
|
| 537 |
async def health_probe() -> dict:
|
| 538 |
return {"status": "ok", "env": "PhishGuard-Env", "version": "3.1.0"}
|
| 539 |
|
| 540 |
|
| 541 |
-
# ββ
|
| 542 |
-
# The validator calls POST /reset with an empty body.
|
| 543 |
-
# Making `request` Optional with a default of None means FastAPI no longer
|
| 544 |
-
# requires a body β when nothing is sent, we just use level="easy".
|
| 545 |
@app.post("/reset", tags=["Environment"])
|
| 546 |
async def reset(request: Optional[ResetRequest] = None) -> dict:
|
| 547 |
"""
|
| 548 |
Reset the environment for a new episode.
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
{ "level": "easy" | "medium" | "hard" }
|
| 552 |
-
|
| 553 |
-
Defaults to level="easy" when no body is provided.
|
| 554 |
"""
|
| 555 |
if request is None:
|
| 556 |
-
request = ResetRequest()
|
| 557 |
|
| 558 |
level = request.level.lower()
|
| 559 |
if level not in LEVEL_MAP:
|
|
@@ -562,28 +513,19 @@ async def reset(request: Optional[ResetRequest] = None) -> dict:
|
|
| 562 |
detail=f"Invalid level '{level}'. Must be one of: easy | medium | hard",
|
| 563 |
)
|
| 564 |
|
| 565 |
-
obs
|
| 566 |
-
first_task
|
| 567 |
-
|
| 568 |
-
"SPAM": "task_spam",
|
| 569 |
-
"PHISH": "task_phishing",
|
| 570 |
-
"SAFE": "task_safe",
|
| 571 |
-
"MALWARE": "task_malware",
|
| 572 |
-
"BEC": "task_bec",
|
| 573 |
-
}
|
| 574 |
-
_TYPE_TO_TASK_ID = {
|
| 575 |
-
"SPAM": "task_spam", "PHISH": "task_phishing",
|
| 576 |
-
"SAFE": "task_safe", "MALWARE": "task_malware", "BEC": "task_bec",
|
| 577 |
-
}
|
| 578 |
return {
|
| 579 |
"observation": obs,
|
| 580 |
-
"task_id":
|
| 581 |
-
"task_group":
|
| 582 |
-
"level":
|
| 583 |
"total_tasks": len(_env.scenarios),
|
| 584 |
}
|
| 585 |
|
| 586 |
|
|
|
|
| 587 |
@app.post("/step", tags=["Environment"])
|
| 588 |
async def step(action: PhishAction) -> dict:
|
| 589 |
obs, reward, done, info = _env.step(action.action)
|
|
@@ -598,6 +540,7 @@ async def step(action: PhishAction) -> dict:
|
|
| 598 |
}
|
| 599 |
|
| 600 |
|
|
|
|
| 601 |
@app.get("/state", tags=["Environment"])
|
| 602 |
async def state() -> dict:
|
| 603 |
return {
|
|
@@ -611,6 +554,7 @@ async def state() -> dict:
|
|
| 611 |
}
|
| 612 |
|
| 613 |
|
|
|
|
| 614 |
if __name__ == "__main__":
|
| 615 |
import uvicorn
|
| 616 |
-
uvicorn.run("env:app", host="0.0.0.0", port=7860, reload=False, log_level="info")
|
|
|
|
| 1 |
"""
|
| 2 |
env.py β PhishGuard-Env | FastAPI Environment Server
|
| 3 |
=======================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
from __future__ import annotations
|
|
|
|
| 40 |
HEALTH_DRAIN_THRESHOLD,
|
| 41 |
calculate_overall_score,
|
| 42 |
grade_action,
|
| 43 |
+
TASK_REGISTRY,
|
| 44 |
)
|
| 45 |
|
| 46 |
+
# ββ Single source of truth: email type β semantic task ID ββββββββββββββββββββ
|
| 47 |
+
_TYPE_TO_TASK_ID: dict[str, str] = {
|
| 48 |
+
"SPAM": "task_spam",
|
| 49 |
+
"PHISH": "task_phishing",
|
| 50 |
+
"SAFE": "task_safe",
|
| 51 |
+
"MALWARE": "task_malware",
|
| 52 |
+
"BEC": "task_bec",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
# SCENARIO DEFINITIONS (lv1 β lv10)
|
| 57 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 283 |
|
| 284 |
_SCENARIO_BY_ID: dict[str, dict] = {s["id"]: s for s in SCENARIOS}
|
| 285 |
|
| 286 |
+
|
| 287 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 288 |
# REQUEST SCHEMA
|
| 289 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 290 |
|
| 291 |
class ResetRequest(BaseModel):
|
| 292 |
+
level: str = "easy"
|
| 293 |
|
| 294 |
|
| 295 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 315 |
raise ValueError(
|
| 316 |
f"Unknown level '{level}'. Valid choices: easy | medium | hard"
|
| 317 |
)
|
| 318 |
+
ids = LEVEL_MAP[level]
|
| 319 |
+
subset = [dict(_SCENARIO_BY_ID[sid]) for sid in ids]
|
| 320 |
remaining = [s for s in SCENARIOS if s["id"] not in ids]
|
| 321 |
subset.extend(remaining)
|
| 322 |
random.shuffle(subset)
|
|
|
|
| 341 |
return first_task["data"]
|
| 342 |
|
| 343 |
def step(self, action_str: str) -> tuple:
|
| 344 |
+
|
| 345 |
+
# ββ Guard: episode already over βββββββββββββββββββββββββββββββββββββββ
|
| 346 |
+
# task_group MUST be present here β the /step FastAPI endpoint reads
|
| 347 |
+
# info["task_group"] and crashes with KeyError if it is missing.
|
| 348 |
if self._is_over():
|
| 349 |
return None, R_BREACH, True, {
|
| 350 |
"task_id": None,
|
| 351 |
+
"task_group": None, # β CRITICAL: was missing, caused KeyError
|
| 352 |
"is_correct": False,
|
| 353 |
"health": self.health,
|
| 354 |
"feedback": "Episode already ended. Call /reset to start a new one.",
|
|
|
|
| 356 |
"task_scores": list(self.task_scores),
|
| 357 |
}
|
| 358 |
|
| 359 |
+
current_task = self.scenarios[self.current_task_idx]
|
| 360 |
+
task_group = current_task["level"]
|
| 361 |
+
# Use the module-level _TYPE_TO_TASK_ID β defined once, used everywhere
|
| 362 |
+
semantic_task_id = _TYPE_TO_TASK_ID.get(
|
| 363 |
+
current_task["type"].upper(), current_task["id"]
|
| 364 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
reward, verdict_msg = grade_action(
|
| 367 |
action_str,
|
|
|
|
| 374 |
|
| 375 |
log.info(
|
| 376 |
"Step | level=%s | task=%s | action=%s | reward=%.4f | verdict=%s",
|
| 377 |
+
self.active_level,
|
| 378 |
+
semantic_task_id,
|
| 379 |
+
action_str.strip().upper(),
|
| 380 |
+
reward,
|
| 381 |
+
verdict_msg,
|
| 382 |
)
|
| 383 |
|
| 384 |
if reward < HEALTH_DRAIN_THRESHOLD:
|
|
|
|
| 412 |
)
|
| 413 |
|
| 414 |
return obs, reward, done, {
|
| 415 |
+
"task_id": semantic_task_id, # "task_spam" not "lv1"
|
| 416 |
+
"task_group": task_group,
|
| 417 |
"is_correct": reward >= R_PERFECT,
|
| 418 |
"health": self.health,
|
| 419 |
"feedback": feedback,
|
|
|
|
| 444 |
),
|
| 445 |
version="3.1.0",
|
| 446 |
lifespan=lifespan,
|
| 447 |
+
docs_url="/docs",
|
| 448 |
redoc_url="/redoc",
|
| 449 |
)
|
| 450 |
|
|
|
|
| 457 |
)
|
| 458 |
|
| 459 |
|
| 460 |
+
# ββ Root metadata βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 461 |
@app.get("/", tags=["Meta"])
|
| 462 |
async def root_metadata() -> dict:
|
| 463 |
"""
|
| 464 |
+
Root metadata β lists all registered task types so the validator
|
| 465 |
+
can confirm at least 3 graded tasks exist before running the episode.
|
|
|
|
|
|
|
| 466 |
"""
|
|
|
|
| 467 |
return {
|
| 468 |
"name": "PhishGuard-Env",
|
| 469 |
"version": "3.1.0",
|
|
|
|
| 472 |
"port": 7860,
|
| 473 |
"tasks": [
|
| 474 |
{
|
| 475 |
+
"id": task_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
"description": meta["description"],
|
| 477 |
+
"grader": "grader.grade_action",
|
| 478 |
}
|
| 479 |
for task_id, meta in TASK_REGISTRY.items()
|
| 480 |
+
],
|
| 481 |
+
"levels": list(LEVEL_MAP.keys()),
|
| 482 |
"endpoints": {
|
| 483 |
+
"reset": "POST /reset",
|
| 484 |
+
"step": "POST /step",
|
| 485 |
+
"state": "GET /state",
|
| 486 |
+
"health": "GET /health",
|
| 487 |
+
"docs": "GET /docs",
|
| 488 |
},
|
| 489 |
}
|
| 490 |
|
| 491 |
|
| 492 |
+
# ββ Liveness probe βββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½ββββββββββ
|
| 493 |
@app.get("/health", tags=["Meta"])
|
| 494 |
async def health_probe() -> dict:
|
| 495 |
return {"status": "ok", "env": "PhishGuard-Env", "version": "3.1.0"}
|
| 496 |
|
| 497 |
|
| 498 |
+
# ββ Reset βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
| 499 |
@app.post("/reset", tags=["Environment"])
|
| 500 |
async def reset(request: Optional[ResetRequest] = None) -> dict:
|
| 501 |
"""
|
| 502 |
Reset the environment for a new episode.
|
| 503 |
+
Body is fully optional β validator calls this with no body at all.
|
| 504 |
+
Defaults to level='easy' when nothing is sent.
|
|
|
|
|
|
|
|
|
|
| 505 |
"""
|
| 506 |
if request is None:
|
| 507 |
+
request = ResetRequest()
|
| 508 |
|
| 509 |
level = request.level.lower()
|
| 510 |
if level not in LEVEL_MAP:
|
|
|
|
| 513 |
detail=f"Invalid level '{level}'. Must be one of: easy | medium | hard",
|
| 514 |
)
|
| 515 |
|
| 516 |
+
obs = _env.reset(level=level)
|
| 517 |
+
first_task = _env.scenarios[_env.current_task_idx]
|
| 518 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
return {
|
| 520 |
"observation": obs,
|
| 521 |
+
"task_id": _TYPE_TO_TASK_ID.get(first_task["type"].upper(), first_task["id"]),
|
| 522 |
+
"task_group": first_task["level"],
|
| 523 |
+
"level": _env.active_level,
|
| 524 |
"total_tasks": len(_env.scenarios),
|
| 525 |
}
|
| 526 |
|
| 527 |
|
| 528 |
+
# ββ Step ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 529 |
@app.post("/step", tags=["Environment"])
|
| 530 |
async def step(action: PhishAction) -> dict:
|
| 531 |
obs, reward, done, info = _env.step(action.action)
|
|
|
|
| 540 |
}
|
| 541 |
|
| 542 |
|
| 543 |
+
# ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 544 |
@app.get("/state", tags=["Environment"])
|
| 545 |
async def state() -> dict:
|
| 546 |
return {
|
|
|
|
| 554 |
}
|
| 555 |
|
| 556 |
|
| 557 |
+
# ββ Entry point βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 558 |
if __name__ == "__main__":
|
| 559 |
import uvicorn
|
| 560 |
+
uvicorn.run("env:app", host="0.0.0.0", port=7860, reload=False, log_level="info")
|