File size: 23,297 Bytes
da0a862
 
 
 
 
 
 
 
 
 
ee929ba
da0a862
 
 
 
 
ee929ba
 
da0a862
 
 
 
 
 
ee929ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da0a862
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee929ba
da0a862
ee929ba
 
 
 
 
 
 
 
da0a862
 
 
 
 
 
 
 
 
ee929ba
 
 
 
da0a862
 
 
ee929ba
 
 
da0a862
 
 
 
 
ee929ba
 
 
 
da0a862
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee929ba
 
 
 
da0a862
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee929ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da0a862
 
 
 
 
ee929ba
da0a862
 
 
 
 
 
 
ee929ba
da0a862
ee929ba
 
 
 
 
 
 
 
da0a862
 
 
ee929ba
da0a862
ee929ba
 
 
da0a862
 
 
 
 
 
 
 
 
 
ee929ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da0a862
 
 
 
 
 
 
ee929ba
 
da0a862
ee929ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da0a862
 
ee929ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da0a862
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee929ba
 
 
 
da0a862
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee929ba
 
 
 
da0a862
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
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()


# ---------------------------------------------------------------------------
# Secret visibility tiers
# ---------------------------------------------------------------------------

class SecretVisibility(str, Enum):
    SURFACE   = "surface"    # visible in raw file content on first obs
    SHALLOW   = "shallow"    # visible after agent calls inspect_file(path)
    DEEP      = "deep"       # visible only after inspect_git_history or inspect_encoded
    CASCADING = "cascading"  # revealed only AFTER another secret is fixed/removed


# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------

@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)
    # Internal tracking (not serialised in to_dict output below)
    _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)
        # Remove private tracking fields from API output
        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


# ---------------------------------------------------------------------------
# Environment
# ---------------------------------------------------------------------------

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

    # ------------------------------------------------------------------ #
    # Public API                                                           #
    # ------------------------------------------------------------------ #

    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,
            )

            # Build initial visible secrets
            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"])

            # ---- Parse structured actions first -------------------------
            structured_result = self._handle_structured_action(action, session)
            if structured_result is not None:
                exit_code, stdout, stderr, timed_out = structured_result
            else:
                # Fall through to raw bash execution
                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,
            )

            # ---- Re-grade -----------------------------------------------
            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,
            )

            # ---- Infer fixed secrets (leaks dropped → mark surface secrets fixed) ----
            self._update_fixed_secrets(session, meta, security)

            # ---- Recompute visible secrets & obs fields ------------------
            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()

    # ------------------------------------------------------------------ #
    # Structured action handlers                                           #
    # ------------------------------------------------------------------ #

    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)   # also unlock by path alone
            return 0, f"[inspect_encoded] Decoded blob at '{path}:{line}'. DEEP encoded secrets now visible.", "", False

        return None

    # ------------------------------------------------------------------ #
    # Visibility computation                                               #
    # ------------------------------------------------------------------ #

    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":
                    # Check path or path:line in deep_inspected
                    if path in deep_inspected or any(k.startswith(path + ":") for k in deep_inspected):
                        visible.append(s)
                else:
                    # inspect_git_history: any deep_inspected entry unlocks it
                    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:
            # Some leaks were fixed — mark unresolved surface secrets iteratively
            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   # mark one per step

    # ------------------------------------------------------------------ #
    # Conflict map                                                         #
    # ------------------------------------------------------------------ #

    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

    # ------------------------------------------------------------------ #
    # Task management                                                      #
    # ------------------------------------------------------------------ #

    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)

    # ------------------------------------------------------------------ #
    # Observation building                                                 #
    # ------------------------------------------------------------------ #

    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"

    # ------------------------------------------------------------------ #
    # Path masking                                                         #
    # ------------------------------------------------------------------ #

    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