failproofai 1.0.2-beta.8 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +4 -4
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
  22. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -3
  24. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
  27. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
  29. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
  31. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
  33. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  36. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/index.html +1 -1
  39. package/.next/standalone/.next/server/app/index.rsc +14 -14
  40. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  41. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  42. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  43. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  44. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  47. package/.next/standalone/.next/server/app/policies/page.js +4 -4
  48. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  61. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/.next/server/chunks/[externals]__0-9ti7z._.js +3 -0
  64. package/.next/standalone/.next/server/chunks/{[externals]__1lt47k4._.js → [externals]__0sg9-3p._.js} +1 -1
  65. package/.next/standalone/.next/server/chunks/[externals]__0u3wrvq._.js +3 -0
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zlqo2t._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1anxb5k._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p-qi2t._.js +5 -0
  73. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +27 -0
  74. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
  75. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/{_19uwam-._.js → _1-akg6w._.js} +2 -2
  78. package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -7
  80. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_09d5c79._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js} +1 -1
  81. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1d2g73o._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js} +1 -1
  82. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/src_hooks_0iu54mz._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +10 -0
  85. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n_lxhg._.js +5 -0
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1yh7hlv._.js +3 -0
  97. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/_0ehudfr._.js +23 -0
  99. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1n2ywfz._.js → _1es2j7i._.js} +4 -4
  100. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/{_1h33neq._.js → _1un1pug._.js} +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +9 -9
  104. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  107. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +10 -0
  109. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  110. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  111. package/.next/standalone/.next/server/middleware.js +1 -1
  112. package/.next/standalone/.next/server/pages/404.html +1 -1
  113. package/.next/standalone/.next/server/pages/500.html +1 -1
  114. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  115. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  116. package/.next/standalone/.next/static/chunks/0nkpwjk01ei3t.js +1 -0
  117. package/.next/standalone/.next/static/chunks/{2iryw37qfqy5y.js → 0ofdelz65_517.js} +1 -1
  118. package/.next/standalone/.next/static/chunks/{34j7nmidlcuni.js → 12uhnpwgsmcev.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{0i3a6wf-f_yky.js → 1_1eb_149xyt9.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/{3927pu4ss_l4k.js → 1tx9pnxyjrkoj.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{1n04wzsaes73g.js → 1x0jrl3a4_i2m.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{3r0udrtpn78-e.js → 1zpv6omeln7al.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/{416zumre3fvfw.js → 20hbg-r3io_ip.js} +1 -1
  124. package/.next/standalone/.next/static/chunks/{11a_m8yksthtf.js → 32bhi32ym8yrz.js} +2 -2
  125. package/.next/standalone/.next/static/chunks/{0u2ojcw25c_np.js → 3fr78ugeb4tjc.js} +1 -1
  126. package/.next/standalone/app/actions/get-hooks-config.ts +8 -1
  127. package/.next/standalone/app/actions/pack-actions.ts +12 -1
  128. package/.next/standalone/app/policies/hooks-client.tsx +41 -4
  129. package/.next/standalone/fp-cloud-cli/tests/test_pagination.py +204 -0
  130. package/.next/standalone/package.json +9 -9
  131. package/.next/standalone/server.js +1 -1
  132. package/README.md +58 -13
  133. package/bin/failproofai.mjs +10 -1
  134. package/dist/cli.mjs +22429 -21292
  135. package/dist/worker.mjs +1247 -540
  136. package/package.json +9 -9
  137. package/src/audit/index.ts +24 -4
  138. package/src/hooks/builtin-policies.ts +933 -8
  139. package/src/hooks/custom-hooks-loader.ts +98 -8
  140. package/src/hooks/handler.ts +26 -14
  141. package/src/hooks/manager.ts +36 -2
  142. package/src/hooks/pack-cli.ts +415 -42
  143. package/src/hooks/pack-failclosed.ts +100 -4
  144. package/src/hooks/pack-manifest.ts +29 -0
  145. package/src/hooks/pack-param-key.ts +36 -0
  146. package/src/hooks/pack-store.ts +101 -9
  147. package/src/hooks/policy-catalog.ts +9 -1
  148. package/src/hooks/policy-evaluator.ts +77 -1
  149. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06cuf1y._.js +0 -3
  150. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
  151. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
  152. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0jnjf2t._.js +0 -3
  153. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
  154. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
  155. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tg4pu6._.js +0 -3
  156. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
  157. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
  158. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mgrjnx._.js +0 -10
  159. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-wzs7f._.js +0 -3
  160. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1wlvpe5._.js +0 -5
  161. package/.next/standalone/.next/server/chunks/ssr/_0l7zv8b._.js +0 -23
  162. package/.next/standalone/.next/static/chunks/1ch5l9t2p16y4.js +0 -1
  163. /package/.next/standalone/.next/static/{NgD5HvuyPxwgrpeIiafJL → 5-xDMxcnrcXDFPQiy3yhg}/_buildManifest.js +0 -0
  164. /package/.next/standalone/.next/static/{NgD5HvuyPxwgrpeIiafJL → 5-xDMxcnrcXDFPQiy3yhg}/_clientMiddlewareManifest.js +0 -0
  165. /package/.next/standalone/.next/static/{NgD5HvuyPxwgrpeIiafJL → 5-xDMxcnrcXDFPQiy3yhg}/_ssgManifest.js +0 -0
@@ -458,8 +458,918 @@ const GIT_COMMIT_MERGE_RE = /git\s+(commit|merge|rebase|cherry-pick)\b/;
458
458
  * Named separately from the binary walk because it never mentions the binary:
459
459
  * the target is a path, and the verb is an ordinary file command.
460
460
  */
461
- const FAILPROOFAI_STATE_WRITE_RE =
462
- /\b(?:rm|unlink|shred|mv|truncate)\b[^;&|]*\.failproofai(?:\/|\b)/;
461
+ const FAILPROOFAI_STATE_PATH_RE = /\.failproofai(?:\/|\b)/;
462
+
463
+ /**
464
+ * Everything the state guard is allowed to let PAST, as a list of commands that
465
+ * can only read.
466
+ *
467
+ * This is the third shape of this check, and the first that is an allowlist.
468
+ * The first was one verb-then-path regex,
469
+ * `\b(?:rm|unlink|shred|mv|truncate)\b[^;&|]*\.failproofai` — `find
470
+ * ~/.failproofai -delete` names none of those words and walked straight
471
+ * through the one guard that cannot be switched off. The second kept the
472
+ * blocklist shape and added the verbs that miss implied (`find -delete`,
473
+ * `-exec`, `dd`, `tee`, redirects, `--delete`), which left
474
+ * `python3 -c 'shutil.rmtree(...)'`, `perl -e 'rmtree(...)'`,
475
+ * `node -e 'fs.rmSync(...)'`, `git clean -xdff`, `sed -i`, `gio trash`,
476
+ * `install /dev/null`, `tar --overwrite` and `chmod 000` all still open — and
477
+ * `cp /dev/null <path>`, which its own doc comment claimed was covered and
478
+ * which the regex never mentioned.
479
+ *
480
+ * A blocklist of destructive verbs cannot be finished: every interpreter on the
481
+ * machine is one more verb. This list can be, because the reasons to point a
482
+ * command at `~/.failproofai` at all are few and all of them read. So the
483
+ * direction is inverted, and the failure modes go with it: a name missing from
484
+ * a blocklist silently disables enforcement and nothing reports it, while a
485
+ * name missing from THIS list denies a command an operator can see and file a
486
+ * bug about. For the one guard nobody can switch off, take the visible failure.
487
+ */
488
+ const STATE_READ_COMMANDS = new Set([
489
+ // Contents
490
+ "cat", "bat", "less", "more", "head", "tail", "nl", "strings", "xxd", "od",
491
+ "jq", "yq", "diff", "cmp", "wc", "sort", "uniq", "column", "awk", "cut",
492
+ // Listing and metadata
493
+ "ls", "dir", "tree", "stat", "file", "du", "df", "readlink", "realpath",
494
+ "basename", "dirname", "pwd", "test", "find",
495
+ // Search
496
+ "grep", "egrep", "fgrep", "rg", "ag", "ack",
497
+ // Integrity
498
+ "md5sum", "sha1sum", "sha256sum", "shasum", "cksum",
499
+ // Navigation, which moves no bytes on its own
500
+ "cd", "pushd", "popd",
501
+ // Copy-family: a read only when the state is the SOURCE — see COPY_COMMANDS
502
+ "cp", "rsync", "install", "tee",
503
+ // Editors-as-readers and no-ops
504
+ "sed", "true", "false", ":", "echo", "printf",
505
+ // Shell grammar. `[ -f ~/.failproofai/policies-config.json ]` and
506
+ // `for f in ~/.failproofai/*` are not commands that touch anything — a loop
507
+ // header only expands words, and a test only stats. They were reaching the
508
+ // unknown-head branch and denying, which is how an always-on guard starts
509
+ // blocking the ordinary way of checking whether the state is there.
510
+ "[", "[[", "]", "]]", "test", "read", "for", "select", "case", "done", "fi", "esac",
511
+ ]);
512
+
513
+ /**
514
+ * Shell keywords that stand in FRONT of a real command.
515
+ *
516
+ * Skipped like a runner, so `do rm -rf "$f"` is judged as the `rm` it is
517
+ * rather than as an unknown head called `do`. That cuts both ways and both are
518
+ * wanted: `do cat "$f"` stops denying, and `until rm -rf ~/.failproofai` stops
519
+ * relying on `until` being unrecognised.
520
+ */
521
+ const SHELL_KEYWORD_PREFIXES = new Set(["do", "then", "else", "elif", "if", "while", "until", "!"]);
522
+
523
+ /**
524
+ * A loop header, which hands its word list to the body that follows.
525
+ *
526
+ * `find ~/.failproofai -type f | while read f; do rm "$f"; done` names the
527
+ * state only in the header — every `;` after it starts a pipeline with nothing
528
+ * failproofai-shaped in it, so the body was judged against a path it never
529
+ * mentions. The header's reach has to carry into the body, and stop at `done`.
530
+ */
531
+ const LOOP_HEADER_RE = /(?:^|[\s(])(?:while|until|for|select)\s/;
532
+
533
+ /**
534
+ * Commands whose verdict depends on which END of the argument list the state
535
+ * path sits at.
536
+ *
537
+ * `cp -r ~/.failproofai /tmp/backup` is how somebody preserves the state before
538
+ * touching it; `cp /dev/null ~/.failproofai/policies-config.json` is how
539
+ * somebody empties a file without ever naming `rm`. The tool is the same and
540
+ * only the destination separates them, so the destination is what decides.
541
+ */
542
+ const COPY_COMMANDS = new Set(["cp", "rsync", "install"]);
543
+
544
+ /**
545
+ * Commands that write EVERY path operand they are given.
546
+ *
547
+ * `tee` was an unknown head, so it denied wherever it appeared — including
548
+ * `cat ~/.failproofai/policies-config.json | tee /tmp/out.json`, which is a
549
+ * read with a transcript. What decides is the same thing that decides for the
550
+ * copy family: which end of the pipe the state sits on.
551
+ */
552
+ const OPERAND_WRITE_COMMANDS = new Set(["tee"]);
553
+
554
+ /**
555
+ * Readers whose LAST operand is an output file rather than another input.
556
+ *
557
+ * `uniq /dev/null ~/.failproofai/policies-config.json` and
558
+ * `xxd -r -p /tmp/hex ~/.failproofai/policies-config.json` both empty the
559
+ * config while sitting in the read allowlist. One operand is still a read, so
560
+ * `xxd ~/.failproofai/policies-config.json | head` stays allowed.
561
+ */
562
+ const SECOND_OPERAND_WRITERS = new Set(["uniq", "xxd"]);
563
+
564
+ /**
565
+ * Readers that write to a file NAMED BY A FLAG, per command.
566
+ *
567
+ * `sort -o` is the shape that matters: an allowlisted reader that takes its
568
+ * destination as an option, so nothing about its head or its operand order
569
+ * says it is writing. `curl` is here rather than treated as a pure mention
570
+ * command because `curl -o ~/.failproofai/policies-config.json <url>` replaces
571
+ * the config with whatever a server returns.
572
+ */
573
+ const OUTPUT_FLAG_COMMANDS: Record<string, readonly string[]> = {
574
+ sort: ["-o", "--output"],
575
+ tree: ["-o", "--output"],
576
+ curl: ["-o", "--output", "--output-dir", "-D", "--dump-header", "--trace", "--trace-ascii"],
577
+ };
578
+
579
+ /**
580
+ * `find` actions that write a file of their own, as opposed to running one.
581
+ *
582
+ * `find /etc -fprint ~/.failproofai/policies-config.json` truncates the config
583
+ * without touching the state directory at all — the state is the *report*
584
+ * target, and `find` is on the read allowlist.
585
+ */
586
+ const FIND_WRITE_ACTIONS = new Set(["-fprint", "-fprint0", "-fprintf", "-fls"]);
587
+
588
+ /**
589
+ * Commands safe to hand a matched path to from `find -exec`.
590
+ *
591
+ * Deliberately NARROWER than `STATE_READ_COMMANDS`: the copy family and `sed`
592
+ * are reads only because of where their operands sit, and `-exec` decides that
593
+ * for them — `find ~/.failproofai -type f -exec cp /dev/null {} \;` and
594
+ * `-exec sed -i …` empty every file in the state through two names that were
595
+ * both on the allowlist. `find` itself is excluded for the same reason
596
+ * (`-exec find {} -delete \;`).
597
+ */
598
+ const SAFE_EXEC_COMMANDS = new Set([
599
+ "cat", "bat", "head", "tail", "nl", "strings", "xxd", "od", "jq", "yq", "wc",
600
+ "ls", "stat", "file", "du", "readlink", "realpath", "basename", "dirname",
601
+ "grep", "egrep", "fgrep", "rg", "md5sum", "sha1sum", "sha256sum", "shasum",
602
+ "cksum", "echo", "printf", "true", ":",
603
+ ]);
604
+
605
+ /**
606
+ * `git` subcommands that rewrite or delete a working tree.
607
+ *
608
+ * `git` is otherwise treated as carrying a path as PROSE — `git commit -m
609
+ * "document ~/.failproofai layout"` must not deny — but `git clean -xdff
610
+ * .failproofai` deletes the directory as thoroughly as `rm -rf` does.
611
+ */
612
+ const GIT_DESTRUCTIVE_SUBCOMMANDS = new Set([
613
+ "clean", "rm", "restore", "checkout", "reset", "stash",
614
+ ]);
615
+
616
+ /**
617
+ * `git`'s own options that swallow the token after them.
618
+ *
619
+ * The subcommand is the first token that is not an option — but `git -c
620
+ * core.x=1 -C ~/.failproofai clean -xdff` makes `core.x=1` the first such
621
+ * token, so the walk settled on it, found no destructive subcommand, and let a
622
+ * `git clean -xdff` of the state directory through.
623
+ */
624
+ const GIT_FLAGS_WITH_OPERANDS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"]);
625
+
626
+ /** The `git` subcommand, with git's own global options walked off first. */
627
+ function gitSubcommand(args: string[]): string | undefined {
628
+ for (let i = 0; i < args.length; i++) {
629
+ const arg = args[i];
630
+ if (GIT_FLAGS_WITH_OPERANDS.has(arg)) {
631
+ i++;
632
+ continue;
633
+ }
634
+ if (arg.startsWith("-")) continue;
635
+ return arg;
636
+ }
637
+ return undefined;
638
+ }
639
+
640
+ /**
641
+ * Commands that take a path as text to record or print, not as a file to open.
642
+ *
643
+ * Without them the always-on guard denies an agent writing a commit message or
644
+ * a PR body that names `~/.failproofai` — the same false positive the binary
645
+ * half of this policy already had to be anchored to avoid.
646
+ */
647
+ const STATE_MENTION_COMMANDS = new Set(["git", "gh", "glab", "echo", "printf", "curl", "code", "open"]);
648
+
649
+ /** `find` actions that hand the matched paths to an arbitrary command. */
650
+ const FIND_EXEC_ACTIONS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
651
+
652
+ /**
653
+ * The pieces a shell treats as SEPARATE commands.
654
+ *
655
+ * `|` is deliberately absent. A pipeline is one unit of work: `find
656
+ * ~/.failproofai -print0 | xargs -0 rm -f` names the path on the left and the
657
+ * verb on the right, and splitting there made each half look innocent. `;`,
658
+ * `&&`, `||`, `&` and a newline do start a genuinely new command, which is what
659
+ * keeps `cat ~/.failproofai/config.json && rm /tmp/scratch` allowed.
660
+ */
661
+ const PIPELINE_SEPARATORS = /\|\||&&|[;\n\r&]+/;
662
+
663
+ /** A `$VAR` or `${VAR}` reference, wherever it sits. */
664
+ const VAR_REFERENCE_RE = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g;
665
+
666
+ /**
667
+ * The spellings of the state directory a glob would have to match.
668
+ *
669
+ * A glob is expanded by the shell, so `rm -rf ~/.failproof*` never contains the
670
+ * literal — it was the cheapest bypass left standing, and cheap matters most
671
+ * for the one guard that cannot be switched off. What can be decided from the
672
+ * pre-expansion string is whether the pattern COULD land on the state, so the
673
+ * pattern is compiled and tried against the paths it would have to hit.
674
+ */
675
+ const STATE_GLOB_CANDIDATES = [
676
+ ".failproofai",
677
+ "~/.failproofai",
678
+ "$HOME/.failproofai",
679
+ "${HOME}/.failproofai",
680
+ "/root/.failproofai",
681
+ "/home/u/.failproofai",
682
+ "/Users/u/.failproofai",
683
+ ];
684
+
685
+ /**
686
+ * Ordinary paths a targeted pattern must NOT be able to match.
687
+ *
688
+ * The floor a bare `*` has to fail. `rm -rf *` in a build directory compiles to
689
+ * a regex that matches every state candidate, and denying it would make the one
690
+ * guard nobody can switch off fire on the most ordinary command there is. A
691
+ * pattern that also lands on `node_modules` or `.config` is not aimed at the
692
+ * state; a pattern that lands on `.failproofai` and on none of these is.
693
+ */
694
+ const GLOB_DECOYS = [
695
+ ".config", ".cache", ".local", ".git", ".npm", ".ssh", ".bashrc",
696
+ "node_modules", "dist", "build", "target", "coverage", "tmp", "src",
697
+ "~/.config", "~/.cache", "$HOME/.config", "${HOME}/.cache",
698
+ "/home/u/.config", "/Users/u/.config", "/root/.cache", "/tmp/build",
699
+ "a", "foo.txt", "test-failures",
700
+ ];
701
+
702
+ /**
703
+ * True when this token is a glob that could expand onto failproofai's state.
704
+ *
705
+ * The floor used to be a literal `fail` in the token, and that is exactly what
706
+ * a glob is for: `rm -rf ~/.f*ailproofai` spells the same directory without the
707
+ * substring anywhere in it, so the pattern was thrown out before it was ever
708
+ * compiled. `.[f]ailproofai`, `.fa*lproofai`, `.fa[i]lproofai` and
709
+ * `.f{a,b}ilproofai` all walked through the same hole.
710
+ *
711
+ * A substring cannot decide this, because the whole point of the metacharacter
712
+ * is to stand where a letter was. What CAN decide it is the pattern's reach: it
713
+ * has to hit the state and miss everything ordinary. So the floor is now
714
+ * computed the same way the match is — compile once, require a candidate, and
715
+ * reject anything that also catches a decoy. `rm -rf *` and `rm -rf ~/.*` stay
716
+ * allowed because they sweep the decoys too; `rm -rf /tmp/test-failures*` stays
717
+ * allowed because it reaches no candidate at all.
718
+ */
719
+ function globCouldNameState(raw: string): boolean {
720
+ // Both readings of a trailing `)`: part of the pattern, and the close of a
721
+ // subshell that wrapped it. Trying both is safe in the way widening always is
722
+ // here — the decoy test still has to pass.
723
+ const stripped = raw.replace(/^[()]+|[()]+$/g, "");
724
+ return globWordCouldNameState(raw) || (stripped !== raw && globWordCouldNameState(stripped));
725
+ }
726
+
727
+ function globWordCouldNameState(token: string): boolean {
728
+ if (!GLOB_METACHAR_RE.test(token)) return false;
729
+ // Braces FIRST, because that is the order the shell works in: brace expansion
730
+ // rewrites the word into several words, and only then is each one matched
731
+ // against the disk. Compiling `{a*,x}` as an alternation of escaped literals
732
+ // put a literal `*` inside the pattern, so `~/.f{a*,x}ilproofai` — which the
733
+ // shell turns into `~/.fa*ilproofai` and then into `~/.failproofai` — read as
734
+ // naming nothing at all.
735
+ for (const word of expandBraces(token)) {
736
+ if (wordNamesState(word)) return true;
737
+ }
738
+ return false;
739
+ }
740
+
741
+ /**
742
+ * How many words one token may expand into before the expansion is abandoned.
743
+ *
744
+ * `{a,b}{c,d}{e,f}…` multiplies, so this is bounded rather than trusted. A real
745
+ * attempt needs a handful; anything past this is answered by the fallback in
746
+ * `expandBraces`, which is a superset and so cannot hide anything.
747
+ */
748
+ const BRACE_EXPANSION_LIMIT = 4096;
749
+
750
+ /**
751
+ * How many expansion passes one token gets. One pass resolves the outermost
752
+ * group of every word, so this is a nesting-depth budget — and, like the width
753
+ * one, running out collapses what is left rather than leaving it unresolved.
754
+ */
755
+ const BRACE_EXPANSION_ROUNDS = 24;
756
+
757
+ /**
758
+ * The words a token expands into, the way the shell would expand them.
759
+ *
760
+ * When the product would exceed the limit, every brace group collapses to `*`
761
+ * instead. That is a SUPERSET of what the braces could produce — it reaches at
762
+ * least everything they reach — so an oversized token can still be recognised,
763
+ * and is still held to the same decoy test that keeps `rm -rf *` allowed.
764
+ */
765
+ function expandBraces(token: string): string[] {
766
+ let words = [token];
767
+ // Bounded on WORK, not on nesting depth. A round cap that returned its
768
+ // half-expanded words handed a still-braced word to a compiler that escapes
769
+ // braces as literals, so seventeen levels of `{x,{x,…{x,a*}…}}` — legal shell
770
+ // with a branch that becomes `~/.fa*ilproofai` — came out matching nothing.
771
+ // Running out of budget must never mean "unresolved", only "collapsed".
772
+ for (let round = 0; round < BRACE_EXPANSION_ROUNDS; round++) {
773
+ const next: string[] = [];
774
+ let expanded = false;
775
+ for (const word of words) {
776
+ const group = firstBraceGroup(word);
777
+ if (!group) {
778
+ next.push(word);
779
+ continue;
780
+ }
781
+ expanded = true;
782
+ for (const alternative of group.alternatives) {
783
+ next.push(word.slice(0, group.start) + alternative + word.slice(group.end + 1));
784
+ }
785
+ }
786
+ if (!expanded) return next;
787
+ if (next.length > BRACE_EXPANSION_LIMIT) return [collapseBraces(token)];
788
+ words = next;
789
+ }
790
+ return words.map(collapseBraces);
791
+ }
792
+
793
+ /**
794
+ * Every brace group in a word replaced by `*`, innermost first.
795
+ *
796
+ * The answer whenever expansion is given up on, and safe to be the answer
797
+ * because it can only WIDEN: `{a*,x}` reaches strictly less than `*` does. So a
798
+ * token too deep or too wide to expand is still decided, and still has to pass
799
+ * the same decoy test that keeps `rm -rf *` allowed. A brace left unmatched
800
+ * becomes `*` too, on the same reasoning.
801
+ */
802
+ function collapseBraces(word: string): string {
803
+ let out = "";
804
+ let depth = 0;
805
+ for (const ch of word) {
806
+ if (ch === "{") {
807
+ // The OUTERMOST group, replaced whole. Resolving innermost-first needs one
808
+ // pass per level, which is another depth budget and another way to run out
809
+ // holding a word that still has braces in it. One pass, any depth.
810
+ if (depth === 0) out += "*";
811
+ depth++;
812
+ continue;
813
+ }
814
+ if (ch === "}") {
815
+ if (depth > 0) depth--;
816
+ continue;
817
+ }
818
+ if (depth === 0) out += ch;
819
+ }
820
+ return out;
821
+ }
822
+
823
+ /** The first brace group in a word, split on its TOP-LEVEL commas. */
824
+ function firstBraceGroup(word: string): { start: number; end: number; alternatives: string[] } | null {
825
+ const start = word.indexOf("{");
826
+ if (start === -1) return null;
827
+ let depth = 0;
828
+ const alternatives: string[] = [];
829
+ let current = "";
830
+ for (let i = start; i < word.length; i++) {
831
+ const ch = word[i];
832
+ if (ch === "{") {
833
+ depth++;
834
+ if (depth === 1) continue;
835
+ } else if (ch === "}") {
836
+ depth--;
837
+ if (depth === 0) {
838
+ alternatives.push(current);
839
+ return { start, end: i, alternatives };
840
+ }
841
+ } else if (ch === "," && depth === 1) {
842
+ alternatives.push(current);
843
+ current = "";
844
+ continue;
845
+ }
846
+ current += ch;
847
+ }
848
+ return null;
849
+ }
850
+
851
+ /**
852
+ * True when one brace-expanded word names the state.
853
+ *
854
+ * The literal check comes first because expansion can PRODUCE the literal:
855
+ * `~/.{failproofai,other}` carries no `.failproofai` as written, and the word it
856
+ * expands to needs no glob compilation to be recognised.
857
+ */
858
+ function wordNamesState(word: string): boolean {
859
+ if (FAILPROOFAI_STATE_PATH_RE.test(word)) return true;
860
+ // Every path PREFIX, not just the whole word. The candidates are the state
861
+ // directory itself, so `~/.f*ailproofai/policies-config.json` — a glob in a
862
+ // segment that is not the last one — compiled to a pattern that could never
863
+ // equal `~/.failproofai` and was read as naming nothing. Naming a file INSIDE
864
+ // the state is naming the state.
865
+ const segments = word.split("/");
866
+ for (let end = 1; end <= segments.length; end++) {
867
+ if (globPrefixNamesState(segments.slice(0, end).join("/"))) return true;
868
+ }
869
+ return false;
870
+ }
871
+
872
+ /**
873
+ * Everything that makes a token a PATTERN rather than a path.
874
+ *
875
+ * The extended-glob operators are two characters wide, and three of them begin
876
+ * with a character that means nothing on its own — which is how
877
+ * `rm -rf ~/.f@(ailproofai)` was not even recognised as a glob, let alone
878
+ * compiled wrong.
879
+ */
880
+ const GLOB_METACHAR_RE = /[*?[{]|[@+!]\(/;
881
+
882
+ /**
883
+ * A glob compiled to regex source, extended-glob operators included.
884
+ *
885
+ * `@(a|b)`, `?(a|b)`, `*(a|b)`, `+(a|b)` and `!(a|b)` are groups carrying a
886
+ * quantifier, and their alternatives are patterns in their own right — so this
887
+ * recurses rather than escaping whatever it finds inside them. `!(…)` is a
888
+ * negation a regex cannot express against a whole path segment, so it becomes
889
+ * `[^/]*`: a superset, which is the direction that stays safe here, because
890
+ * reaching too far is settled by the decoy test and reaching too little is a
891
+ * bypass.
892
+ */
893
+ function compileGlobPattern(token: string): string {
894
+ let pattern = "";
895
+ for (let i = 0; i < token.length; i++) {
896
+ const extended = extglobAt(token, i);
897
+ if (extended) {
898
+ pattern += extended.source;
899
+ i = extended.end;
900
+ continue;
901
+ }
902
+ const ch = token[i];
903
+ if (ch === "*") pattern += "[^/]*";
904
+ else if (ch === "?") pattern += "[^/]";
905
+ else if (ch === "[") {
906
+ const bracket = bracketExpressionAt(token, i);
907
+ if (!bracket) {
908
+ // An unclosed `[` is a literal `[` to the shell, so it is one here.
909
+ // Bailing out would answer "names nothing" on the strength of a
910
+ // malformed pattern — the same shape as a budget that returns instead
911
+ // of collapsing.
912
+ pattern += "\\[";
913
+ continue;
914
+ }
915
+ pattern += bracket.source;
916
+ i = bracket.end;
917
+ } else pattern += ch.replace(/[.+^${}()|\\\]]/g, "\\$&");
918
+ }
919
+ return pattern;
920
+ }
921
+
922
+ /** One `@(…)`-style group, with its alternatives compiled as patterns. */
923
+ function extglobAt(token: string, start: number): { source: string; end: number } | null {
924
+ const operator = token[start];
925
+ if (!"?*+@!".includes(operator) || token[start + 1] !== "(") return null;
926
+ const alternatives: string[] = [];
927
+ let current = "";
928
+ let depth = 0;
929
+ let i = start + 1;
930
+ for (; i < token.length; i++) {
931
+ const ch = token[i];
932
+ if (ch === "(") {
933
+ depth++;
934
+ if (depth === 1) continue;
935
+ } else if (ch === ")") {
936
+ depth--;
937
+ if (depth === 0) {
938
+ alternatives.push(current);
939
+ break;
940
+ }
941
+ } else if (ch === "|" && depth === 1) {
942
+ alternatives.push(current);
943
+ current = "";
944
+ continue;
945
+ }
946
+ current += ch;
947
+ }
948
+ // Unterminated, so the operator is an ordinary character rather than a group.
949
+ if (i >= token.length) return null;
950
+ if (operator === "!") return { source: "[^/]*", end: i };
951
+ const body = alternatives.map((alternative) => compileGlobPattern(alternative)).join("|");
952
+ const quantifier = operator === "?" ? "?" : operator === "*" ? "*" : operator === "+" ? "+" : "";
953
+ return { source: `(?:${body})${quantifier}`, end: i };
954
+ }
955
+
956
+ /**
957
+ * One shell bracket expression, translated into a JavaScript character class.
958
+ *
959
+ * Copying the shell's text into a regex verbatim reads two of its spellings
960
+ * backwards. POSIX negates with `!`, so bash expands `~/.f[!b]ilproofai` onto
961
+ * the state while JavaScript read `[!b]` as "either `!` or `b`" and matched
962
+ * nothing — a one-character bypass of the guard nobody can switch off. And a
963
+ * `]` in the FIRST position is a literal `]` rather than the end of the class,
964
+ * so scanning for the next `]` closed `[]abc]` on the wrong character.
965
+ *
966
+ * Anything exotic — a POSIX class, a collating symbol, an equivalence class —
967
+ * becomes `[^/]`, which matches any single character a path segment can hold
968
+ * and so reaches at least as far as the original. Widening is safe here in a
969
+ * way narrowing never is: the decoy test is what decides, and it is unchanged.
970
+ */
971
+ function bracketExpressionAt(token: string, start: number): { source: string; end: number } | null {
972
+ let i = start + 1;
973
+ let negated = false;
974
+ if (token[i] === "!" || token[i] === "^") {
975
+ negated = true;
976
+ i++;
977
+ }
978
+ // A `]` here is content, not the terminator.
979
+ let body = "";
980
+ if (token[i] === "]") {
981
+ body += "]";
982
+ i++;
983
+ }
984
+ while (i < token.length && token[i] !== "]") {
985
+ // `[:alpha:]`, `[.a.]` and `[=a=]` carry a `]` of their own, which closed
986
+ // the class early and left a stray `]` matching as a literal.
987
+ const inner = token[i] === "[" ? token[i + 1] : undefined;
988
+ if (inner === ":" || inner === "." || inner === "=") {
989
+ const close = token.indexOf(`${inner}]`, i + 2);
990
+ if (close !== -1) {
991
+ body += token.slice(i, close + 2);
992
+ i = close + 2;
993
+ continue;
994
+ }
995
+ }
996
+ body += token[i];
997
+ i++;
998
+ }
999
+ if (i >= token.length) return null;
1000
+ if (/\[[.=:]/.test(body)) return { source: "[^/]", end: i };
1001
+ // `\` and `]` are the two characters that would end or escape the class in
1002
+ // JavaScript; the rest of a bracket expression means the same in both.
1003
+ const escaped = body.replace(/[\\\]]/g, "\\$&");
1004
+ if (escaped.length === 0) return { source: "[^/]", end: i };
1005
+ return { source: `[${negated ? "^" : ""}${escaped}]`, end: i };
1006
+ }
1007
+
1008
+ /**
1009
+ * One `/`-delimited prefix of a brace-expanded word, compiled and tried against
1010
+ * the candidates. Braces are already gone by here; an unmatched one is literal.
1011
+ */
1012
+ function globPrefixNamesState(token: string): boolean {
1013
+ if (!GLOB_METACHAR_RE.test(token)) return false;
1014
+ const pattern = "^" + compileGlobPattern(token);
1015
+ let re: RegExp;
1016
+ try {
1017
+ re = new RegExp(pattern + "$");
1018
+ } catch {
1019
+ return false;
1020
+ }
1021
+ if (!STATE_GLOB_CANDIDATES.some((candidate) => re.test(candidate))) return false;
1022
+ return !GLOB_DECOYS.some((decoy) => re.test(decoy));
1023
+ }
1024
+
1025
+ /**
1026
+ * Every command-substitution body in a command, `$(…)` and backticked alike.
1027
+ *
1028
+ * A substitution runs its contents as a command of its own, so
1029
+ * `echo $(rm -rf ~/.failproofai)` deletes the state while presenting `echo` —
1030
+ * a mention command — as the head. Splitting on parentheses instead would have
1031
+ * cost the opposite case: `rm -rf $(ls -d ~/.failproofai)` is a real delete
1032
+ * whose INNER command only reads, and the outer `rm` has to keep denying. So
1033
+ * the bodies are pulled out and judged as commands as well as, not instead of,
1034
+ * the whole string.
1035
+ */
1036
+ function substitutionBodies(command: string): string[] {
1037
+ const bodies: string[] = [];
1038
+ for (let i = 0; i < command.length; i++) {
1039
+ if (command[i] === "`") {
1040
+ const close = command.indexOf("`", i + 1);
1041
+ if (close === -1) break;
1042
+ bodies.push(command.slice(i + 1, close));
1043
+ i = close;
1044
+ continue;
1045
+ }
1046
+ if (command[i] !== "$" || command[i + 1] !== "(") continue;
1047
+ let depth = 1;
1048
+ let j = i + 2;
1049
+ for (; j < command.length && depth > 0; j++) {
1050
+ if (command[j] === "(") depth++;
1051
+ else if (command[j] === ")") depth--;
1052
+ }
1053
+ if (depth === 0) bodies.push(command.slice(i + 2, j - 1));
1054
+ i = j - 1;
1055
+ }
1056
+ return bodies;
1057
+ }
1058
+
1059
+ /** An output redirect and the token it targets: `>f`, `> f`, `2>f`, `>>f`, `>|f`. */
1060
+ const REDIRECT_TARGET_RE = /\d*>{1,2}\|?\s*("[^"]*"|'[^']*'|[^\s;&|<>]+)/g;
1061
+
1062
+ /**
1063
+ * Strip the punctuation a shell consumes, so `(cd` reads as `cd`.
1064
+ *
1065
+ * Quotes come off for the same reason the brackets do, and they are the pair
1066
+ * that mattered: `bash -c 'cat ~/.failproofai/policies-config.json'` presented
1067
+ * a head of `'cat`, which is on no allowlist, so the guard denied a plain read
1068
+ * of the config through the most ordinary wrapper there is. Stripping them
1069
+ * cannot let a deleter past — `"rm"` unquotes to `rm`, which is still not a
1070
+ * reader.
1071
+ */
1072
+ function bareToken(token: string): string {
1073
+ return token.replace(/^[(){}'"]+|[(){}'"]+$/g, "");
1074
+ }
1075
+
1076
+ /**
1077
+ * Quotes only, for the glob scan.
1078
+ *
1079
+ * `bareToken` also drops `(`, `)`, `{` and `}`, because a shell group and a
1080
+ * subshell write them around a command. Every one of those characters is ALSO
1081
+ * how a pattern is spelled, and both spellings end the token with one: a brace
1082
+ * expansion (`~/.{fail*,zz}`) and an extended glob (`~/.f@(ailproofai)`) each
1083
+ * arrived here decapitated — an unterminated group that expands to nothing and
1084
+ * compiles to a pattern matching nothing. A closing character that really did
1085
+ * belong to a shell group leaves a word still carrying the literal path, which
1086
+ * the literal check reads, and `globCouldNameState` tries the stripped form too.
1087
+ */
1088
+ function unquotedToken(token: string): string {
1089
+ return token.replace(/^['"]+|['"]+$/g, "");
1090
+ }
1091
+
1092
+ /** The name a shell would exec, given a token: `/usr/bin/rm` → `rm`. */
1093
+ function commandBasename(token: string): string {
1094
+ const bare = bareToken(token);
1095
+ return bare.slice(bare.lastIndexOf("/") + 1);
1096
+ }
1097
+
1098
+ /**
1099
+ * Walk off everything a shell resolves before it settles on the command word,
1100
+ * and return the tokens from the command word on. Mirrors the walk in
1101
+ * `classifySelfInvocation` — the same prefixes hide a deleter that hid the
1102
+ * binary, and `xargs -0 rm -f` is the one that matters most here.
1103
+ */
1104
+ function commandWords(simpleCommand: string): string[] {
1105
+ const tokens = simpleCommand.trim().split(/\s+/).map(bareToken).filter(Boolean);
1106
+ let i = 0;
1107
+ while (i < tokens.length) {
1108
+ const token = tokens[i];
1109
+ // A redirect operator and, when it stands alone, the token it targets —
1110
+ // `> file` must not leave `file` sitting where the command word goes.
1111
+ if (/^\d*(?:>{1,2}\|?|<{1,3})/.test(token)) {
1112
+ i += /^\d*(?:>{1,2}\|?|<{1,3})$/.test(token) ? 2 : 1;
1113
+ continue;
1114
+ }
1115
+ const skippable =
1116
+ ENV_ASSIGNMENT_RE.test(token) ||
1117
+ token.startsWith("-") ||
1118
+ RUNNER_OPERAND_RE.test(token) ||
1119
+ SHELL_KEYWORD_PREFIXES.has(token) ||
1120
+ COMMAND_PREFIX_TOKENS.has(commandBasename(token));
1121
+ if (!skippable) break;
1122
+ i++;
1123
+ }
1124
+ return tokens.slice(i);
1125
+ }
1126
+
1127
+ /**
1128
+ * True when this one simple command would destroy whatever it is pointed at.
1129
+ *
1130
+ * Reached only for a command that already names failproofai's state, so the
1131
+ * question is narrow: is this a read, or is it everything else.
1132
+ */
1133
+ function simpleCommandDestroys(words: string[], namesState: (text: string) => boolean): boolean {
1134
+ if (words.length === 0) return false;
1135
+ const head = commandBasename(words[0]);
1136
+ const args = words.slice(1);
1137
+
1138
+ // `find` is a read until one of its ACTIONS turns it into something else.
1139
+ // `-delete` is the miss that started all of this; `-exec` runs anything at
1140
+ // all, so it is judged by the command it hands the paths to, and an
1141
+ // unrecognised one is treated as destructive rather than waved through.
1142
+ if (head === "find") {
1143
+ if (args.some((a) => a === "-delete")) return true;
1144
+ // `-fprint <file>` and its siblings truncate the file they report INTO, so
1145
+ // `find /etc -fprint ~/.failproofai/policies-config.json` empties the config
1146
+ // while never descending into the state at all.
1147
+ if (args.some((a) => FIND_WRITE_ACTIONS.has(a))) return true;
1148
+ // EVERY `-exec`, not the first: `find … -exec cat {} + -o -exec rm {} +`
1149
+ // put a read in front of the deleter and walked past a findIndex.
1150
+ return args.some(
1151
+ (a, i) => FIND_EXEC_ACTIONS.has(a) && !SAFE_EXEC_COMMANDS.has(commandBasename(args[i + 1] ?? "")),
1152
+ );
1153
+ }
1154
+
1155
+ // `sed -i` edits in place — and so does the `w` command inside a script,
1156
+ // which needs no flag at all: `sed 's/a/b/w <state>' /etc/hosts` writes the
1157
+ // state without `-i` anywhere on the line.
1158
+ if (head === "sed") {
1159
+ if (args.some((a) => a.startsWith("-i") || a.startsWith("--in-place"))) return true;
1160
+ // The filename may ride in the same token (`'s/a/b/w<path>'`) or in the
1161
+ // next one, because a quoted script containing a space is two tokens by the
1162
+ // time it gets here. `s/a/b/w` and a bare `w` both end in a `w` that
1163
+ // follows a delimiter, which is what separates them from a word like `raw`.
1164
+ return args.some(
1165
+ (a, i) => /(?:^|[;}/\s])w$/.test(a) && namesState(args[i + 1] ?? ""),
1166
+ ) || args.some((a) => /(?:^|[;}/\s])w\s*\S*\.failproofai/.test(a));
1167
+ }
1168
+
1169
+ // `awk` can run a shell (`system("rm -rf …")`) or pipe into one, so its
1170
+ // program has to be read for those two, not just for the `>` the redirect
1171
+ // scan already catches.
1172
+ if (head === "awk" || head === "gawk" || head === "mawk") {
1173
+ return args.some((a) => /system\s*\(|\|\s*["']|\|&/.test(a));
1174
+ }
1175
+
1176
+ // Copying the state OUT is a backup. Copying anything ONTO it — classically
1177
+ // `/dev/null` — empties it without naming a delete verb. The destination is
1178
+ // the last operand, unless `-t` names it instead: `cp -t ~/.failproofai
1179
+ // /dev/null` puts the destination FIRST and left the last operand innocent.
1180
+ if (COPY_COMMANDS.has(head)) {
1181
+ // `rsync --remove-source-files` deletes what it just copied, so the state
1182
+ // being the SOURCE — the shape that makes every other copy a backup — is
1183
+ // what makes this one a move.
1184
+ if (args.includes("--remove-source-files") && args.some((a) => namesState(a))) return true;
1185
+ const targetAt = args.findIndex((a) => a === "-t" || a === "--target-directory");
1186
+ if (targetAt !== -1 && namesState(args[targetAt + 1] ?? "")) return true;
1187
+ if (args.some((a) => a.startsWith("--target-directory=") && namesState(a))) return true;
1188
+ const operands = args.filter((a) => !a.startsWith("-"));
1189
+ const destination = operands[operands.length - 1];
1190
+ return destination !== undefined && namesState(destination);
1191
+ }
1192
+
1193
+ // `tee` writes every operand it is given and reads none of them.
1194
+ if (OPERAND_WRITE_COMMANDS.has(head)) {
1195
+ return args.some((a) => !a.startsWith("-") && namesState(a));
1196
+ }
1197
+
1198
+ // `uniq INPUT OUTPUT` and `xxd IN OUT`: the second operand is written.
1199
+ if (SECOND_OPERAND_WRITERS.has(head)) {
1200
+ const operands = args.filter((a) => !a.startsWith("-"));
1201
+ return operands.length > 1 && namesState(operands[operands.length - 1]);
1202
+ }
1203
+
1204
+ // `git` normally carries the path as prose in a message or a body.
1205
+ if (head === "git") {
1206
+ const subcommand = gitSubcommand(args);
1207
+ return subcommand !== undefined && GIT_DESTRUCTIVE_SUBCOMMANDS.has(subcommand);
1208
+ }
1209
+
1210
+ // A reader whose destination arrives as an option rather than as an operand.
1211
+ // Checked before the allowlist, or `sort -o <state> /dev/null` reads as the
1212
+ // `sort` it is named after.
1213
+ if (writesViaOutputFlag(head, args, namesState)) return true;
1214
+
1215
+ if (STATE_READ_COMMANDS.has(head) || STATE_MENTION_COMMANDS.has(head)) return false;
1216
+ return true;
1217
+ }
1218
+
1219
+ /**
1220
+ * True when a flag on this command names the state as an output file.
1221
+ *
1222
+ * `sort -o`, `tree -o` and `curl -o` all take their destination as an option,
1223
+ * so nothing about the head or the operand order says they are writing — and
1224
+ * all three heads were on a list that said they only read. Short options bundle
1225
+ * (`curl -sfo <file>`), so the trailing letter is what decides.
1226
+ */
1227
+ function writesViaOutputFlag(head: string, args: string[], namesState: (text: string) => boolean): boolean {
1228
+ const flags = OUTPUT_FLAG_COMMANDS[head];
1229
+ if (!flags) return false;
1230
+ for (let i = 0; i < args.length; i++) {
1231
+ const arg = args[i];
1232
+ const long = flags.find((f) => f.startsWith("--") && arg.startsWith(`${f}=`));
1233
+ if (long && namesState(arg)) return true;
1234
+ const bundled = /^-[A-Za-z]*[oD]$/.test(arg) && flags.includes(`-${arg[arg.length - 1]}`);
1235
+ if ((flags.includes(arg) || bundled) && namesState(args[i + 1] ?? "")) return true;
1236
+ }
1237
+ return false;
1238
+ }
1239
+
1240
+ /**
1241
+ * True when `command` would delete, move or overwrite failproofai's own state.
1242
+ *
1243
+ * Deleting the state IS disabling enforcement, and it never names the binary:
1244
+ * `rm ~/.failproofai/policies/packs/installed.json` switches off every pack
1245
+ * policy on the machine, and fail-closed does NOT fire, because a missing store
1246
+ * reads as a fresh machine rather than a broken one — so nothing anywhere
1247
+ * reports it.
1248
+ *
1249
+ * Two things carry the path into a command that never spells it, and both were
1250
+ * live bypasses of the per-segment version:
1251
+ *
1252
+ * - `cd ~/.failproofai && rm -rf .` — the path is an argument of `cd`, and the
1253
+ * destroying command names only `.`
1254
+ * - `D=~/.failproofai; rm -rf $D` — the path is a value, and the destroying
1255
+ * command names only `$D`
1256
+ *
1257
+ * So the walk is stateful: it remembers which variables hold the path and
1258
+ * whether the shell has been moved INTO the directory, and carries both
1259
+ * forward. What it does NOT do is treat every later command as suspect —
1260
+ * `cat ~/.failproofai/config.json && rm /tmp/scratch` has to stay allowed, and
1261
+ * does, because nothing in the second command reaches the state.
1262
+ */
1263
+ function destroysFailproofaiState(command: string, depth = 0): boolean {
1264
+ // A substitution runs its body as a command of its own, so it is judged as
1265
+ // one — `echo $(rm -rf ~/.failproofai)` otherwise presents `echo` as the head
1266
+ // and walks straight through. Bounded, because a body can contain another.
1267
+ // Breadth-first with a budget rather than depth-limited recursion: a nest
1268
+ // deeper than the limit is trivial to write (`echo $(echo $(… rm …))`), and
1269
+ // every level of it presents `echo` as the head. The budget is what bounds
1270
+ // the work instead, because siblings multiply where depth does not.
1271
+ if (depth === 0) {
1272
+ const bodies = substitutionBodies(command);
1273
+ for (let i = 0; i < bodies.length && i < 64; i++) {
1274
+ if (destroysFailproofaiState(bodies[i], 1)) return true;
1275
+ bodies.push(...substitutionBodies(bodies[i]));
1276
+ }
1277
+ }
1278
+
1279
+ // Variables that hold the path. The scan used to ask only whether a value
1280
+ // CONTAINED the path, which caught `D=~/.failproofai; rm -rf $D` and missed
1281
+ // `A=~/.failproofai; B=$A; rm -rf $B` — the second hop names no path at all,
1282
+ // only the first variable. So a value referencing a known state variable
1283
+ // counts too. Repeating to a fixpoint costs four lines and makes the result
1284
+ // independent of the order the assignments appear in, rather than resting on
1285
+ // a shell evaluating them top to bottom.
1286
+ const assignments = [
1287
+ ...command.matchAll(/(?:^|[\s;&|(])(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|[^\s;&|)]+)/g),
1288
+ ].map(([, name, value]) => [name, value.replace(/^["']|["']$/g, "")] as const);
1289
+ const stateVars = new Set<string>();
1290
+ for (let pass = 0; pass < assignments.length + 1; pass++) {
1291
+ const before = stateVars.size;
1292
+ for (const [name, value] of assignments) {
1293
+ const holdsState =
1294
+ FAILPROOFAI_STATE_PATH_RE.test(value) ||
1295
+ [...value.matchAll(VAR_REFERENCE_RE)].some(([, ref]) => stateVars.has(ref));
1296
+ if (holdsState) stateVars.add(name);
1297
+ }
1298
+ if (stateVars.size === before) break;
1299
+ }
1300
+
1301
+ const namesState = (text: string): boolean => {
1302
+ if (FAILPROOFAI_STATE_PATH_RE.test(text)) return true;
1303
+ for (const [, name] of text.matchAll(VAR_REFERENCE_RE)) {
1304
+ if (stateVars.has(name)) return true;
1305
+ }
1306
+ // Per token, not over the whole text: a glob compiled from a whole command
1307
+ // line matches nothing, and it is the individual operand — `~/.failproof*`
1308
+ // — that would expand onto the state.
1309
+ return text.split(/\s+/).some((token) => globCouldNameState(unquotedToken(token)));
1310
+ };
1311
+
1312
+ let cwdInState = false;
1313
+ // How deep in `( … )` the walk is, and how deep it was when the `cd` fired.
1314
+ // A `cd` inside a subshell moves the subshell only, so the window closes when
1315
+ // the parenthesis does: `(cd ~/.failproofai && cat x); rm -rf node_modules`
1316
+ // was denying a cleanup in an entirely unrelated directory.
1317
+ let parenDepth = 0;
1318
+ let cdParenDepth: number | null = null;
1319
+ // A loop header hands its word list to the body, and the body is a separate
1320
+ // pipeline that names nothing. Without this, `for f in ~/.failproofai/*; do
1321
+ // rm -rf $f; done` is a header that only expands and a body that only deletes
1322
+ // something called `$f`.
1323
+ let loopCarriesState = false;
1324
+ for (const pipeline of command.split(PIPELINE_SEPARATORS)) {
1325
+ if (!pipeline.trim()) continue;
1326
+ const simpleCommands = pipeline.split("|");
1327
+ const reachesState = namesState(pipeline) || cwdInState || loopCarriesState;
1328
+ if (reachesState && LOOP_HEADER_RE.test(pipeline)) loopCarriesState = true;
1329
+ if (/(?:^|\s)done(?:\s|$)/.test(pipeline)) loopCarriesState = false;
1330
+
1331
+ // A redirect is a write with no command in front of it — `> path` empties a
1332
+ // file on its own. Only a redirect whose TARGET is the state counts: the
1333
+ // previous version matched any `>` anywhere in the segment, so
1334
+ // `grep -r sudo ~/.failproofai 2>/dev/null` and
1335
+ // `cat ~/.failproofai/config.json > /tmp/backup.json` both denied, which is
1336
+ // exactly the diagnosis this guard must not block.
1337
+ if (reachesState) {
1338
+ for (const [, target] of pipeline.matchAll(REDIRECT_TARGET_RE)) {
1339
+ if (namesState(target.replace(/^["']|["']$/g, ""))) return true;
1340
+ }
1341
+ }
1342
+
1343
+ for (const simple of simpleCommands) {
1344
+ parenDepth += (simple.match(/\(/g) ?? []).length;
1345
+ const words = commandWords(simple);
1346
+ const head = words.length > 0 ? commandBasename(words[0]) : "";
1347
+ // Track the shell's position before judging: a `cd` INTO the state makes
1348
+ // every later relative path a state path, and a `cd` back out ends that.
1349
+ if (head === "cd" || head === "pushd") {
1350
+ const operand = words.slice(1).find((w) => !w.startsWith("-"));
1351
+ cwdInState = operand !== undefined && namesState(operand);
1352
+ cdParenDepth = cwdInState ? parenDepth : null;
1353
+ } else if (head === "popd") {
1354
+ // `popd` returns to wherever the shell was before `pushd`, which ends
1355
+ // the window as surely as a `cd` out does. Without it every command for
1356
+ // the rest of the line was judged as if it stood in the state directory.
1357
+ cwdInState = false;
1358
+ cdParenDepth = null;
1359
+ } else if (reachesState && words.length > 0 && simpleCommandDestroys(words, namesState)) {
1360
+ // Judged BEFORE the closing parenthesis is counted: a `rm -rf ./*)`
1361
+ // still runs inside the subshell it closes.
1362
+ return true;
1363
+ }
1364
+ parenDepth -= (simple.match(/\)/g) ?? []).length;
1365
+ if (cdParenDepth !== null && parenDepth < cdParenDepth) {
1366
+ cwdInState = false;
1367
+ cdParenDepth = null;
1368
+ }
1369
+ }
1370
+ }
1371
+ return false;
1372
+ }
463
1373
 
464
1374
  const FAILPROOFAI_UNINSTALL_RE = /(?:npm\s+(?:uninstall|remove|un|r)\s.*failproofai|bun\s+remove\s.*failproofai|yarn\s+global\s+remove\s+failproofai|pnpm\s+(?:remove|uninstall|un)\s.*failproofai)/;
465
1375
 
@@ -1453,6 +2363,22 @@ function blockWorkOnMain(ctx: PolicyContext): PolicyResult {
1453
2363
  * merge keeps the behaviour users have.
1454
2364
  */
1455
2365
  function blockFailproofaiCommands(ctx: PolicyContext): PolicyResult {
2366
+ // A file tool reaches the state without a shell at all. Writing
2367
+ // `{"enabledPolicies":[]}` over `~/.failproofai/policies-config.json`, or an
2368
+ // empty `policies/packs/installed.json`, disables enforcement exactly as
2369
+ // completely as `rm -rf` does — and the machine still reads as fresh, so
2370
+ // fail-closed never fires. Reading is not offered by these tools' write half,
2371
+ // so naming the state at all is the whole test.
2372
+ if (ctx.toolName === "Write" || ctx.toolName === "Edit" || ctx.toolName === "NotebookEdit") {
2373
+ const path = getFilePath(ctx) || ((ctx.toolInput?.notebook_path as string) ?? "");
2374
+ if (FAILPROOFAI_STATE_PATH_RE.test(path)) {
2375
+ return deny(
2376
+ "Writing to failproofai's own state would switch enforcement off. " +
2377
+ "If a policy is blocking legitimate work, say so and let the operator decide.",
2378
+ );
2379
+ }
2380
+ return allow();
2381
+ }
1456
2382
  if (ctx.toolName !== "Bash") return allow();
1457
2383
  const cmd = getCommand(ctx);
1458
2384
  // The raw command AND its shell-unescaped form: a shell strips quotes and
@@ -1476,13 +2402,12 @@ function blockFailproofaiCommands(ctx: PolicyContext): PolicyResult {
1476
2402
  return deny("Uninstalling failproofai is blocked");
1477
2403
  }
1478
2404
  // Deleting the state IS disabling enforcement, without ever naming the binary.
1479
- // `rm ~/.failproofai/policies/packs/installed.json` switched off every pack
1480
- // policy on the machine and fail-closed did NOT fire, because a missing store
1481
- // reads as a fresh machine rather than a broken one — so nothing anywhere
1482
- // reported it.
1483
- if (FAILPROOFAI_STATE_WRITE_RE.test(cmd) || FAILPROOFAI_STATE_WRITE_RE.test(unescaped)) {
2405
+ // Checked on the raw command AND its shell-unescaped form for the same reason
2406
+ // the binary walk is: `rm -rf ~/.failproof"ai"` presents a broken literal to a
2407
+ // matcher and a real path to the shell.
2408
+ if (destroysFailproofaiState(cmd) || destroysFailproofaiState(unescaped)) {
1484
2409
  return deny(
1485
- "Deleting or moving failproofai's own state would switch enforcement off. " +
2410
+ "Deleting, moving or overwriting failproofai's own state would switch enforcement off. " +
1486
2411
  "If a policy is blocking legitimate work, say so and let the operator decide.",
1487
2412
  );
1488
2413
  }