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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +4 -4
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -3
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +4 -4
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__0-9ti7z._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[externals]__1lt47k4._.js → [externals]__0sg9-3p._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__0u3wrvq._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zlqo2t._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1anxb5k._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p-qi2t._.js +5 -0
- package/.next/standalone/.next/server/chunks/_09dz7xv._.js +27 -0
- package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
- package/.next/standalone/.next/server/chunks/{_19uwam-._.js → _1-akg6w._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -7
- 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
- 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
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_0iu54mz._.js +3 -0
- package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +10 -0
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n_lxhg._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1yh7hlv._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0ehudfr._.js +23 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1n2ywfz._.js → _1es2j7i._.js} +4 -4
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_1h33neq._.js → _1un1pug._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +9 -9
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +10 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware.js +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
- package/.next/standalone/.next/static/chunks/0nkpwjk01ei3t.js +1 -0
- package/.next/standalone/.next/static/chunks/{2iryw37qfqy5y.js → 0ofdelz65_517.js} +1 -1
- package/.next/standalone/.next/static/chunks/{34j7nmidlcuni.js → 12uhnpwgsmcev.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0i3a6wf-f_yky.js → 1_1eb_149xyt9.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3927pu4ss_l4k.js → 1tx9pnxyjrkoj.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1n04wzsaes73g.js → 1x0jrl3a4_i2m.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3r0udrtpn78-e.js → 1zpv6omeln7al.js} +1 -1
- package/.next/standalone/.next/static/chunks/{416zumre3fvfw.js → 20hbg-r3io_ip.js} +1 -1
- package/.next/standalone/.next/static/chunks/{11a_m8yksthtf.js → 32bhi32ym8yrz.js} +2 -2
- package/.next/standalone/.next/static/chunks/{0u2ojcw25c_np.js → 3fr78ugeb4tjc.js} +1 -1
- package/.next/standalone/app/actions/get-hooks-config.ts +8 -1
- package/.next/standalone/app/actions/pack-actions.ts +12 -1
- package/.next/standalone/app/policies/hooks-client.tsx +41 -4
- package/.next/standalone/fp-cloud-cli/tests/test_pagination.py +204 -0
- package/.next/standalone/package.json +9 -9
- package/.next/standalone/server.js +1 -1
- package/README.md +58 -13
- package/bin/failproofai.mjs +10 -1
- package/dist/cli.mjs +22429 -21292
- package/dist/worker.mjs +1247 -540
- package/package.json +9 -9
- package/src/audit/index.ts +24 -4
- package/src/hooks/builtin-policies.ts +933 -8
- package/src/hooks/custom-hooks-loader.ts +98 -8
- package/src/hooks/handler.ts +26 -14
- package/src/hooks/manager.ts +36 -2
- package/src/hooks/pack-cli.ts +415 -42
- package/src/hooks/pack-failclosed.ts +100 -4
- package/src/hooks/pack-manifest.ts +29 -0
- package/src/hooks/pack-param-key.ts +36 -0
- package/src/hooks/pack-store.ts +101 -9
- package/src/hooks/policy-catalog.ts +9 -1
- package/src/hooks/policy-evaluator.ts +77 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__06cuf1y._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0jnjf2t._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tg4pu6._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mgrjnx._.js +0 -10
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-wzs7f._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1wlvpe5._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/_0l7zv8b._.js +0 -23
- package/.next/standalone/.next/static/chunks/1ch5l9t2p16y4.js +0 -1
- /package/.next/standalone/.next/static/{NgD5HvuyPxwgrpeIiafJL → 5-xDMxcnrcXDFPQiy3yhg}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NgD5HvuyPxwgrpeIiafJL → 5-xDMxcnrcXDFPQiy3yhg}/_clientMiddlewareManifest.js +0 -0
- /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
|
|
462
|
-
|
|
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
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
|
|
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
|
|
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
|
}
|