okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petupdate dependencies across cli, renderer and toolingbd84040

main
7.6 KiB227 linesraw
1"""Bounded file-access evidence and before/after diffs for the terminal view.
2
3This is observation, not a sandbox. External processes and native writes may bypass it.
4"""
5
6from __future__ import annotations
7
8from collections.abc import Callable, Generator
9from typing import Protocol, cast
10import albedo_shell
11import contextlib
12import contextvars
13import difflib
14import os
15import shlex
16import sys
17
18GUARD = contextvars.ContextVar("albedo_trace_guard", default=False)
19LIMIT = 128 * 1024
20
21
22@contextlib.contextmanager
23def unobserved() -> Generator[None, None, None]:
24    """Harness bookkeeping inside this block, and tasks created in it, stay out of the trace."""
25    token = GUARD.set(True)
26    try:
27        yield
28    finally:
29        GUARD.reset(token)
30
31
32def text(value: object) -> str:
33    return (
34        os.fsdecode(value)
35        if isinstance(value, (str, bytes, os.PathLike))
36        else str(value)
37    )
38
39
40def command(args: object) -> str:
41    """The command as a person would type it: no `sh -c` wrapper, no store path on argv[0]."""
42    if not isinstance(args, (list, tuple)):
43        return text(args)
44    argv = [text(arg) for arg in args]
45    if not argv:
46        return ""
47    script = albedo_shell.shell_script(argv)
48    if script is not None:
49        return script
50    name = os.path.basename(argv[0]) if os.path.isabs(argv[0]) else argv[0]
51    return shlex.join([name, *argv[1:]])
52
53
54class Trace:
55    def __init__(self):
56        self.activities: dict[tuple[str, str], dict[str, str]] = {}
57        self.before: dict[str, str | None] = {}
58        self.truncated: bool = False
59        self.sealed: bool = False
60
61    def activity(self, kind: str, target: object, detail: str = "") -> None:
62        if self.sealed:
63            return
64        if len(self.activities) >= 64:
65            self.truncated = True
66        else:
67            target = str(target)[:1000]
68            entry = {"kind": kind, "target": target}
69            if detail:
70                self.truncated |= len(detail) > 16000
71                entry["detail"] = detail[:16000]
72            self.activities[(kind, target)] = entry
73
74    def renamed(self, source: str, destination: str) -> None:
75        if self.sealed or source == destination:
76            return
77        # Atomic editors write a temporary file then replace the real target.
78        # The temporary file is not an edit; snapshot the destination before rename.
79        self.writing(destination)
80        self.before.pop(source, None)
81
82    def writing(self, path: str) -> None:
83        if self.sealed or path in self.before:
84            return
85        if len(self.before) >= 16:
86            self.truncated = True
87            return
88        self.before[path] = snapshot(path)
89
90    def finish(self) -> dict[str, object]:
91        if self.sealed:
92            raise RuntimeError("trace is already sealed")
93        self.sealed = True
94        with unobserved():
95            changes: list[dict[str, str | int]] = []
96            for path, before in self.before.items():
97                after = snapshot(path)
98                if before == after:
99                    continue
100                if before is None or after is None:
101                    changes.append(
102                        {
103                            "path": path[:1000],
104                            "kind": "unavailable",
105                            "reason": "binary, unreadable, or larger than 128 KiB",
106                        }
107                    )
108                    continue
109                lines = list(
110                    difflib.unified_diff(
111                        before.splitlines(True),
112                        after.splitlines(True),
113                        fromfile=path,
114                        tofile=path,
115                    )
116                )
117                diff = "".join(lines)
118                self.truncated |= len(diff) > 16000
119                changes.append(
120                    {
121                        "path": path[:1000],
122                        "kind": "diff",
123                        "diff": diff[:16000],
124                        "added": sum(
125                            line.startswith("+") and not line.startswith("+++")
126                            for line in lines
127                        ),
128                        "removed": sum(
129                            line.startswith("-") and not line.startswith("---")
130                            for line in lines
131                        ),
132                    }
133                )
134            return {
135                "activities": [
136                    activity.copy() for activity in self.activities.values()
137                ],
138                "changes": changes,
139                "truncated": self.truncated,
140            }
141
142    def release(self) -> None:
143        if not self.sealed:
144            raise RuntimeError("trace must be sealed before release")
145        self.before.clear()
146        self.activities.clear()
147
148
149def snapshot(path: str) -> str | None:
150    try:
151        if not os.path.exists(path):
152            return ""
153        if not os.path.isfile(path) or os.path.getsize(path) > LIMIT:
154            return None
155        with open(path, "rb") as file:
156            value = file.read(LIMIT + 1)
157        return (
158            value.decode("utf-8")
159            if len(value) <= LIMIT and b"\0" not in value
160            else None
161        )
162    except (OSError, UnicodeError):
163        return None
164
165
166class TracedCapture(Protocol):
167    trace: Trace
168
169
170def _no_capture() -> None:
171    return None
172
173
174current: Callable[[], TracedCapture | None] = _no_capture
175
176
177def note(kind: str, target: str, detail: str = "") -> None:
178    """Record what a harness tool did for the running cell, as it was asked
179    for: the command run() started, the pattern files.find searched. `detail`
180    is what the person watching sees of its result, such as a search's answer."""
181    capture = current()
182    if capture is not None:
183        capture.trace.activity(kind, target, detail)
184
185
186def install(get_capture: Callable[[], TracedCapture | None]) -> None:
187    global current
188    current = get_capture
189
190    def audit(event: str, args: tuple[object, ...]) -> None:
191        capture = get_capture()
192        if capture is None or capture.trace.sealed or GUARD.get():
193            return
194        token = GUARD.set(True)
195        try:
196            if event == "open" and isinstance(args[0], (str, bytes)):
197                path = os.path.abspath(os.fsdecode(args[0]))
198                if path == "/proc" or path.startswith("/proc/"):
199                    return  # process supervision is harness bookkeeping, not user exploration
200                flags = args[2]
201                if not isinstance(flags, int):
202                    raise TypeError("invalid open flags")
203                if flags & (
204                    os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND
205                ):
206                    capture.trace.writing(path)
207                else:
208                    capture.trace.activity("read", path)
209            elif event in ("os.rename", "os.replace") and len(args) >= 2:
210                source, destination = (
211                    os.path.abspath(os.fsdecode(cast(str | bytes, path)))
212                    for path in args[:2]
213                )
214                capture.trace.renamed(source, destination)
215            elif event in ("os.listdir", "os.scandir"):
216                path = os.fsdecode(cast(str | bytes, args[0])) if args else "."
217                if path != "/proc" and not path.startswith("/proc/"):
218                    capture.trace.activity("list", path)
219            elif event == "subprocess.Popen":
220                capture.trace.activity("run", command(args[1]))
221        except Exception:
222            if not capture.trace.sealed:
223                capture.trace.truncated = True
224        finally:
225            GUARD.reset(token)
226
227    sys.addaudithook(audit)