char-slop/ai-dots
ai dotfiles
git clone https://git.t4t.associates/char-slop/ai-dots
6ff1139
main
1import { getMarkdownTheme } from "@earendil-works/pi-coding-agent" ; 2import type { ExtensionAPI , ExtensionContext } from "@earendil-works/pi-coding-agent" ; 3import { Markdown } from "@earendil-works/pi-tui" ; 4import { 5analyzeBash , 6assessAnalysis , 7assessPath , 8type PermissionPolicy , 9type PermissionVerdict , 10} from "bash-effect-analyzer" ; 11import { hostname } from "node:os" ; 12import * as path from "node:path" ; 13import { compactConfirm } from "../_char_common/compact-confirm" ; 14import { loadSettings } from "../_char_common/settings" ; 15import { resolvesToFilesystemRoot , rootTargetCommand } from "./root-target" ; 16 17interface PermissionConfig { 18allowedCommandPrefixes :string []; 19writableDirectories :string []; 20readableDirectories :string []; 21yoloDisabledHosts :string []; 22} 23 24function readPermissionConfig ( cwd :string ) :PermissionConfig { 25const result :PermissionConfig = { 26allowedCommandPrefixes :[], 27writableDirectories :[], 28readableDirectories :[], 29yoloDisabledHosts :[], 30}; 31const settings = loadSettings ( cwd ); 32const pg = settings . permissionGate ; 33if ( pg && typeof pg === "object" ) { 34const gate = pg as Record < string , unknown >; 35const stringList = ( v :unknown ) :string [] => 36Array . isArray ( v ) ?v . filter (( s ) :s isstring => typeof s === "string" ) :[]; 37result . allowedCommandPrefixes . push ( ...stringList ( gate . allowedCommandPrefixes )); 38result . writableDirectories . push ( ...stringList ( gate . writableDirectories )); 39result . readableDirectories . push ( ...stringList ( gate . readableDirectories )); 40result . yoloDisabledHosts . push ( ...stringList ( gate . yoloDisabledHosts )); 41} 42return result ; 43} 44 45function bullet ( items :string [], prefix = "-" ) :string { 46return items . map (( s ) => ` ${ prefix } ${ s } ` ). join ( "\n" ); 47} 48 49function formatAllowMessage ( label :string , verdict :PermissionVerdict ) :string { 50return `🔓 auto-approved ${ label } \n ${ bullet ( verdict . allowReasons , " •" )} ` ; 51} 52 53interface PromptDetail { 54heading :string ; 55body :string ; 56} 57 58function buildPromptMarkdown ( detail :PromptDetail , verdict :PermissionVerdict ) :string { 59const sections = [ detail . body , `**Concerns**\n ${ bullet ( verdict . promptReasons )} ` ]; 60if ( verdict . allowReasons . length > 0 ) { 61sections . push ( 62`**Benign effects** (would auto-approve on their own)\n ${ bullet ( verdict . allowReasons )} ` , 63); 64} 65return sections . join ( "\n\n" ); 66} 67 68// Render the verdict details as a Markdown widget pinned above the editor, 69// then ask via the compact single-line prompt. The built-in `ctx.ui.confirm` 70// draws ~11 rows of chrome (borders, spacers, key hints) for what is 71// fundamentally a y/n question, and embedding the long body into its title 72// also triggers a redraw loop when the rendered prompt exceeds the terminal 73// height. `compactConfirm` keeps the dialog itself to a single wrapped line 74// and requires the user to type "yes" or "no" explicitly so a stray 75// keystroke can't approve a destructive operation. 76async function promptForApproval ( 77ctx :ExtensionContext , 78detail :PromptDetail , 79verdict :PermissionVerdict , 80) :Promise < boolean > { 81const markdown = buildPromptMarkdown ( detail , verdict ); 82ctx . ui . setWidget ( 83"permission-gate-prompt" , 84( _tui , _theme ) => new Markdown ( markdown , 1 , 0 , getMarkdownTheme ()), 85); 86try { 87return await compactConfirm ( ctx , detail . heading , "" ); 88} finally { 89ctx . ui . setWidget ( "permission-gate-prompt" , undefined ); 90} 91} 92 93export default function ( pi :ExtensionAPI ) { 94let yolo = false ; 95let yoloDisabled = false ; 96let strict = false ; 97let strictReads = false ; 98let allowedCommands :string [] = []; 99let configWritableDirs :string [] = []; 100let configReadableDirs :string [] = []; 101let lastPersistedSnapshot :string | null = null ; 102 103function persistAllowedCommands () { 104const snapshot = JSON . stringify ( allowedCommands ); 105if ( snapshot === lastPersistedSnapshot ) return ; 106pi . appendEntry ( "permission-gate-allowed" , { commands :[ ...allowedCommands ] }); 107lastPersistedSnapshot = snapshot ; 108} 109 110const DIM = "\x1b[2m" ; 111const RESET = "\x1b[22m" ; 112 113function buildPolicy ( cwd :string ) :PermissionPolicy { 114return { 115 cwd, 116allowedPrefixes :allowedCommands , 117// /dev/null is the universal Unix bit-bucket; redirecting to it has no 118// filesystem effect, so it's always safe regardless of cwd. 119writablePaths :[ "/dev/null" , ...configWritableDirs ], 120restrictReads :strictReads , 121readablePaths :configReadableDirs , 122}; 123} 124 125function updateStatus ( ctx :{ 126ui :{ setStatus ( id :string , text :string | undefined ) :void }; 127}) { 128if ( yolo ) { 129ctx . ui . setStatus ( "permission-gate" , ` ${ DIM } 🔓 yolo ${ RESET } ` ); 130} else if ( strict && strictReads ) { 131ctx . ui . setStatus ( "permission-gate" , ` ${ DIM } 🔒 strict + strict-reads ${ RESET } ` ); 132} else if ( strict ) { 133ctx . ui . setStatus ( "permission-gate" , ` ${ DIM } 🔒 strict ${ RESET } ` ); 134} else if ( strictReads ) { 135ctx . ui . setStatus ( "permission-gate" , ` ${ DIM } 🔒 strict-reads ${ RESET } ` ); 136} else if ( allowedCommands . length > 0 ) { 137ctx . ui . setStatus ( 138"permission-gate" , 139` ${ DIM } 🔓 ${ allowedCommands . length } allowed command(s) ${ RESET } ` , 140); 141} else { 142ctx . ui . setStatus ( "permission-gate" , undefined ); 143} 144} 145 146pi . on ( "session_start" , async ( _event , ctx ) => { 147const config = readPermissionConfig ( ctx . cwd ); 148allowedCommands = [ ...config . allowedCommandPrefixes ]; 149configWritableDirs = config . writableDirectories . map (( d ) => path . resolve ( ctx . cwd , d )); 150configReadableDirs = config . readableDirectories . map (( d ) => path . resolve ( ctx . cwd , d )); 151yoloDisabled = config . yoloDisabledHosts . includes ( hostname ()); 152// Each turn_start writes a full snapshot, so only the most recent one 153// reflects the session's actual state - unioning all historical snapshots 154// would resurrect any command the user ever toggled off. 155let lastSnapshot :string [] | null = null ; 156for ( const entry of ctx . sessionManager . getEntries ()) { 157if ( entry . type === "custom" && entry . customType === "permission-gate-allowed" ) { 158lastSnapshot = ( entry . data as { commands ?:string [] })?. commands ?? []; 159} 160} 161if ( lastSnapshot ) { 162for ( const p of lastSnapshot ) { 163if ( ! allowedCommands . includes ( p )) allowedCommands . push ( p ); 164} 165// Seed the dedup key so we don't immediately re-append an identical 166// snapshot on the next turn_start. 167lastPersistedSnapshot = JSON . stringify ( allowedCommands ); 168} 169updateStatus ( ctx ); 170}); 171 172pi . on ( "turn_start" , async () => { 173if ( allowedCommands . length > 0 ) persistAllowedCommands (); 174}); 175 176// prettier-ignore 177const PERM_MODES = { 178yolo :{ 179on :"🔓 yolo mode on - all permission gates disabled" , 180off :"🔒 yolo mode off - permission gates active" , 181toggle :() => { yolo = ! yolo ; if ( yolo ) { strict = false ; strictReads = false ; } return yolo ; }, 182}, 183strict :{ 184on :"🔒 strict mode on - any action requiring approval will be auto-denied" , 185off :"🔓 strict mode off - approval prompts re-enabled" , 186toggle :() => { strict = ! strict ; if ( strict ) yolo = false ; return strict ; }, 187}, 188"strict-reads" :{ 189on :"🔒 strict-reads mode on - reads confined to cwd + readableDirectories (bash commands and read/ls/grep/find tools)" , 190off :"🔓 strict-reads mode off" , 191toggle :() => { strictReads = ! strictReads ; if ( strictReads ) yolo = false ; return strictReads ; }, 192}, 193} as const satisfies Record < string , { on :string ; off :string ; toggle :() => boolean }>; 194type PermMode = keyof typeof PERM_MODES ; 195 196function describeState () :string { 197const flags = [ 198yolo ?"yolo" :null , 199strict ?"strict" :null , 200strictReads ?"strict-reads" :null , 201]. filter (( f ) :f isstring => f !== null ); 202const modeLine = 203flags . length === 0 204 ?"all modes off (interactive prompting)" 205 :`active: ${ flags . join ( ", " )} ` ; 206const allowLine = 207allowedCommands . length === 0 208 ?"no allowed command prefixes" 209 :`allowed prefixes:\n ${ allowedCommands . map (( c ) => ` • ${ c } ` ). join ( "\n" )} ` ; 210return ` ${ modeLine } \n ${ allowLine } ` ; 211} 212 213pi . registerCommand ( "perms" , { 214description :"Show or toggle permission-gate modes: /perms [yolo|strict|strict-reads]" , 215getArgumentCompletions :( prefix :string ) => { 216const items = ( Object . keys ( PERM_MODES ) as PermMode []) 217. filter (( m ) => m . startsWith ( prefix )) 218. map (( m ) => ({ value :m , label :m })); 219return items . length > 0 ?items :null ; 220}, 221handler :async ( args , ctx ) => { 222const arg = args ?. trim (); 223if ( ! arg ) { 224ctx . ui . notify ( describeState (), "info" ); 225return ; 226} 227if ( arg === "yolo" && ! yolo && yoloDisabled ) { 228ctx . ui . notify ( "yolo mode is disabled on this host (permissionGate.yoloDisabledHosts)" , "warning" ); 229return ; 230} 231if ( ! ( arg in PERM_MODES )) { 232ctx . ui . notify ( 233`Unknown mode " ${ arg } ". Valid: ${ Object . keys ( PERM_MODES ). join ( ", " )} ` , 234"warning" , 235); 236return ; 237} 238const mode = PERM_MODES [ arg as PermMode ]; 239const enabled = mode . toggle (); 240ctx . ui . notify ( enabled ?mode . on :mode . off , "info" ); 241updateStatus ( ctx ); 242}, 243}); 244 245pi . registerCommand ( "allow" , { 246description : 247"Toggle a command prefix on the session allow-list - ALL effects of matching commands are auto-approved" , 248getArgumentCompletions :( prefix :string ) => { 249if ( allowedCommands . length === 0 ) return null ; 250const items = allowedCommands . map (( c ) => ({ value :c , label :c })); 251const filtered = items . filter (( i ) => i . value . startsWith ( prefix )); 252return filtered . length > 0 ?filtered :null ; 253}, 254handler :async ( args , ctx ) => { 255const command = args ?. trim (); 256if ( ! command ) { 257if ( allowedCommands . length === 0 ) { 258ctx . ui . notify ( "No allowed commands. Usage: /allow <command>" , "info" ); 259} else { 260ctx . ui . notify ( 261`Allowed commands:\n ${ allowedCommands . map (( c ) => ` • ${ c } ` ). join ( "\n" )} ` , 262"info" , 263); 264} 265return ; 266} 267const existingIndex = allowedCommands . indexOf ( command ); 268if ( existingIndex >= 0 ) { 269allowedCommands . splice ( existingIndex , 1 ); 270ctx . ui . notify ( `🔒 " ${ command } " is no longer auto-approved` , "info" ); 271} else { 272allowedCommands . push ( command ); 273ctx . ui . notify ( 274`🔓 Commands matching " ${ command } " are now auto-approved for this session` , 275"info" , 276); 277} 278persistAllowedCommands (); 279updateStatus ( ctx ); 280}, 281}); 282 283async function enforceVerdict ( opts :{ 284ctx :ExtensionContext ; 285verdict :PermissionVerdict ; 286allowLabel :string ; 287deniedLine :string ; 288promptDetail :PromptDetail ; 289}) :Promise <{ block :true ; reason :string } | undefined > { 290const { ctx, verdict, allowLabel, deniedLine, promptDetail} = opts ; 291 292if ( verdict . decision === "allow" ) { 293if ( ctx . hasUI ) { 294ctx . ui . notify ( formatAllowMessage ( allowLabel , verdict ), "info" ); 295} 296return ; 297} 298 299if ( strict ) { 300return { 301block :true , 302reason :`Denied by strict mode. Concerns:\n ${ bullet ( verdict . promptReasons )} ` , 303}; 304} 305if ( ! ctx . hasUI ) { 306return { 307block :true , 308reason :`Cannot prompt for permission (no UI). ${ deniedLine } \n ${ bullet ( verdict . promptReasons , " •" )} ` , 309}; 310} 311const ok = await promptForApproval ( ctx , promptDetail , verdict ); 312if ( ! ok ) { 313return { 314block :true , 315reason :`Denied by user. Concerns:\n ${ bullet ( verdict . promptReasons )} ` , 316}; 317} 318return ; 319} 320 321pi . on ( "tool_call" , async ( event , ctx ) => { 322if ( event . toolName === "bash" ) { 323const command = ( event . input as { command ?:string }). command ?? "" ; 324const analysis = await analyzeBash ( command , { 325environment :{ 326HOME :process . env . HOME , 327OLDPWD :process . env . OLDPWD , 328CDPATH :process . env . CDPATH , 329}, 330}); 331const deniedCommand = rootTargetCommand ( analysis , ctx . cwd ); 332if ( deniedCommand ) { 333return { 334block :true , 335reason : 336deniedCommand === "find" 337 ?"Denied: `find` may not search filesystem root `/` because it would take too long. Search a narrower directory instead." 338 :"Denied: `rm` may not target filesystem root `/`." , 339}; 340} 341if ( yolo ) return ; 342 343const policy = buildPolicy ( ctx . cwd ); 344const verdict = assessAnalysis ( analysis , policy ); 345return enforceVerdict ({ 346 ctx, 347 verdict, 348allowLabel :`\` ${ command } \`` , 349deniedLine :`Denied: ${ command } ` , 350promptDetail :{ heading :"Bash command" , body :"```bash\n" + command + "\n```" }, 351}); 352} 353 354if ( event . toolName === "find" ) { 355const rawPath = ( event . input as { path ?:string }). path ?? "." ; 356if ( resolvesToFilesystemRoot ( ctx . cwd , rawPath )) { 357return { 358block :true , 359reason : 360"Denied: `find` may not search filesystem root `/` because it would take too long. Search a narrower directory instead." , 361}; 362} 363} 364 365if ( event . toolName === "read" ) { 366const rawPath = ( event . input as { path ?:string }). path ; 367if ( rawPath ) { 368const filename = path . basename ( path . resolve ( ctx . cwd , rawPath )); 369if ( filename === ".env" || filename === ".env.local" ) { 370return { 371block :true , 372reason :`Denied: \`read\` may not access \` ${ filename } \` because it may contain secrets.` , 373}; 374} 375} 376} 377 378if ( yolo ) return ; 379 380if ( event . toolName === "edit" || event . toolName === "write" ) { 381return resolvePathDecision ({ 382 ctx, 383rawPath : 384( event . input as { path ?:string ; file_path ?:string }). path ?? 385( event . input as { file_path ?:string }). file_path , 386verdict :( p ) => assessPath ( "write" , p , buildPolicy ( ctx . cwd )), 387confirmTitle :"Write outside CWD" , 388label :` ${ event . toolName } ` , 389}); 390} 391 392// Read-style tools are only gated in strict-reads mode — in normal 393// operation reads are unrestricted and the bash safe-command list does the 394// policy work. 395if ( 396strictReads && 397( event . toolName === "read" || 398event . toolName === "ls" || 399event . toolName === "grep" || 400event . toolName === "find" ) 401) { 402const rawPath = ( event . input as { path ?:string }). path ; 403// ls/grep/find without a path default to cwd, which is always allowed. 404if ( ! rawPath ) return ; 405return resolvePathDecision ({ 406 ctx, 407 rawPath, 408verdict :( p ) => assessPath ( "read" , p , buildPolicy ( ctx . cwd )), 409confirmTitle :"Read outside CWD" , 410label :` ${ event . toolName } ` , 411}); 412} 413}); 414 415async function resolvePathDecision ( opts :{ 416ctx :ExtensionContext ; 417rawPath :string | undefined ; 418verdict :( rawPath :string ) => PermissionVerdict ; 419confirmTitle :string ; 420label :string ; 421}) :Promise <{ block :true ; reason :string } | undefined > { 422const { ctx, rawPath, verdict :makeVerdict , confirmTitle, label} = opts ; 423if ( ! rawPath ) return ; 424 425const resolved = path . resolve ( ctx . cwd , rawPath ); 426return enforceVerdict ({ 427 ctx, 428verdict :makeVerdict ( rawPath ), 429allowLabel :` ${ label } \` ${ rawPath } \`` , 430deniedLine :`Denied ${ label } ${ rawPath } ` , 431promptDetail :{ 432heading :confirmTitle , 433body :`**Tool:** \` ${ label } \`\n\n**Path:** \` ${ rawPath } \`\n\n**Resolved:** \` ${ resolved } \`` , 434}, 435}); 436} 437}