| from __future__ import annotations |
|
|
| import json |
| import os |
| import shutil |
| import subprocess |
| import threading |
| import uuid |
| from dataclasses import asdict, dataclass, field |
| from datetime import datetime, timezone |
| from enum import Enum |
| from pathlib import Path |
| from typing import Any |
|
|
| from graders.gitleaks_eval import SecurityGrader, git_integrity_message |
| from graders.health_eval import HealthGrader, health_failure_message |
| from graders.reward import STEP_BUDGETS, RewardBreakdown |
| from server.observation import compute_ranked_actions, get_top_blocker |
|
|
|
|
| def utc_now() -> str: |
| return datetime.now(timezone.utc).isoformat() |
|
|
|
|
| |
| |
| |
|
|
| class SecretVisibility(str, Enum): |
| SURFACE = "surface" |
| SHALLOW = "shallow" |
| DEEP = "deep" |
| CASCADING = "cascading" |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class CommandResult: |
| action: str |
| exit_code: int |
| stdout: str |
| stderr: str |
| timed_out: bool |
| executed_at: str = field(default_factory=utc_now) |
|
|
|
|
| @dataclass |
| class SessionState: |
| session_id: str |
| task_id: int |
| category: str |
| title: str |
| description: str |
| workspace: str |
| initial_leaks: int |
| current_leaks: int |
| security_score: float |
| health_score: float |
| efficiency_bonus: float |
| reward: float |
| step_budget: int |
| steps_taken: int |
| steps_remaining: int |
| visible_secrets: list[dict[str, Any]] = field(default_factory=list) |
| hidden_count_hint: int = 0 |
| ranked_actions: list[dict[str, Any]] = field(default_factory=list) |
| top_blocker: str = "" |
| conflict_map: dict[str, Any] = field(default_factory=dict) |
| health_stdout: str = "" |
| health_stderr: str = "" |
| health_exit_code: int = 0 |
| observation: str = "" |
| error: str = "none" |
| step_count: int = 0 |
| last_result: CommandResult | None = None |
| created_at: str = field(default_factory=utc_now) |
| updated_at: str = field(default_factory=utc_now) |
| |
| _inspected_files: set[str] = field(default_factory=set, repr=False) |
| _deep_inspected: set[str] = field(default_factory=set, repr=False) |
| _fixed_secrets: set[str] = field(default_factory=set, repr=False) |
|
|
| def to_dict(self) -> dict[str, Any]: |
| payload = asdict(self) |
| |
| for private in ("_inspected_files", "_deep_inspected", "_fixed_secrets"): |
| payload.pop(private, None) |
| if self.last_result is None: |
| payload["last_result"] = None |
| return payload |
|
|
|
|
| |
| |
| |
|
|
| class SecretsAuditEnvironment: |
| def __init__( |
| self, |
| repo_root: Path | None = None, |
| runtime_root: Path | None = None, |
| command_timeout_seconds: int = 90, |
| ) -> None: |
| self.repo_root = (repo_root or Path(__file__).resolve().parents[1]).resolve() |
| self.tasks_root = self.repo_root / "tasks" |
| self.runtime_root = (runtime_root or self.repo_root / "runtime").resolve() |
| self.runtime_root.mkdir(parents=True, exist_ok=True) |
| self.command_timeout_seconds = command_timeout_seconds |
| self.security_grader = SecurityGrader() |
| self.health_grader = HealthGrader() |
| self._lock = threading.RLock() |
| self._session: SessionState | None = None |
| self._task_meta: dict[str, Any] | None = None |
| self._workspace_path: Path | None = None |
|
|
| |
| |
| |
|
|
| def list_tasks(self) -> list[dict[str, Any]]: |
| tasks: list[dict[str, Any]] = [] |
| for task_file in sorted(self.tasks_root.glob("*/task_*/task.json")): |
| with task_file.open("r", encoding="utf-8") as handle: |
| tasks.append(json.load(handle)) |
| return tasks |
|
|
| def state(self) -> dict[str, Any]: |
| with self._lock: |
| payload = { |
| "session": None if self._session is None else self._session.to_dict(), |
| "available_tasks": self.list_tasks(), |
| } |
| return self._mask_value(payload) |
|
|
| def reset(self, task_id: int) -> dict[str, Any]: |
| with self._lock: |
| task_dir = self._locate_task(task_id) |
| meta = self._load_task_meta(task_dir) |
| session_id = str(uuid.uuid4()) |
| workspace = self.runtime_root / f"session_{session_id}" |
| workspace.parent.mkdir(parents=True, exist_ok=True) |
|
|
| self._materialize_task(task_dir, meta, workspace) |
| if not (workspace / ".git").exists(): |
| self._initialize_git_workspace(workspace) |
|
|
| workspace = workspace.resolve() |
| initial_security = self.security_grader.grade(workspace, meta) |
| initial_health = self.health_grader.grade(workspace) |
| difficulty = meta.get("category", "medium") |
| step_budget = STEP_BUDGETS.get(difficulty, STEP_BUDGETS["medium"]) |
|
|
| reward = RewardBreakdown.from_reports( |
| initial_security, |
| initial_health, |
| steps_taken=0, |
| difficulty=difficulty, |
| ) |
|
|
| |
| all_secrets: list[dict[str, Any]] = meta.get("secrets", []) |
| known_files = list({s["path"] for s in all_secrets}) |
| empty_set: set[str] = set() |
| visible = self._compute_visible_secrets(all_secrets, empty_set, empty_set, empty_set) |
| hidden_count = len(all_secrets) - len(visible) |
| obs_text = self._build_observation(initial_health, initial_security, None) |
|
|
| ranked = compute_ranked_actions( |
| inspected_files=empty_set, |
| deep_inspected=empty_set, |
| fixed_secrets=empty_set, |
| visible_secrets=visible, |
| known_files=known_files, |
| difficulty=difficulty, |
| hidden_count=hidden_count, |
| ) |
| blocker = get_top_blocker(visible, empty_set, hidden_count, difficulty) |
| conflict_map = self._build_conflict_map(meta, visible) |
|
|
| self._task_meta = meta |
| self._workspace_path = workspace |
| self._session = SessionState( |
| session_id=session_id, |
| task_id=meta["id"], |
| category=difficulty, |
| title=meta["title"], |
| description=meta.get("description", ""), |
| workspace=".", |
| initial_leaks=initial_security.finding_count, |
| current_leaks=initial_security.finding_count, |
| security_score=reward.security_score, |
| health_score=reward.health_score, |
| efficiency_bonus=reward.efficiency_bonus, |
| reward=reward.total_reward, |
| step_budget=step_budget, |
| steps_taken=0, |
| steps_remaining=step_budget, |
| visible_secrets=visible, |
| hidden_count_hint=hidden_count, |
| ranked_actions=ranked, |
| top_blocker=blocker, |
| conflict_map=conflict_map, |
| health_stdout=initial_health.stdout, |
| health_stderr=initial_health.stderr, |
| health_exit_code=initial_health.exit_code, |
| observation=obs_text, |
| error=self._build_error(initial_health, initial_security, None), |
| _inspected_files=set(), |
| _deep_inspected=set(), |
| _fixed_secrets=set(), |
| ) |
| self._session.updated_at = utc_now() |
| return self.state() |
|
|
| def step(self, action: str) -> dict[str, Any]: |
| with self._lock: |
| if self._session is None or self._task_meta is None or self._workspace_path is None: |
| raise RuntimeError("No active session. Call /reset first.") |
|
|
| workspace = self._workspace_path |
| session = self._session |
| meta = self._task_meta |
| difficulty = meta.get("category", "medium") |
| step_budget = STEP_BUDGETS.get(difficulty, STEP_BUDGETS["medium"]) |
|
|
| |
| structured_result = self._handle_structured_action(action, session) |
| if structured_result is not None: |
| exit_code, stdout, stderr, timed_out = structured_result |
| else: |
| |
| timed_out = False |
| try: |
| completed = subprocess.run( |
| ["/usr/bin/bash", "-lc", action], |
| cwd=workspace, |
| capture_output=True, |
| text=True, |
| timeout=self.command_timeout_seconds, |
| ) |
| exit_code = completed.returncode |
| stdout = completed.stdout |
| stderr = completed.stderr |
| except subprocess.TimeoutExpired as exc: |
| timed_out = True |
| exit_code = 124 |
| stdout = exc.stdout or "" |
| stderr = exc.stderr or f"Command timed out after {self.command_timeout_seconds}s." |
|
|
| session.step_count += 1 |
| session.last_result = CommandResult( |
| action=action, |
| exit_code=exit_code, |
| stdout=stdout, |
| stderr=stderr, |
| timed_out=timed_out, |
| ) |
|
|
| |
| security = self.security_grader.grade(workspace, meta) |
| health = self.health_grader.grade(workspace) |
| reward = RewardBreakdown.from_reports( |
| security, |
| health, |
| initial_leaks=session.initial_leaks, |
| steps_taken=session.step_count, |
| difficulty=difficulty, |
| ) |
|
|
| |
| self._update_fixed_secrets(session, meta, security) |
|
|
| |
| all_secrets = meta.get("secrets", []) |
| known_files = list({s["path"] for s in all_secrets}) |
| visible = self._compute_visible_secrets( |
| all_secrets, |
| session._inspected_files, |
| session._deep_inspected, |
| session._fixed_secrets, |
| ) |
| hidden_count = len(all_secrets) - len(visible) |
|
|
| ranked = compute_ranked_actions( |
| inspected_files=session._inspected_files, |
| deep_inspected=session._deep_inspected, |
| fixed_secrets=session._fixed_secrets, |
| visible_secrets=visible, |
| known_files=known_files, |
| difficulty=difficulty, |
| hidden_count=hidden_count, |
| ) |
| blocker = get_top_blocker(visible, session._fixed_secrets, hidden_count, difficulty) |
| conflict_map = self._build_conflict_map(meta, visible) |
|
|
| steps_taken = session.step_count |
| session.current_leaks = security.finding_count |
| session.security_score = reward.security_score |
| session.health_score = reward.health_score |
| session.efficiency_bonus = reward.efficiency_bonus |
| session.reward = reward.total_reward |
| session.steps_taken = steps_taken |
| session.steps_remaining = max(0, step_budget - steps_taken) |
| session.visible_secrets = visible |
| session.hidden_count_hint = hidden_count |
| session.ranked_actions = ranked |
| session.top_blocker = blocker |
| session.conflict_map = conflict_map |
| session.health_stdout = health.stdout |
| session.health_stderr = health.stderr |
| session.health_exit_code = health.exit_code |
| session.observation = self._build_observation(health, security, session.last_result) |
| session.error = self._build_error(health, security, session.last_result) |
| session.updated_at = utc_now() |
| return self.state() |
|
|
| |
| |
| |
|
|
| def _handle_structured_action( |
| self, |
| action: str, |
| session: SessionState, |
| ) -> tuple[int, str, str, bool] | None: |
| """Parse special prefixed actions. Returns (exit_code, stdout, stderr, timed_out) |
| or None if the action should fall through to bash execution.""" |
| stripped = action.strip() |
|
|
| if stripped.startswith("inspect_file "): |
| path = stripped[len("inspect_file "):].strip() |
| session._inspected_files.add(path) |
| return 0, f"[inspect_file] Marked '{path}' as inspected. SHALLOW secrets now visible.", "", False |
|
|
| if stripped.startswith("inspect_git_history"): |
| parts = stripped.split(maxsplit=1) |
| path = parts[1].strip() if len(parts) > 1 else "." |
| session._deep_inspected.add(path) |
| return 0, f"[inspect_git_history] Scanned git history at '{path}'. DEEP secrets now visible.", "", False |
|
|
| if stripped.startswith("inspect_encoded "): |
| rest = stripped[len("inspect_encoded "):].strip().split() |
| path = rest[0] if rest else "." |
| line = rest[1] if len(rest) > 1 else "0" |
| key = f"{path}:{line}" |
| session._deep_inspected.add(key) |
| session._deep_inspected.add(path) |
| return 0, f"[inspect_encoded] Decoded blob at '{path}:{line}'. DEEP encoded secrets now visible.", "", False |
|
|
| return None |
|
|
| |
| |
| |
|
|
| def _compute_visible_secrets( |
| self, |
| all_secrets: list[dict[str, Any]], |
| inspected_files: set[str], |
| deep_inspected: set[str], |
| fixed_secrets: set[str], |
| ) -> list[dict[str, Any]]: |
| """Return the subset of secrets the agent can currently see.""" |
| visible: list[dict[str, Any]] = [] |
| for s in all_secrets: |
| vis = s.get("visibility", SecretVisibility.SURFACE) |
| path = s.get("path", "") |
| trigger = s.get("trigger_secret_id") |
|
|
| if vis == SecretVisibility.SURFACE: |
| visible.append(s) |
| elif vis == SecretVisibility.SHALLOW: |
| if path in inspected_files: |
| visible.append(s) |
| elif vis == SecretVisibility.DEEP: |
| req = s.get("requires_action", "") |
| if req == "inspect_encoded": |
| |
| if path in deep_inspected or any(k.startswith(path + ":") for k in deep_inspected): |
| visible.append(s) |
| else: |
| |
| if deep_inspected: |
| visible.append(s) |
| elif vis == SecretVisibility.CASCADING: |
| if trigger and trigger in fixed_secrets: |
| visible.append(s) |
| return visible |
|
|
| def _update_fixed_secrets( |
| self, |
| session: SessionState, |
| meta: dict[str, Any], |
| security_report: Any, |
| ) -> None: |
| """Heuristically infer which secrets are fixed based on leak count drop. |
| |
| Mark all currently visible surface secrets as fixed if the total |
| finding_count dropped to 0. For more granular tracking we compare |
| current_leaks vs initial_leaks and mark the first unfixed visible |
| surface secret. |
| """ |
| all_secrets = meta.get("secrets", []) |
| if security_report.finding_count == 0: |
| for s in all_secrets: |
| session._fixed_secrets.add(s["id"]) |
| elif security_report.finding_count < session.current_leaks: |
| |
| visible_unfixed = [ |
| s for s in self._compute_visible_secrets( |
| all_secrets, |
| session._inspected_files, |
| session._deep_inspected, |
| session._fixed_secrets, |
| ) |
| if s["id"] not in session._fixed_secrets |
| and s.get("visibility") == SecretVisibility.SURFACE |
| ] |
| for s in visible_unfixed: |
| session._fixed_secrets.add(s["id"]) |
| break |
|
|
| |
| |
| |
|
|
| def _build_conflict_map( |
| self, |
| meta: dict[str, Any], |
| visible_secrets: list[dict[str, Any]], |
| ) -> dict[str, Any]: |
| """Return conflict map filtered to currently-visible secrets only.""" |
| raw_map = meta.get("conflict_map", {}) |
| if not raw_map: |
| return {} |
| visible_ids = {s["id"] for s in visible_secrets} |
| result: dict[str, Any] = {} |
| for secret_id, conflicts in raw_map.items(): |
| if secret_id in visible_ids: |
| result[secret_id] = { |
| "reveals": conflicts.get("reveals", []), |
| "blocks": conflicts.get("blocks", []), |
| "note": conflicts.get("note", ""), |
| } |
| return result |
|
|
| |
| |
| |
|
|
| def _locate_task(self, task_id: int) -> Path: |
| matches = list(self.tasks_root.glob(f"*/task_{task_id:02d}")) |
| if not matches: |
| raise FileNotFoundError(f"Task {task_id} was not found under {self.tasks_root}.") |
| return matches[0] |
|
|
| def _load_task_meta(self, task_dir: Path) -> dict[str, Any]: |
| with (task_dir / "task.json").open("r", encoding="utf-8") as handle: |
| return json.load(handle) |
|
|
| def _materialize_task(self, task_dir: Path, meta: dict[str, Any], workspace: Path) -> None: |
| if workspace.exists(): |
| shutil.rmtree(workspace) |
|
|
| build_script = meta.get("build_script") |
| if build_script: |
| workspace.mkdir(parents=True, exist_ok=True) |
| subprocess.run( |
| [ |
| "python3", |
| str(task_dir / build_script), |
| "--output", |
| str(workspace), |
| ], |
| cwd=task_dir, |
| check=True, |
| ) |
| return |
|
|
| shutil.copytree(task_dir, workspace, ignore=shutil.ignore_patterns("task.json", "build_repo.py")) |
|
|
| def _initialize_git_workspace(self, workspace: Path) -> None: |
| commands = [ |
| ["git", "init"], |
| ["git", "config", "user.name", "SecretsAuditEnv"], |
| ["git", "config", "user.email", "benchmark@example.com"], |
| ["git", "add", "."], |
| ["git", "commit", "-m", "Initial vulnerable state"], |
| ] |
| for command in commands: |
| subprocess.run(command, cwd=workspace, check=True, capture_output=True, text=True) |
|
|
| |
| |
| |
|
|
| def _build_observation(self, health: Any, security: Any, result: CommandResult | None) -> str: |
| parts: list[str] = [] |
| if result is not None: |
| if result.stdout.strip(): |
| parts.append(f"Command stdout:\n{result.stdout.strip()}") |
| if result.stderr.strip(): |
| parts.append(f"Command stderr:\n{result.stderr.strip()}") |
|
|
| health_message = health_failure_message(health) |
| if health_message: |
| parts.append(f"Health check output:\n{health_message}") |
|
|
| git_message = git_integrity_message(security) |
| if git_message: |
| parts.append(git_message) |
|
|
| if not parts: |
| return "none" |
| return "\n\n".join(parts) |
|
|
| def _build_error(self, health: Any, security: Any, result: CommandResult | None) -> str: |
| if result is not None: |
| if result.timed_out: |
| return result.stderr.strip() or "Command timed out." |
| if result.exit_code != 0: |
| return result.stderr.strip() or result.stdout.strip() or "Command failed." |
|
|
| health_message = health_failure_message(health) |
| if health_message: |
| return health_message |
|
|
| git_message = git_integrity_message(security) |
| if git_message: |
| return git_message |
|
|
| return "none" |
|
|
| |
| |
| |
|
|
| def _mask_value(self, value: Any) -> Any: |
| if isinstance(value, dict): |
| return {key: self._mask_value(item) for key, item in value.items()} |
| if isinstance(value, list): |
| return [self._mask_value(item) for item in value] |
| if isinstance(value, str): |
| return self._mask_text(value) |
| return value |
|
|
| def _mask_text(self, text: str) -> str: |
| if not text: |
| return text |
|
|
| masked = text |
| candidates = {str(self.repo_root), os.path.expanduser("~")} |
| if self._workspace_path is not None: |
| candidates.add(str(self._workspace_path)) |
| for candidate in sorted((item for item in candidates if item), key=len, reverse=True): |
| masked = masked.replace(f"{candidate}/", "") |
| masked = masked.replace(candidate, ".") |
| return masked |
|
|