okami.mom/albedo

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

nekomimi.petmake python kernel friendlier based on tracesd4842e6

main
9.8 KiB286 linesraw
1"""Shell lines read as run() calls, and the guard that keeps cells on run().
2
3Cells start programs with run(program, *args): no shell, supervised, traced.
4A shell line or a raw process API from a cell is refused with the run() call
5it means when the line is simple enough to read, so the refusal teaches by
6example. A time.sleep of a second or more is refused too: it blocks the whole
7kernel, and the refusal says to run the work async or to wait on purpose. This is guidance, not a sandbox: libraries and plugins still spawn.
8"""
9
10from __future__ import annotations
11
12from collections.abc import Sequence
13import os
14import re
15import shlex
16import sys
17import sysconfig
18
19SHELLS = {"sh", "bash", "zsh", "dash", "ksh", "fish", "nu"}
20CELL_PREFIX = "<albedo:"  # the filename every cell compiles under
21BLOCKING_SLEEP = 1.0  # seconds; a shorter time.sleep from a cell is let through
22SPAWN_EVENTS = {
23    "subprocess.Popen",
24    "os.system",
25    "os.posix_spawn",
26    "os.exec",
27    "os.spawn",
28}
29# stdlib frames sit between a cell and the spawn it asked for; site-packages do not
30STDLIB = tuple({sysconfig.get_paths()[key] for key in ("stdlib", "platstdlib")})
31HINTS = (
32    "cd dir && … → cwd=, NAME=value → env=, a | b → run(a…).pipe(b…), "
33    "| tail -n N → .tail(lines=N), | head -n N → .head(lines=N), 2>&1 is implied, "
34    "and chains, loops, and globs are Python over the job's output."
35)
36
37
38class Refused(PermissionError):
39    """A shell or raw process API where run() belongs."""
40
41
42def shell_script(argv: Sequence[str]) -> str | None:
43    """The script of `sh -c script` and its spellings (`bash -lc`), else None."""
44    if not argv or os.path.basename(argv[0]) not in SHELLS:
45        return None
46    for index, arg in enumerate(argv[1:], 1):
47        if arg.startswith("--"):
48            continue  # --norc, --login
49        if not arg.startswith("-"):
50            return None
51        if "c" in arg[1:]:
52            return argv[index + 1] if index + 1 < len(argv) else ""
53    return None
54
55
56def words(items: Sequence[object]) -> list[str]:
57    """run()'s arguments as argv: text, paths, and numbers."""
58    argv = []
59    for item in items:
60        if isinstance(item, (str, bytes, os.PathLike)):
61            argv.append(os.fsdecode(item))
62        elif isinstance(item, (int, float)) and not isinstance(item, bool):
63            argv.append(str(item))
64        else:
65            raise TypeError(
66                f"run() arguments are text, paths, or numbers, not {type(item).__name__}"
67            )
68    return argv
69
70
71def call(
72    argv: Sequence[str], cwd: str | None = None, env: dict[str, str] | None = None
73) -> str:
74    """argv as the run() call that starts it."""
75    parts = [repr(arg) for arg in argv]
76    if cwd:
77        parts.append(f"cwd={cwd!r}")
78    if env:
79        parts.append(f"env={env!r}")
80    return f"run({', '.join(parts)})"
81
82
83def _unquoted(script: str) -> str:
84    """script with quoted text blanked, so shell syntax outside quotes shows."""
85    out, quote, escaped = [], "", False
86    for char in script:
87        if escaped:
88            out.append(" ")
89            escaped = False
90        elif char == "\\" and quote != "'":
91            out.append(" ")
92            escaped = True
93        elif quote:
94            out.append(" ")
95            quote = "" if char == quote else quote
96        elif char in "'\"":
97            out.append(" ")
98            quote = char
99        else:
100            out.append(char)
101    return "".join(out)
102
103
104_LIMIT = re.compile(r"^(head|tail)(?:\s+-n\s*(\d+)|\s+-(\d+))?$")
105
106
107def _split(text: str, plain: str, separator: str) -> list[tuple[str, str]]:
108    """text, and its unquoted form, cut where separator stands outside quotes."""
109    parts, start = [], 0
110    for match in re.finditer(separator, plain):
111        parts.append((text[start : match.start()], plain[start : match.start()]))
112        start = match.end()
113    parts.append((text[start:], plain[start:]))
114    return [part for part in parts if part[0].strip()]
115
116
117def _stage(text: str) -> tuple[list[str], dict[str, str]]:
118    """One pipeline stage's argv and the NAME=value words before it."""
119    words, env = shlex.split(text), {}
120    while words and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", words[0]):
121        name, _, value = words.pop(0).partition("=")
122        env[name] = value
123    if not words:
124        raise ValueError("a stage with no program")
125    return words, env
126
127
128def translate(script: str) -> str | None:
129    """The run() code a simple shell line means, or None when it does more than
130    start programs: `cd cli && go test ./... | tail -5` is
131    `job = run('go', 'test', './...', cwd='cli')`, `await job`, then
132    `job.tail(lines=5)`, and each other `|` is a .pipe(...)."""
133    plain = _unquoted(script)
134    for match in re.finditer(r"2>&1", plain):  # stderr joins stdout anyway
135        script = script[: match.start()] + "    " + script[match.end() :]
136    plain = plain.replace("2>&1", "    ")
137    if (
138        re.search(r"[$`*?<>(){}&]|(?:^|\s)~", plain.replace("&&", "  "))
139        or "||" in plain
140    ):
141        return None
142    cwd, stages = None, None
143    try:
144        for text, part in _split(script, plain, r"&&|;|\n"):
145            piped = [stage for stage, _ in _split(text, part, r"\|")]
146            words = shlex.split(piped[0])
147            if words[:1] == ["cd"] and len(words) == 2 and stages is None:
148                cwd = words[1]
149            elif stages is None:
150                stages = piped
151            else:
152                return None
153        if stages is None:
154            return None
155        limit = _LIMIT.match(stages[-1].strip()) if len(stages) > 1 else None
156        if limit is not None:
157            stages = stages[:-1]
158        calls = []
159        for index, stage in enumerate(stages):
160            words, env = _stage(stage)
161            inner = shell_script(words)
162            if inner is not None:  # a shell inside the line: read what it runs
163                return (
164                    translate(inner)
165                    if len(stages) == 1 and limit is None and not env
166                    else None
167                )
168            call_text = call(words, cwd, env)
169            calls.append(
170                call_text if index == 0 else ".pipe" + call_text.removeprefix("run")
171            )
172    except ValueError:
173        return None
174    read = (
175        "tail()"
176        if limit is None
177        else f"{limit.group(1)}(lines={limit.group(2) or limit.group(3) or 10})"
178    )
179    return f"job = {''.join(calls)}\nawait job\njob.{read}"
180
181
182def refusal(
183    what: str,
184    script: str | None = None,
185    argv: Sequence[str] | None = None,
186    cwd: str | None = None,
187) -> Refused:
188    """Why `what` is refused, with the run() call it means when there is one."""
189    suggestion = (
190        translate(script)
191        if script is not None
192        else (f"job = await {call(argv, cwd)}\njob.tail()" if argv else None)
193    )
194    if suggestion:
195        return Refused(
196            f"{what} is refused here; run() starts programs without a shell. this one is:\n"
197            + "".join(f"    {line}\n" for line in suggestion.splitlines())
198        )
199    return Refused(
200        f"{what} is refused here; run() starts one program without a shell: "
201        f"run('git', 'status', cwd='cli'). {HINTS}"
202    )
203
204
205installed = False
206
207
208def _from_cell(skip: int) -> bool:
209    """Whether the spawn under way was asked for by cell code itself, rather
210    than by a plugin or library the cell called. skip counts the audit hook's
211    own frames above the spawning call."""
212    try:
213        frame = sys._getframe(skip)
214    except ValueError:
215        return False
216    while frame is not None:
217        name = frame.f_code.co_filename
218        if name.startswith(CELL_PREFIX):
219            return True
220        stdlib = name.startswith("<frozen") or (
221            name.startswith(STDLIB) and "-packages" not in name
222        )
223        if not stdlib:
224            return False
225        frame = frame.f_back
226    return False
227
228
229def _strings(args: object) -> list[str]:
230    if isinstance(args, (str, bytes, os.PathLike)):
231        return [os.fsdecode(args)]
232    if isinstance(args, (list, tuple)):
233        return [
234            os.fsdecode(arg) if isinstance(arg, (str, bytes, os.PathLike)) else str(arg)
235            for arg in args
236        ]
237    return []
238
239
240def sleep_refusal(seconds: float) -> Refused:
241    """Why a cell may not block the kernel with time.sleep."""
242    return Refused(
243        f"time.sleep({seconds:g}) is refused here: it blocks the whole kernel, so nothing "
244        "else in this session runs while it waits. Start the work with run() and do other "
245        "useful work while it runs; its completion wakes you. If there is nothing else to "
246        "do, give the user a status report first, then wait with "
247        f"`await asyncio.sleep({seconds:g})`."
248    )
249
250
251def guard(event: str, args: tuple[object, ...]) -> None:
252    """Audit hook: a cell that spawns through subprocess or os gets run() instead,
253    and one that sleeps the kernel is told to run async work or wait explicitly."""
254    if event == "time.sleep":
255        seconds = args[0]
256        if (
257            isinstance(seconds, (int, float))
258            and seconds >= BLOCKING_SLEEP
259            and _from_cell(skip=2)
260        ):
261            raise sleep_refusal(seconds)
262        return
263    if event not in SPAWN_EVENTS or not _from_cell(skip=2):
264        return
265    if event == "os.system":
266        raise refusal("os.system", script=_strings(args[0])[0])
267    argv = _strings(args[2] if event == "os.spawn" else args[1])
268    cwd = (
269        _strings(args[2])[0]
270        if event == "subprocess.Popen" and args[2] is not None
271        else None
272    )
273    script = shell_script(argv)
274    what = "subprocess" if event == "subprocess.Popen" else event
275    raise (
276        refusal(what, script=script)
277        if script is not None
278        else refusal(what, argv=argv, cwd=cwd)
279    )
280
281
282def install() -> None:
283    global installed
284    if not installed:
285        installed = True
286        sys.addaudithook(guard)