mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 06:30:25 +00:00
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""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
|