""" Supply Chain Incident Response — Environment Logic Replace the contents of: agentic_security_lab/server/agentic_security_lab_environment.py Reward design (dense — signal every step, not just at episode end) ─────────────────────────────────────────────────────────────────── quarantine correct package +0.15 rotate critical secret +0.12 rotate non-critical secret +0.06 notify affected team +0.04 scan_logs (returns intel) +0.02 inspect_package +0.01 check_dependents +0.01 quarantine clean package (FP) −0.05 rotate already-rotated secret −0.02 invalid / unknown command −0.01 Episode-end bonuses (awarded on conclude or max_steps): all required packages quarantined +0.10 all required secrets rotated +0.10 all required teams notified +0.05 concluded before exfil deadline +0.10 attacker succeeded −0.20 """ import uuid from typing import Any from models import ( AgenticSecurityLabAction, AgenticSecurityLabObservation, AgenticSecurityLabState, ) from scenarios import get_scenario VALID_COMMANDS = { "inspect_package", "check_dependents", "rotate_secret", "quarantine", "notify", "scan_logs", "conclude", } class AgenticSecurityLabEnvironment: """Supply chain incident response RL environment.""" def __init__(self, task_name: str = "easy"): self._task_name = task_name self._state = AgenticSecurityLabState() self._scenario: dict[str, Any] = {} # ── OpenEnv interface ────────────────────────────────────────────────────── def reset(self, task_name: str | None = None) -> AgenticSecurityLabObservation: if task_name: self._task_name = task_name sc = get_scenario(self._task_name) self._scenario = sc self._state = AgenticSecurityLabState( episode_id = str(uuid.uuid4()), step_count = 0, task_name = self._task_name, packages = sc["packages"], dependents = sc["dependents"], secrets = sc["secrets"], max_steps = sc["max_steps"], exfiltration_step = sc["exfiltration_step"], ) malicious = [p for p, d in sc["packages"].items() if d["malicious"]] exposed = list(sc["secrets"].keys()) return AgenticSecurityLabObservation( success = True, done = False, reward = 0.0, result = ( f"[INCIDENT ALERT] Task: {self._task_name.upper()}\n" f"{sc['description']}\n\n" f"Packages in scope : {list(sc['packages'].keys())}\n" f"Exfiltration in : {sc['exfiltration_step']} steps " f"(budget: {sc['max_steps']} steps total)\n\n" f"Commands available: inspect_package, check_dependents, " f"rotate_secret, quarantine, notify, scan_logs, conclude" ), data = {"packages_in_scope": list(sc["packages"].keys())}, incident_summary = f"Incident open. {len(malicious)} malicious package(s) suspected.", steps_remaining = sc["exfiltration_step"], exposed_secrets = exposed, active_malicious_packages = malicious, ) def step(self, action: AgenticSecurityLabAction) -> AgenticSecurityLabObservation: s = self._state # Guard: episode already over if s.incident_contained or s.attacker_succeeded or s.step_count >= s.max_steps: return self._terminal_obs(0.0) s.step_count += 1 steps_left = max(0, s.exfiltration_step - s.step_count) # Check attacker exfiltration trigger if s.step_count >= s.exfiltration_step and not s.attacker_succeeded: crit_exposed = [ k for k, v in s.secrets.items() if not v["rotated"] and v["critical"] ] if crit_exposed: s.attacker_succeeded = True cmd = action.command.strip().lower() params = action.parameters if cmd not in VALID_COMMANDS: reward = -0.01 s.total_reward += reward return self._build_obs( reward = reward, success = False, result = f"Unknown command '{cmd}'. Valid: {sorted(VALID_COMMANDS)}", steps_left = steps_left, error = f"Invalid command: {cmd}", ) # Dispatch dispatch = { "inspect_package": self._cmd_inspect, "check_dependents": self._cmd_check_dependents, "rotate_secret": self._cmd_rotate_secret, "quarantine": self._cmd_quarantine, "notify": self._cmd_notify, "scan_logs": self._cmd_scan_logs, "conclude": self._cmd_conclude, } return dispatch[cmd](params, steps_left) @property def state(self) -> AgenticSecurityLabState: return self._state # ── Command handlers ─────────────────────────────────────────────────────── def _cmd_inspect(self, params: dict, steps_left: int) -> AgenticSecurityLabObservation: pkg = params.get("package", "") if not pkg or pkg not in self._state.packages: return self._build_obs(-0.01, False, f"Package '{pkg}' not found. Known: {list(self._state.packages.keys())}", steps_left, error="Package not found") meta = self._state.packages[pkg] self._state.inspected.append(pkg) reward = 0.01 self._state.total_reward += reward ioc_str = ( f"⚠ IOCs detected: {meta['iocs']}" if meta.get("iocs") else "✓ No IOCs found." ) return self._build_obs( reward = reward, success = True, result = ( f"Inspection — {pkg}\n" f" Publisher : {meta.get('publisher', 'unknown')}\n" f" Published : {meta.get('publish_date', 'unknown')}\n" f" Versions : {meta.get('versions', [])}\n" f" {ioc_str}" ), data = {"package": pkg, "metadata": meta}, steps_left = steps_left, ) def _cmd_check_dependents(self, params: dict, steps_left: int) -> AgenticSecurityLabObservation: pkg = params.get("package", "") deps = self._state.dependents.get(pkg) if deps is None: return self._build_obs(-0.01, False, f"No dependency data for '{pkg}'.", steps_left, error="Package not in dep graph") reward = 0.01 self._state.total_reward += reward return self._build_obs( reward = reward, success = True, result = f"Dependents of {pkg} ({len(deps)} total): {deps}", data = {"package": pkg, "dependents": deps}, steps_left = steps_left, ) def _cmd_rotate_secret(self, params: dict, steps_left: int) -> AgenticSecurityLabObservation: secret = params.get("secret", "") s = self._state if secret not in s.secrets: return self._build_obs(-0.01, False, f"Secret '{secret}' unknown. Known: {list(s.secrets.keys())}", steps_left, error="Secret not found") if s.secrets[secret]["rotated"]: reward = -0.02 s.total_reward += reward return self._build_obs(reward, False, f"Secret '{secret}' was already rotated.", steps_left) s.secrets[secret]["rotated"] = True s.rotated_secrets.append(secret) is_critical = s.secrets[secret]["critical"] reward = 0.12 if is_critical else 0.06 s.total_reward += reward return self._build_obs( reward = reward, success = True, result = ( f"✓ Rotated '{secret}' " f"({'CRITICAL' if is_critical else 'standard'}).\n" f" Owner: {s.secrets[secret]['owner']}. " f" Old value invalidated, new value issued." ), data = {"secret": secret, "critical": is_critical}, steps_left = steps_left, ) def _cmd_quarantine(self, params: dict, steps_left: int) -> AgenticSecurityLabObservation: pkg = params.get("package", "") s = self._state if pkg not in s.packages: return self._build_obs(-0.01, False, f"Package '{pkg}' not in scope.", steps_left, error="Package not found") if pkg in s.quarantined: return self._build_obs(-0.02, False, f"'{pkg}' already quarantined.", steps_left) if not s.packages[pkg]["malicious"]: reward = -0.05 s.total_reward += reward return self._build_obs(reward, False, f"⚠ False positive: '{pkg}' is NOT malicious. " f"Unnecessary quarantine harms users.", steps_left) s.quarantined.append(pkg) reward = 0.15 s.total_reward += reward return self._build_obs( reward = reward, success = True, result = ( f"✓ Yanked '{pkg}' from registry. " f"Download counter zeroed. Consumers will see 404 on next install." ), data = {"package": pkg}, steps_left = steps_left, ) def _cmd_notify(self, params: dict, steps_left: int) -> AgenticSecurityLabObservation: team = params.get("team", "") s = self._state all_teams: set[str] = set() for teams in s.dependents.values(): all_teams.update(teams) if team not in all_teams: return self._build_obs(-0.01, False, f"Team '{team}' not in affected list.", steps_left, error="Unknown team") if team in s.notified_teams: return self._build_obs(0.0, True, f"Team '{team}' already notified.", steps_left) s.notified_teams.append(team) reward = 0.04 s.total_reward += reward return self._build_obs( reward = reward, success = True, result = f"✓ Notified '{team}': breach alert sent with remediation steps.", data = {"team": team}, steps_left = steps_left, ) def _cmd_scan_logs(self, params: dict, steps_left: int) -> AgenticSecurityLabObservation: pkg = params.get("package", "") hints = self._scenario.get("scan_logs_hints", {}) if pkg not in self._state.packages: return self._build_obs(-0.01, False, f"Package '{pkg}' not in scope.", steps_left, error="Package not found") self._state.scanned_logs.append(pkg) log_result = hints.get(pkg, f"No suspicious entries found for {pkg}.") reward = 0.02 self._state.total_reward += reward return self._build_obs( reward = reward, success = True, result = f"CI/CD log scan — {pkg}:\n {log_result}", data = {"package": pkg, "log_excerpt": log_result}, steps_left = steps_left, ) def _cmd_conclude(self, _params: dict, steps_left: int) -> AgenticSecurityLabObservation: s = self._state req = self._scenario["required_actions"] q_done = set(s.quarantined) >= set(req["quarantine"]) r_done = set(s.rotated_secrets) >= set(req["rotate_secret"]) n_done = set(s.notified_teams) >= set(req["notify"]) bonus = 0.0 if q_done: bonus += 0.10 if r_done: bonus += 0.10 if n_done: bonus += 0.05 if not s.attacker_succeeded: bonus += 0.10 if s.attacker_succeeded: bonus -= 0.20 s.total_reward += bonus s.incident_contained = True missing_q = set(req["quarantine"]) - set(s.quarantined) missing_r = set(req["rotate_secret"]) - set(s.rotated_secrets) missing_n = set(req["notify"]) - set(s.notified_teams) result = ( f"{'✓' if q_done else '✗'} Packages quarantined : " f"{len(s.quarantined)}/{len(req['quarantine'])}" + (f" — missing: {missing_q}" if missing_q else "") + "\n" f"{'✓' if r_done else '✗'} Secrets rotated : " f"{len(s.rotated_secrets)}/{len(req['rotate_secret'])}" + (f" — missing: {missing_r}" if missing_r else "") + "\n" f"{'✓' if n_done else '✗'} Teams notified : " f"{len(s.notified_teams)}/{len(req['notify'])}" + (f" — missing: {missing_n}" if missing_n else "") + "\n" f"{'✓ Contained before exfiltration' if not s.attacker_succeeded else '✗ Attacker exfiltrated credentials'}\n" f"Total episode reward : {s.total_reward:.3f}" ) return self._terminal_obs(bonus, result=result) # ── Helpers ──────────────────────────────────────────────────────────────── def _build_obs( self, reward: float, success: bool, result: str, steps_left: int, data: dict | None = None, error: str | None = None, ) -> AgenticSecurityLabObservation: s = self._state malicious_left = [ p for p, d in s.packages.items() if d["malicious"] and p not in s.quarantined ] exposed_left = [k for k, v in s.secrets.items() if not v["rotated"]] done = s.incident_contained or s.attacker_succeeded or s.step_count >= s.max_steps return AgenticSecurityLabObservation( success = success, done = done, reward = reward, result = result, data = data or {}, incident_summary = ( f"Step {s.step_count}/{s.max_steps} | " f"Quarantined: {len(s.quarantined)} | " f"Rotated: {len(s.rotated_secrets)} | " f"Notified: {len(s.notified_teams)} | " f"Attacker: {'⚠ ACTIVE' if s.attacker_succeeded else 'not yet'}" ), steps_remaining = steps_left, exposed_secrets = exposed_left, active_malicious_packages = malicious_left, error = error, ) def _terminal_obs(self, reward: float, result: str = "") -> AgenticSecurityLabObservation: s = self._state s.incident_contained = True return AgenticSecurityLabObservation( success = True, done = True, reward = reward, result = result or "Episode ended.", incident_summary = ( f"CLOSED | Steps: {s.step_count} | " f"Total reward: {s.total_reward:.3f}" ), steps_remaining = 0, exposed_secrets = [k for k, v in s.secrets.items() if not v["rotated"]], active_malicious_packages = [], )