"""What the agent itself created this session — and the one fact that follows (OPE-114 §1). The reviewer is never shown file contents, so `python scripts/setup.py` cannot be judged from its text: the effect lives inside a file neither the reviewer nor the human at the card is shown. But the engine knows something neither of them does — whether it wrote or downloaded that file moments ago. This module keeps that record and renders it as one line of fixed-vocabulary fact. Deliberately NOT here: reading file contents, analysing what a script does, or tracing values out of untrusted text (the general taint tracking of OPE-114 is a separate, larger design). A miss leaves behaviour exactly as it is today, so partial coverage only ever moves toward caution — unlike a detector, whose false negatives would breed false confidence. """ from __future__ import annotations import shlex from dataclasses import dataclass from pathlib import Path from typing import Any, Optional WRITTEN = "written" DOWNLOADED = "downloaded" # Download-shaped tools that resolve their own target and report it in the RESULT. Reading # the result beats guessing an argument name: it records where the bytes actually landed # rather than what was asked for. # NOTE web_fetch is deliberately absent: it returns page text and never writes a file, so # it creates nothing to later execute. Listing it here would claim coverage we do not have. _DOWNLOAD_RESULT_TOOLS = { "github_clone", "github_pull", "email_download_attachment", } # Shell fetchers: program -> flags whose VALUE names an output path. `curl -O` (no value, # saves under the URL's basename) is handled separately. Case matters for the unix tools — # curl's `-o FILE` and `-O` are different flags — so only the PowerShell names below are # folded, and their table entries are pre-lowercased. _FETCHER_OUTPUT_FLAGS = { "curl": {"-o", "--output"}, "wget": {"-O", "--output-document"}, "invoke-webrequest": {"-outfile"}, "iwr": {"-outfile"}, } _CASE_FOLDED_FETCHERS = {"invoke-webrequest", "iwr"} # Programs whose real input is a file they never name on the command line. Without this, # `make deploy` would look like it touches nothing at all. _IMPLICIT_TARGETS: dict[str, tuple[str, ...]] = { "make": ("Makefile", "makefile", "GNUmakefile"), "npm": ("package.json",), "pnpm": ("package.json",), "yarn": ("package.json",), "bun": ("package.json",), "pytest": ("conftest.py",), "tox": ("tox.ini",), "nox": ("noxfile.py",), "docker-compose": ( "docker-compose.yml", "docker-compose.yaml", "compose.yaml", "compose.yml", ), } # Extensions that make a bare token (no path separator) worth resolving as a file. _SCRIPT_SUFFIXES = { ".py", ".sh", ".bash", ".zsh", ".js", ".mjs", ".cjs", ".ts", ".rb", ".pl", ".php", ".ps1", ".bat", ".cmd", ".jar", ".exe", ".json", ".yml", ".yaml", ".ini", ".toml", ".cfg", ".mk", } @dataclass(frozen=True) class Origin: """How a path came into being this session, and at which step.""" step: int kind: str # WRITTEN | DOWNLOADED @dataclass(frozen=True) class Match: """A proposed call naming a path this session created.""" path: str # as written in the call, for the human-facing line origin: Origin steps_ago: int @property def downloaded(self) -> bool: return self.origin.kind == DOWNLOADED def render(self) -> str: """One line, fixed vocabulary — never file content, never outside-authored text.""" verb = "downloaded" if self.downloaded else "created" if self.steps_ago <= 0: when = "just now" elif self.steps_ago == 1: when = "1 step ago" else: when = f"{self.steps_ago} steps ago" return f"{self.path} was {verb} by the agent {when}" def resolve(path: str, root: Path) -> str: """One canonical key per file, so `./a.py`, `a.py` and the absolute form collapse. Mirrors the permission engine's scoping resolution: relative paths hang off the workspace root, absolute and `~` forms are taken as-is.""" p = Path(str(path)).expanduser() try: return str(p.resolve() if p.is_absolute() else (root / p).resolve()) except (OSError, ValueError): # pragma: no cover - unresolvable exotic path return str(p) def _looks_like_path(token: str) -> bool: if not token or token.startswith("-") or "://" in token: return False if "/" in token or "\\" in token: return True return Path(token).suffix.lower() in _SCRIPT_SUFFIXES def _program(argv: list[str]) -> str: name = Path(argv[0]).name.lower() return name[:-4] if name.endswith(".exe") else name def _sub_commands(command: str) -> list[list[str]]: """Every sub-command of a compound command, tokenized. Splitting is textual and deliberately over-eager (see `permissions._split_commands`): more parts to scan can only ever surface more paths, never hide one.""" from .permissions import _split_commands out: list[list[str]] = [] for part in _split_commands(command): try: argv = shlex.split(part) except ValueError: argv = part.split() # unbalanced quotes: still worth scanning for paths if argv: out.append(argv) return out def command_paths(command: str) -> list[str]: """Every path a shell command names, plus the implicit files it would read. No attempt is made to work out WHICH token is "the script" — every path-like token is returned and checked. Semantics-free and conservative: understanding the command is exactly the thing that cannot be done reliably from its text. Known misses, by design rather than oversight: a file that only becomes involved through an import or include (agent writes `helper.py`, runs `main.py`) is invisible here, and no cheap analysis would find it.""" found: list[str] = [] for argv in _sub_commands(command): found.extend(t for t in argv[1:] if _looks_like_path(t)) program = _program(argv) if program == "docker" and len(argv) > 1 and argv[1].lower() == "compose": program = "docker-compose" found.extend(_IMPLICIT_TARGETS.get(program, ())) if _looks_like_path(argv[0]): found.append(argv[0]) # ./run.sh return found def _shell_download_paths(command: str) -> list[str]: """Output paths of fetch commands. `curl URL | sh` writes no file and needs no entry: a pipe already costs a command its prefix eligibility, so it gates today.""" out: list[str] = [] for argv in _sub_commands(command): program = _program(argv) flags = _FETCHER_OUTPUT_FLAGS.get(program) if not flags: continue folded = program in _CASE_FOLDED_FETCHERS for i, token in enumerate(argv[1:], start=1): probe = token.lower() if folded else token if probe in flags and i + 1 < len(argv): out.append(argv[i + 1]) if program == "curl" and "-O" in argv[1:]: # curl -O saves under the URL's own basename. for candidate in argv[1:]: if "://" in candidate: name = candidate.split("?")[0].rstrip("/").rsplit("/", 1)[-1] if name: out.append(name) break return out def created_paths( tool_name: str, arguments: dict[str, Any], result: Any ) -> tuple[list[str], str]: """(paths, origin) for a call that just SUCCEEDED, or ([], "") when it created nothing.""" from .permissions import write_paths from .risk import WRITE_TOOLS if tool_name in WRITE_TOOLS: paths, located = write_paths(tool_name, arguments or {}) return (paths, WRITTEN) if located and paths else ([], "") if tool_name in _DOWNLOAD_RESULT_TOOLS: path = result.get("path") if isinstance(result, dict) else None return ([str(path)], DOWNLOADED) if path else ([], "") if tool_name == "run_shell": fetched = _shell_download_paths(str((arguments or {}).get("command", ""))) return (fetched, DOWNLOADED) if fetched else ([], "") return ([], "") def referenced_paths(tool_name: str, arguments: dict[str, Any]) -> list[str]: """Paths a PROPOSED call would run or act on. Shell only in phase 1: it is where the write-then-execute chain lands, and where the command text hides the effect.""" if tool_name == "run_shell": return command_paths(str((arguments or {}).get("command", ""))) return [] class SessionFiles: """Per-session record of what the agent created. Runtime-only, like the engine's other reviewer state: a restart starts clean rather than inheriting stale provenance.""" def __init__(self, workspace_root: Path) -> None: self.root = Path(workspace_root) self._files: dict[str, Origin] = {} def record( self, tool_name: str, arguments: dict[str, Any], result: Any, *, step: int ) -> None: """Note what a SUCCESSFUL call created. Callers must not record failed calls: a write that raised left nothing on disk to run.""" paths, origin = created_paths(tool_name, arguments, result) for path in paths: # A later write or download over the same path wins — the newer bytes are the # ones that would execute. self._files[resolve(path, self.root)] = Origin(step=step, kind=origin) def match( self, tool_name: str, arguments: dict[str, Any], *, step: int ) -> Optional[Match]: """The most recently created path this call names, or None. Newest wins: it is the one whose contents the agent most recently controlled.""" best: Optional[Match] = None for path in referenced_paths(tool_name, arguments): origin = self._files.get(resolve(path, self.root)) if origin is None: continue candidate = Match( path=path, origin=origin, steps_ago=max(step - origin.step, 0) ) if best is None or candidate.origin.step > best.origin.step: best = candidate return best