okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
bd84040
main
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.""" 25token = GUARD .set (True ) 26try : 27yield 28finally : 29GUARD .reset (token ) 30 31 32def text (value :object )-> str : 33return ( 34os .fsdecode (value ) 35if isinstance (value , (str ,bytes ,os .PathLike )) 36else 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].""" 42if not isinstance (args , (list ,tuple )): 43return text (args ) 44argv = [text (arg )for arg in args ] 45if not argv : 46return "" 47script = albedo_shell .shell_script (argv ) 48if script is not None : 49return script 50name = os .path .basename (argv [0 ])if os .path .isabs (argv [0 ])else argv [0 ] 51return shlex .join ([name ,* argv [1 :]]) 52 53 54class Trace : 55def __init__ (self ): 56self .activities :dict [tuple [str ,str ],dict [str ,str ]]= {} 57self .before :dict [str ,str | None ]= {} 58self .truncated :bool = False 59self .sealed :bool = False 60 61def activity (self ,kind :str ,target :object ,detail :str = "" )-> None : 62if self .sealed : 63return 64if len (self .activities )>= 64 : 65self .truncated = True 66else : 67target = str (target )[:1000 ] 68entry = {"kind" :kind ,"target" :target } 69if detail : 70self .truncated |= len (detail )> 16000 71entry ["detail" ]= detail [:16000 ] 72self .activities [(kind ,target )]= entry 73 74def renamed (self ,source :str ,destination :str )-> None : 75if self .sealed or source == destination : 76return 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. 79self .writing (destination ) 80self .before .pop (source ,None ) 81 82def writing (self ,path :str )-> None : 83if self .sealed or path in self .before : 84return 85if len (self .before )>= 16 : 86self .truncated = True 87return 88self .before [path ]= snapshot (path ) 89 90def finish (self )-> dict [str ,object ]: 91if self .sealed : 92raise RuntimeError ("trace is already sealed" ) 93self .sealed = True 94with unobserved (): 95changes :list [dict [str ,str | int ]]= [] 96for path ,before in self .before .items (): 97after = snapshot (path ) 98if before == after : 99continue 100if before is None or after is None : 101changes .append ( 102 { 103"path" :path [:1000 ], 104"kind" :"unavailable" , 105"reason" :"binary, unreadable, or larger than 128 KiB" , 106 } 107 ) 108continue 109lines = list ( 110difflib .unified_diff ( 111before .splitlines (True ), 112after .splitlines (True ), 113fromfile = path , 114tofile = path , 115 ) 116 ) 117diff = "" .join (lines ) 118self .truncated |= len (diff )> 16000 119changes .append ( 120 { 121"path" :path [:1000 ], 122"kind" :"diff" , 123"diff" :diff [:16000 ], 124"added" :sum ( 125line .startswith ("+" )and not line .startswith ("+++" ) 126for line in lines 127 ), 128"removed" :sum ( 129line .startswith ("-" )and not line .startswith ("---" ) 130for line in lines 131 ), 132 } 133 ) 134return { 135"activities" : [ 136activity .copy ()for activity in self .activities .values () 137 ], 138"changes" :changes , 139"truncated" :self .truncated , 140 } 141 142def release (self )-> None : 143if not self .sealed : 144raise RuntimeError ("trace must be sealed before release" ) 145self .before .clear () 146self .activities .clear () 147 148 149def snapshot (path :str )-> str | None : 150try : 151if not os .path .exists (path ): 152return "" 153if not os .path .isfile (path )or os .path .getsize (path )> LIMIT : 154return None 155with open (path ,"rb" )as file : 156value = file .read (LIMIT + 1 ) 157return ( 158value .decode ("utf-8" ) 159if len (value )<= LIMIT and b"\0" not in value 160else None 161 ) 162except (OSError ,UnicodeError ): 163return None 164 165 166class TracedCapture (Protocol ): 167trace :Trace 168 169 170def _no_capture ()-> None : 171return 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 179for: the command run() started, the pattern files.find searched. `detail` 180is what the person watching sees of its result, such as a search's answer.""" 181capture = current () 182if capture is not None : 183capture .trace .activity (kind ,target ,detail ) 184 185 186def install (get_capture :Callable [[],TracedCapture | None ])-> None : 187global current 188current = get_capture 189 190def audit (event :str ,args :tuple [object , ...])-> None : 191capture = get_capture () 192if capture is None or capture .trace .sealed or GUARD .get (): 193return 194token = GUARD .set (True ) 195try : 196if event == "open" and isinstance (args [0 ], (str ,bytes )): 197path = os .path .abspath (os .fsdecode (args [0 ])) 198if path == "/proc" or path .startswith ("/proc/" ): 199return # process supervision is harness bookkeeping, not user exploration 200flags = args [2 ] 201if not isinstance (flags ,int ): 202raise TypeError ("invalid open flags" ) 203if flags & ( 204os .O_WRONLY | os .O_RDWR | os .O_CREAT | os .O_TRUNC | os .O_APPEND 205 ): 206capture .trace .writing (path ) 207else : 208capture .trace .activity ("read" ,path ) 209elif event in ("os.rename" ,"os.replace" )and len (args )>= 2 : 210source ,destination = ( 211os .path .abspath (os .fsdecode (cast (str | bytes ,path ))) 212for path in args [:2 ] 213 ) 214capture .trace .renamed (source ,destination ) 215elif event in ("os.listdir" ,"os.scandir" ): 216path = os .fsdecode (cast (str | bytes ,args [0 ]))if args else "." 217if path != "/proc" and not path .startswith ("/proc/" ): 218capture .trace .activity ("list" ,path ) 219elif event == "subprocess.Popen" : 220capture .trace .activity ("run" ,command (args [1 ])) 221except Exception : 222if not capture .trace .sealed : 223capture .trace .truncated = True 224finally : 225GUARD .reset (token ) 226 227sys .addaudithook (audit )