okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
d4842e6
main
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.""" 44if not argv or os .path .basename (argv [0 ])not in SHELLS : 45return None 46for index ,arg in enumerate (argv [1 :],1 ): 47if arg .startswith ("--" ): 48continue # --norc, --login 49if not arg .startswith ("-" ): 50return None 51if "c" in arg [1 :]: 52return argv [index + 1 ]if index + 1 < len (argv )else "" 53return None 54 55 56def words (items :Sequence [object ])-> list [str ]: 57"""run()'s arguments as argv: text, paths, and numbers.""" 58argv = [] 59for item in items : 60if isinstance (item , (str ,bytes ,os .PathLike )): 61argv .append (os .fsdecode (item )) 62elif isinstance (item , (int ,float ))and not isinstance (item ,bool ): 63argv .append (str (item )) 64else : 65raise TypeError ( 66f"run() arguments are text, paths, or numbers, not { type ( item ). __name__ } " 67 ) 68return argv 69 70 71def call ( 72argv :Sequence [str ],cwd :str | None = None ,env :dict [str ,str ]| None = None 73)-> str : 74"""argv as the run() call that starts it.""" 75parts = [repr (arg )for arg in argv ] 76if cwd : 77parts .append (f"cwd= { cwd !r } " ) 78if env : 79parts .append (f"env= { env !r } " ) 80return f"run( { ', ' . join ( parts ) } )" 81 82 83def _unquoted (script :str )-> str : 84"""script with quoted text blanked, so shell syntax outside quotes shows.""" 85out ,quote ,escaped = [],"" ,False 86for char in script : 87if escaped : 88out .append (" " ) 89escaped = False 90elif char == "\\" and quote != "'" : 91out .append (" " ) 92escaped = True 93elif quote : 94out .append (" " ) 95quote = "" if char == quote else quote 96elif char in "'\"" : 97out .append (" " ) 98quote = char 99else : 100out .append (char ) 101return "" .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.""" 109parts ,start = [],0 110for match in re .finditer (separator ,plain ): 111parts .append ((text [start :match .start ()],plain [start :match .start ()])) 112start = match .end () 113parts .append ((text [start :],plain [start :])) 114return [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.""" 119words ,env = shlex .split (text ), {} 120while words and re .match (r"^[A-Za-z_][A-Za-z0-9_]*=" ,words [0 ]): 121name ,_ ,value = words .pop (0 ).partition ("=" ) 122env [name ]= value 123if not words : 124raise ValueError ("a stage with no program" ) 125return 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 130start 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(...).""" 133plain = _unquoted (script ) 134for match in re .finditer (r"2>&1" ,plain ):# stderr joins stdout anyway 135script = script [:match .start ()]+ " " + script [match .end () :] 136plain = plain .replace ("2>&1" ," " ) 137if ( 138re .search (r"[$`*?<>(){}&]|(?:^|\s)~" ,plain .replace ("&&" ," " )) 139or "||" in plain 140 ): 141return None 142cwd ,stages = None ,None 143try : 144for text ,part in _split (script ,plain ,r"&&|;|\n" ): 145piped = [stage for stage ,_ in _split (text ,part ,r"\|" )] 146words = shlex .split (piped [0 ]) 147if words [:1 ]== ["cd" ]and len (words )== 2 and stages is None : 148cwd = words [1 ] 149elif stages is None : 150stages = piped 151else : 152return None 153if stages is None : 154return None 155limit = _LIMIT .match (stages [- 1 ].strip ())if len (stages )> 1 else None 156if limit is not None : 157stages = stages [:- 1 ] 158calls = [] 159for index ,stage in enumerate (stages ): 160words ,env = _stage (stage ) 161inner = shell_script (words ) 162if inner is not None :# a shell inside the line: read what it runs 163return ( 164translate (inner ) 165if len (stages )== 1 and limit is None and not env 166else None 167 ) 168call_text = call (words ,cwd ,env ) 169calls .append ( 170call_text if index == 0 else ".pipe" + call_text .removeprefix ("run" ) 171 ) 172except ValueError : 173return None 174read = ( 175"tail()" 176if limit is None 177else f" { limit . group ( 1 ) } (lines= { limit . group ( 2 ) or limit . group ( 3 ) or 10 } )" 178 ) 179return f"job = { '' . join ( calls ) } \nawait job\njob. { read } " 180 181 182def refusal ( 183what :str , 184script :str | None = None , 185argv :Sequence [str ]| None = None , 186cwd :str | None = None , 187)-> Refused : 188"""Why `what` is refused, with the run() call it means when there is one.""" 189suggestion = ( 190translate (script ) 191if script is not None 192else (f"job = await { call ( argv , cwd ) } \njob.tail()" if argv else None ) 193 ) 194if suggestion : 195return Refused ( 196f" { 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 ) 199return Refused ( 200f" { what } is refused here; run() starts one program without a shell: " 201f"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 210than by a plugin or library the cell called. skip counts the audit hook's 211own frames above the spawning call.""" 212try : 213frame = sys ._getframe (skip ) 214except ValueError : 215return False 216while frame is not None : 217name = frame .f_code .co_filename 218if name .startswith (CELL_PREFIX ): 219return True 220stdlib = name .startswith ("<frozen" )or ( 221name .startswith (STDLIB )and "-packages" not in name 222 ) 223if not stdlib : 224return False 225frame = frame .f_back 226return False 227 228 229def _strings (args :object )-> list [str ]: 230if isinstance (args , (str ,bytes ,os .PathLike )): 231return [os .fsdecode (args )] 232if isinstance (args , (list ,tuple )): 233return [ 234os .fsdecode (arg )if isinstance (arg , (str ,bytes ,os .PathLike ))else str (arg ) 235for arg in args 236 ] 237return [] 238 239 240def sleep_refusal (seconds :float )-> Refused : 241"""Why a cell may not block the kernel with time.sleep.""" 242return Refused ( 243f"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 " 247f"`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, 253and one that sleeps the kernel is told to run async work or wait explicitly.""" 254if event == "time.sleep" : 255seconds = args [0 ] 256if ( 257isinstance (seconds , (int ,float )) 258and seconds >= BLOCKING_SLEEP 259and _from_cell (skip = 2 ) 260 ): 261raise sleep_refusal (seconds ) 262return 263if event not in SPAWN_EVENTS or not _from_cell (skip = 2 ): 264return 265if event == "os.system" : 266raise refusal ("os.system" ,script = _strings (args [0 ])[0 ]) 267argv = _strings (args [2 ]if event == "os.spawn" else args [1 ]) 268cwd = ( 269_strings (args [2 ])[0 ] 270if event == "subprocess.Popen" and args [2 ]is not None 271else None 272 ) 273script = shell_script (argv ) 274what = "subprocess" if event == "subprocess.Popen" else event 275raise ( 276refusal (what ,script = script ) 277if script is not None 278else refusal (what ,argv = argv ,cwd = cwd ) 279 ) 280 281 282def install ()-> None : 283global installed 284if not installed : 285installed = True 286sys .addaudithook (guard )