failproofai 1.0.0-beta.7 → 1.0.0-beta.8

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 (124) 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 +3 -3
  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 +2 -2
  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/_head.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  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/reminder/route.js.nft.json +1 -1
  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 +2 -2
  41. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  42. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  43. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  44. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  45. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  46. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  49. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  62. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +3 -3
  68. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ommekk._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0c_qcgp._.js → [root-of-the-server]__1tkyag1._.js} +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tyf0tc._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1v185mo._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/_04mekdy._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/{_194yh9u._.js → _0i4l3_6._.js} +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/_0r-dsre._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/_0se484m._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/_0uz7o9y._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/_1fahg9e._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/_1w8sola._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/_1w_6p9g._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  89. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  90. package/.next/standalone/.next/server/pages/404.html +1 -1
  91. package/.next/standalone/.next/server/pages/500.html +1 -1
  92. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  93. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  94. package/.next/standalone/.next/static/chunks/{2aej0vi1fm87m.js → 02vvywmww22zz.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{3t1wr_kx6brwc.js → 0_-focvo4cuiv.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{2bxld6-rym3ah.js → 1fwh-2un_l-eo.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2p2kz4aj1tooj.js → 1zrdsxe91qq6i.js} +1 -1
  98. package/.next/standalone/.next/static/chunks/{0m-m7wgtxdoav.js → 2047vah_v4emu.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/23hl78dzffun4.js +6 -0
  100. package/.next/standalone/.next/static/chunks/{11l6oh9cyb35a.js → 2bw0stk-s-1dd.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{0gr76ps8_zm26.js → 2r_j5wrkbu5wm.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/3m8onpqrjxzu5.js +1 -0
  103. package/.next/standalone/.next/static/chunks/{36pc3_8fpb6ic.js → 3yn1y6ckypqwp.js} +1 -1
  104. package/.next/standalone/package.json +5 -5
  105. package/.next/standalone/server.js +1 -1
  106. package/bin/failproofai.mjs +118 -1
  107. package/dist/cli.mjs +1166 -101
  108. package/dist/worker.mjs +77 -6
  109. package/package.json +5 -5
  110. package/scripts/repro-npm-install.sh +155 -0
  111. package/src/hooks/builtin-policies.ts +154 -2
  112. package/src/hooks/configure-wizard.ts +16 -6
  113. package/src/hooks/daemon-client.ts +35 -0
  114. package/src/hooks/daemon-service.ts +227 -22
  115. package/src/hooks/first-run-gate.ts +9 -1
  116. package/src/hooks/fp-reset.ts +25 -0
  117. package/src/hooks/uninstall-cli.ts +345 -0
  118. package/src/hooks/worker-server.ts +12 -2
  119. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kbc9bs._.js +0 -3
  120. package/.next/standalone/.next/static/chunks/0945ybz3fsvba.js +0 -1
  121. package/.next/standalone/.next/static/chunks/166f8qnh03jqc.js +0 -6
  122. /package/.next/standalone/.next/static/{LUaLkKg40nSkHrIajexIk → gOR8i03AeQm1VnIayDHVV}/_buildManifest.js +0 -0
  123. /package/.next/standalone/.next/static/{LUaLkKg40nSkHrIajexIk → gOR8i03AeQm1VnIayDHVV}/_clientMiddlewareManifest.js +0 -0
  124. /package/.next/standalone/.next/static/{LUaLkKg40nSkHrIajexIk → gOR8i03AeQm1VnIayDHVV}/_ssgManifest.js +0 -0
package/dist/worker.mjs CHANGED
@@ -990,11 +990,82 @@ function blockEnvFiles(ctx) {
990
990
  }
991
991
  return allow();
992
992
  }
993
+ function quoteAwareSegments(command) {
994
+ const segments = [];
995
+ let tokens = [];
996
+ let token = "";
997
+ let quote = null;
998
+ const endToken = () => {
999
+ if (token)
1000
+ tokens.push(token);
1001
+ token = "";
1002
+ };
1003
+ const endSegment = () => {
1004
+ endToken();
1005
+ if (tokens.length)
1006
+ segments.push(tokens);
1007
+ tokens = [];
1008
+ };
1009
+ for (let i = 0;i < command.length; i++) {
1010
+ const c = command[i];
1011
+ if (quote) {
1012
+ if (c === "\\" && quote === '"' && i + 1 < command.length) {
1013
+ token += command[++i];
1014
+ continue;
1015
+ }
1016
+ if (c === quote) {
1017
+ quote = null;
1018
+ continue;
1019
+ }
1020
+ token += c;
1021
+ continue;
1022
+ }
1023
+ if (c === "\\" && i + 1 < command.length) {
1024
+ token += command[++i];
1025
+ continue;
1026
+ }
1027
+ if (c === '"' || c === "'") {
1028
+ quote = c;
1029
+ continue;
1030
+ }
1031
+ if (/\s/.test(c)) {
1032
+ endToken();
1033
+ continue;
1034
+ }
1035
+ if (/[;&|\n\r(){}`]/.test(c)) {
1036
+ endSegment();
1037
+ continue;
1038
+ }
1039
+ token += c;
1040
+ }
1041
+ endSegment();
1042
+ return segments;
1043
+ }
1044
+ function namesElevation(command, depth = 0) {
1045
+ for (const tokens of quoteAwareSegments(command)) {
1046
+ let i = 0;
1047
+ while (i < tokens.length) {
1048
+ const token = tokens[i];
1049
+ const base = token.slice(token.lastIndexOf("/") + 1);
1050
+ if (base === "sudo" || base === "doas")
1051
+ return true;
1052
+ const isSkippable = ENV_ASSIGNMENT_RE.test(token) || token.startsWith("-") || token.startsWith(">") || token.startsWith("<") || RUNNER_OPERAND_RE.test(token) || COMMAND_PREFIX_TOKENS.has(base);
1053
+ if (!isSkippable)
1054
+ break;
1055
+ if (EVAL_FLAG_RE.test(token) && depth < 3 && i + 1 < tokens.length) {
1056
+ if (namesElevation(tokens[i + 1], depth + 1))
1057
+ return true;
1058
+ }
1059
+ i++;
1060
+ }
1061
+ }
1062
+ return false;
1063
+ }
993
1064
  function blockSudo(ctx) {
994
1065
  if (ctx.toolName !== "Bash")
995
1066
  return allow();
996
1067
  const cmd = getCommand(ctx).trimStart();
997
- if (SUDO_RE.test(cmd) || cmd.startsWith("sudo ")) {
1068
+ if (namesElevation(cmd)) {
998
1069
  const allowPatterns = ctx.params?.allowPatterns ?? [];
999
1070
  if (allowPatterns.some((p) => matchesAllowedPattern(cmd, p)))
1000
1071
  return allow();
@@ -1754,7 +1825,7 @@ function registerBuiltinPolicies(enabledNames) {
1754
1825
  }
1755
1826
  }
1756
1827
  }
1757
- var isClaudeInternalPath, isClaudeSettingsFile, SHELL_OPERATORS, SHELL_METACHAR_RE, JWT_RE, API_KEY_PATTERNS, CONNECTION_STRING_RE, PRIVATE_KEY_RE, BEARER_TOKEN_RE, SQL_TOOL_RE, DESTRUCTIVE_SQL_RE, DELETE_NO_WHERE_RE, SQL_WHERE_RE, SCHEMA_ALTER_RE, PUBLISH_CMD_RE, ENV_PRINTENV_RE, ECHO_ENV_RE, EXPORT_RE, PS_ENV_VAR_RE, PS_CHILDITEM_ENV_RE, DOTNET_GETENV_RE, CMD_ECHO_ENV_RE, ENV_FILE_PATH_RE, ENV_CMD_RE, SUDO_RE, PS_ELEVATION_RE, RUNAS_RE, CURL_PIPE_SH_RE, SEGMENT_SEPARATORS, COMMAND_PREFIX_TOKENS, SELF_BINARY_TOKEN_RE, CONFIG_SUBCOMMAND_RE, PAUSE_FLAG_RE, ENV_ASSIGNMENT_RE, RUNNER_OPERAND_RE, PS_WEB_PIPE_RE, SHORT_FLAG_BUNDLE_RE, SAFE_FORCE_PREFIXES, SECRET_FILE_RE, SECRET_FILE_ID_RSA_RE, SECRET_FILE_CREDENTIALS_RE, GIT_COMMIT_MERGE_RE, FAILPROOFAI_CLI_RE, FAILPROOFAI_UNINSTALL_RE, GIT_AMEND_RE, GIT_STASH_DROP_RE, GIT_ADD_ALL_RE, NPM_GLOBAL_RE, YARN_GLOBAL_RE, PNPM_GLOBAL_RE, BUN_GLOBAL_RE, CARGO_INSTALL_RE, PIP_SYSTEM_RE, PKG_MANAGER_DETECTORS, NOHUP_RE, SCREEN_DETACH_RE, TMUX_DETACH_RE, DISOWN_RE, BACKGROUND_AMPERSAND_RE, KUBECTL_RE, TERRAFORM_RE, AWS_CLI_RE, GCLOUD_RE, AZ_CLI_RE, HELM_RE, GH_PIPELINE_RE, gitBranchCache, GIT_BRANCH_CACHE_MAX_ENTRIES = 500, HOME_PREFIX_RE, RM_CMD_RE, FIND_CMD_RE, FIND_EXEC_RE, FIND_GLOBAL_OPT_RE, FIND_EXPR_START_RE, SCRATCH_ROOTS, CATASTROPHIC_DEPTH = 2, READ_LIKE_CMDS, TOOL_CALL_TRACKER_MAX_BYTES = 65536, SEGMENT_SPLIT_RE, BUILTIN_POLICIES;
1828
+ var isClaudeInternalPath, isClaudeSettingsFile, SHELL_OPERATORS, SHELL_METACHAR_RE, JWT_RE, API_KEY_PATTERNS, CONNECTION_STRING_RE, PRIVATE_KEY_RE, BEARER_TOKEN_RE, SQL_TOOL_RE, DESTRUCTIVE_SQL_RE, DELETE_NO_WHERE_RE, SQL_WHERE_RE, SCHEMA_ALTER_RE, PUBLISH_CMD_RE, ENV_PRINTENV_RE, ECHO_ENV_RE, EXPORT_RE, PS_ENV_VAR_RE, PS_CHILDITEM_ENV_RE, DOTNET_GETENV_RE, CMD_ECHO_ENV_RE, ENV_FILE_PATH_RE, ENV_CMD_RE, PS_ELEVATION_RE, RUNAS_RE, CURL_PIPE_SH_RE, SEGMENT_SEPARATORS, COMMAND_PREFIX_TOKENS, SELF_BINARY_TOKEN_RE, CONFIG_SUBCOMMAND_RE, PAUSE_FLAG_RE, ENV_ASSIGNMENT_RE, RUNNER_OPERAND_RE, PS_WEB_PIPE_RE, SHORT_FLAG_BUNDLE_RE, SAFE_FORCE_PREFIXES, SECRET_FILE_RE, SECRET_FILE_ID_RSA_RE, SECRET_FILE_CREDENTIALS_RE, GIT_COMMIT_MERGE_RE, FAILPROOFAI_CLI_RE, FAILPROOFAI_UNINSTALL_RE, GIT_AMEND_RE, GIT_STASH_DROP_RE, GIT_ADD_ALL_RE, NPM_GLOBAL_RE, YARN_GLOBAL_RE, PNPM_GLOBAL_RE, BUN_GLOBAL_RE, CARGO_INSTALL_RE, PIP_SYSTEM_RE, PKG_MANAGER_DETECTORS, NOHUP_RE, SCREEN_DETACH_RE, TMUX_DETACH_RE, DISOWN_RE, BACKGROUND_AMPERSAND_RE, KUBECTL_RE, TERRAFORM_RE, AWS_CLI_RE, GCLOUD_RE, AZ_CLI_RE, HELM_RE, GH_PIPELINE_RE, gitBranchCache, GIT_BRANCH_CACHE_MAX_ENTRIES = 500, EVAL_FLAG_RE, HOME_PREFIX_RE, RM_CMD_RE, FIND_CMD_RE, FIND_EXEC_RE, FIND_GLOBAL_OPT_RE, FIND_EXPR_START_RE, SCRATCH_ROOTS, CATASTROPHIC_DEPTH = 2, READ_LIKE_CMDS, TOOL_CALL_TRACKER_MAX_BYTES = 65536, SEGMENT_SPLIT_RE, BUILTIN_POLICIES;
1758
1829
  var init_builtin_policies = __esm(() => {
1759
1830
  init_hook_logger();
1760
1831
  isClaudeInternalPath = isAgentInternalPath;
@@ -1791,7 +1862,6 @@ var init_builtin_policies = __esm(() => {
1791
1862
  CMD_ECHO_ENV_RE = /echo\s+%[A-Za-z_]/i;
1792
1863
  ENV_FILE_PATH_RE = /(?:^|[\\/])\.env(?:\.|$)/;
1793
1864
  ENV_CMD_RE = /\.env(?:\b|\s|$|\.)/;
1794
- SUDO_RE = /(?:^|;|&&|\|\|)\s*sudo\s/;
1795
1865
  PS_ELEVATION_RE = /Start-Process\s+.*-Verb\s+RunAs/i;
1796
1866
  RUNAS_RE = /(?:^|;|&&|\|\|)\s*runas\s/i;
1797
1867
  CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh|dash|ksh|csh|tcsh|fish|ash)\b/;
@@ -1877,6 +1947,7 @@ var init_builtin_policies = __esm(() => {
1877
1947
  HELM_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*helm(?:\s|$)/;
1878
1948
  GH_PIPELINE_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*gh\s+(?:workflow\s+(?:run|enable|disable)|run\s+(?:rerun|cancel)|pr\s+merge|release\s+(?:create|delete)|cache\s+delete|secret\s+(?:set|delete))\b/;
1879
1949
  gitBranchCache = new Map;
1950
+ EVAL_FLAG_RE = /^-{1,2}c$/;
1880
1951
  HOME_PREFIX_RE = /^(?:~[A-Za-z0-9_.-]*|\$HOME|\$\{HOME\})(?=$|\/)/;
1881
1952
  RM_CMD_RE = /^(?:\/\S*\/)?rm$/;
1882
1953
  FIND_CMD_RE = /^(?:\/\S*\/)?find$/;
@@ -2434,7 +2505,7 @@ var init_builtin_policies = __esm(() => {
2434
2505
  });
2435
2506
 
2436
2507
  // package.json
2437
- var version = "1.0.0-beta.7";
2508
+ var version = "1.0.0-beta.8";
2438
2509
  var init_package = () => {};
2439
2510
 
2440
2511
  // src/posthog-key.ts
@@ -9993,7 +10064,7 @@ function isWorkerHookRequest(msg) {
9993
10064
  if (!msg || typeof msg !== "object")
9994
10065
  return false;
9995
10066
  const m = msg;
9996
- return m.type === "hook" && typeof m.hookEvent === "string" && typeof m.cli === "string" && typeof m.stdin === "string" && (m.cwd === undefined || typeof m.cwd === "string");
10067
+ return m.type === "hook" && typeof m.hookEvent === "string" && typeof m.cli === "string" && typeof m.stdin === "string" && (m.cwd === undefined || m.cwd === null || typeof m.cwd === "string");
9997
10068
  }
9998
10069
  function encodeFrame(value) {
9999
10070
  const body = Buffer.from(JSON.stringify(value), "utf8");
@@ -10052,7 +10123,7 @@ function handleConnection(socket) {
10052
10123
  try {
10053
10124
  const result = await evaluateHookEvent(request.hookEvent, request.cli, request.stdin, {
10054
10125
  awaitTelemetryFlush: false,
10055
- fallbackCwd: request.cwd
10126
+ fallbackCwd: request.cwd ?? undefined
10056
10127
  });
10057
10128
  socket.write(encodeFrame({
10058
10129
  type: "hookResult",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-beta.8",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs",
@@ -114,9 +114,9 @@
114
114
  "sharp": "0.35.0"
115
115
  },
116
116
  "optionalDependencies": {
117
- "@failproofai/failproofaid-linux-x64": "1.0.0-beta.7",
118
- "@failproofai/failproofaid-linux-arm64": "1.0.0-beta.7",
119
- "@failproofai/failproofaid-darwin-x64": "1.0.0-beta.7",
120
- "@failproofai/failproofaid-darwin-arm64": "1.0.0-beta.7"
117
+ "@failproofai/failproofaid-linux-x64": "1.0.0-beta.8",
118
+ "@failproofai/failproofaid-linux-arm64": "1.0.0-beta.8",
119
+ "@failproofai/failproofaid-darwin-x64": "1.0.0-beta.8",
120
+ "@failproofai/failproofaid-darwin-arm64": "1.0.0-beta.8"
121
121
  }
122
122
  }
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env bash
2
+ # ─────────────────────────────────────────────────────────────────────────────
3
+ # Reproduce a REAL user install, in the shape that actually breaks.
4
+ #
5
+ # sudo npm i -g failproofai (global prefix owned by root)
6
+ # then run the CLI as a NORMAL user
7
+ #
8
+ # That split is the whole point. A single-user laptop where npm's prefix is
9
+ # owned by the person running the hooks hides an entire class of bug — it is
10
+ # how the root-owned policy-shim fail-open shipped: every hook run by a non-root
11
+ # user hit EACCES, the policy never loaded, and the hook exited 0 ALLOWING the
12
+ # call, while builtins kept firing so the machine looked protected.
13
+ #
14
+ # systemd is real here (jrei/systemd-ubuntu), so `failproofai config` installs
15
+ # and starts the actual service rather than skipping that half.
16
+ #
17
+ # Usage:
18
+ # scripts/repro-npm-install.sh # build, pack, drop into a shell
19
+ # scripts/repro-npm-install.sh --published # install the last RELEASE instead
20
+ # ─────────────────────────────────────────────────────────────────────────────
21
+ set -uo pipefail
22
+
23
+ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
24
+ cd "$REPO"
25
+
26
+ IMAGE=jrei/systemd-ubuntu:24.04
27
+ NAME=fpai-repro
28
+ USER_IN_BOX=dev
29
+ PUBLISHED=0
30
+ [ "${1:-}" = "--published" ] && PUBLISHED=1
31
+
32
+ VERSION=$(node -p "require('$REPO/package.json').version")
33
+ say() { printf '\n\033[1m── %s\033[0m\n' "$1"; }
34
+
35
+ # ── 1. Build what a tarball would carry ──────────────────────────────────────
36
+ if [ "$PUBLISHED" -eq 0 ]; then
37
+ say "Building dist + daemon"
38
+ [ -f dist/cli.mjs ] || bun run build
39
+ [ -f target/release/failproofaid ] || cargo build --release --bin failproofaid
40
+
41
+ # `dist/cli.mjs` INLINES the version at build time. `npm pack --ignore-scripts`
42
+ # skips the `prepare` script that would rebuild it, so bumping package.json
43
+ # without rebuilding packs a bundle that reports the OLD version — and you
44
+ # spend an afternoon testing code you did not write. A real publish never hits
45
+ # this (publish.yml builds, and `prepare` runs on a normal pack); this is a
46
+ # trap of the fast path only, which is exactly why it is checked here.
47
+ if ! grep -q "$VERSION" dist/cli.mjs 2>/dev/null; then
48
+ say "dist is stale (built for $(grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+-beta\.[0-9]\+' dist/cli.mjs | head -1)) — rebuilding"
49
+ bun run build
50
+ fi
51
+
52
+ say "Packing $VERSION"
53
+ rm -f failproofai-*.tgz
54
+ npm pack --ignore-scripts >/dev/null 2>&1
55
+ TARBALL="$REPO/failproofai-$VERSION.tgz"
56
+ [ -f "$TARBALL" ] || { echo "pack failed"; exit 1; }
57
+ ls -la "$TARBALL" | awk '{printf " %s bytes %s\n", $5, $9}'
58
+
59
+ # Belt and braces: assert what is actually IN the tarball, not what we meant
60
+ # to put there.
61
+ PACKED=$(tar -xzOf "$TARBALL" package/dist/cli.mjs 2>/dev/null \
62
+ | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+-beta\.[0-9]\+' | head -1)
63
+ if [ "$PACKED" != "$VERSION" ]; then
64
+ echo " REFUSING: tarball reports '$PACKED' but package.json says '$VERSION'"
65
+ exit 1
66
+ fi
67
+ echo " bundled version verified: $PACKED"
68
+ fi
69
+
70
+ # ── 2. A container with a real init ──────────────────────────────────────────
71
+ say "Starting $IMAGE (real systemd)"
72
+ docker rm -f "$NAME" >/dev/null 2>&1
73
+ # --cgroupns=host + the two tmpfs mounts are REQUIRED on a cgroup v2 host
74
+ # (anything modern). Without them systemd cannot set up its own hierarchy and
75
+ # the container exits 255 immediately, with an empty `docker logs` — which is
76
+ # a genuinely awful thing to debug, hence the comment.
77
+ docker run -d --name "$NAME" --privileged --cgroupns=host \
78
+ --network=host \
79
+ -v /sys/fs/cgroup:/sys/fs/cgroup:rw --tmpfs /run --tmpfs /run/lock \
80
+ ${TARBALL:+-v "$TARBALL":/pkg.tgz:ro} \
81
+ -v "$REPO/target/release/failproofaid":/opt/failproofaid:ro \
82
+ "$IMAGE" >/dev/null
83
+ # Wait for systemd to finish booting, or `systemctl` answers nothing useful.
84
+ until docker exec "$NAME" systemctl is-system-running 2>/dev/null | grep -qE 'running|degraded'; do
85
+ if ! docker ps --filter "name=$NAME" --format '{{.Names}}' | grep -q "$NAME"; then
86
+ echo " container died during boot — cgroup v2 flags wrong for this host?"
87
+ docker logs "$NAME" 2>&1 | tail -5
88
+ exit 1
89
+ fi
90
+ sleep 1
91
+ done
92
+ echo " systemd: $(docker exec "$NAME" systemctl is-system-running 2>/dev/null)"
93
+
94
+ # ── 3. Node, and a NON-ROOT user to run as ───────────────────────────────────
95
+ say "Installing Node + creating an unprivileged user"
96
+ docker exec "$NAME" bash -lc '
97
+ apt-get update -qq >/dev/null 2>&1
98
+ apt-get install -y -qq curl ca-certificates sudo >/dev/null 2>&1
99
+ curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/n.sh 2>/dev/null && bash /tmp/n.sh >/dev/null 2>&1
100
+ apt-get install -y -qq nodejs >/dev/null 2>&1
101
+ node --version
102
+ ' 2>&1 | sed 's/^/ node /'
103
+
104
+ docker exec "$NAME" bash -lc "
105
+ id $USER_IN_BOX >/dev/null 2>&1 || useradd -m -s /bin/bash $USER_IN_BOX
106
+ # Passwordless sudo: the daemon install needs root, and we are testing the
107
+ # INSTALL, not whether a human can type a password.
108
+ echo '$USER_IN_BOX ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/$USER_IN_BOX
109
+ chmod 440 /etc/sudoers.d/$USER_IN_BOX
110
+ "
111
+
112
+ # ── 4. Install globally AS ROOT — the shape that exposes ownership bugs ──────
113
+ say "npm install -g (as root)"
114
+ if [ "$PUBLISHED" -eq 1 ]; then
115
+ docker exec "$NAME" bash -lc "npm install -g failproofai@$VERSION 2>&1 | tail -4"
116
+ else
117
+ docker exec "$NAME" bash -lc "npm install -g /pkg.tgz 2>&1 | tail -4"
118
+ fi
119
+
120
+ say "Who owns the global install?"
121
+ docker exec "$NAME" bash -lc '
122
+ P=$(npm root -g)/failproofai
123
+ stat -c " %U:%G %a %n" "$P" 2>/dev/null
124
+ stat -c " %U:%G %a %n" "$P/dist" 2>/dev/null
125
+ '
126
+
127
+ # ── 5. Hand over, running as the unprivileged user ───────────────────────────
128
+ say "Ready"
129
+ cat <<EOF
130
+
131
+ The box is up as container "$NAME". Everything below runs as the
132
+ UNPRIVILEGED user, against a root-owned global install — the real shape.
133
+
134
+ Shell in:
135
+ docker exec -it -u $USER_IN_BOX $NAME bash -l
136
+
137
+ Then, inside:
138
+ failproofai --version
139
+ failproofai config # real systemd service install
140
+ systemctl status failproofaid@$USER_IN_BOX
141
+
142
+ The daemon binary is mounted at /opt/failproofaid. There is no GitHub
143
+ release for $VERSION, so point the CLI at it rather than letting it try
144
+ to download one:
145
+ export FAILPROOFAI_DAEMON_BINARY=/opt/failproofaid
146
+
147
+ Fire a hook exactly as an agent CLI would:
148
+ printf '{"session_id":"s","cwd":"'"\$PWD"'","hook_event_name":"PreToolUse",
149
+ "tool_name":"Bash","tool_input":{"command":"sudo rm -rf /"}}' \\
150
+ | failproofai --hook PreToolUse --cli claude
151
+
152
+ Tear down:
153
+ docker rm -f $NAME
154
+
155
+ EOF
@@ -164,7 +164,6 @@ const ENV_FILE_PATH_RE = /(?:^|[\\/])\.env(?:\.|$)/;
164
164
  const ENV_CMD_RE = /\.env(?:\b|\s|$|\.)/;
165
165
 
166
166
  // blockSudo
167
- const SUDO_RE = /(?:^|;|&&|\|\|)\s*sudo\s/;
168
167
  const PS_ELEVATION_RE = /Start-Process\s+.*-Verb\s+RunAs/i;
169
168
  const RUNAS_RE = /(?:^|;|&&|\|\|)\s*runas\s/i;
170
169
 
@@ -749,10 +748,163 @@ function blockEnvFiles(ctx: PolicyContext): PolicyResult {
749
748
  return allow();
750
749
  }
751
750
 
751
+ /**
752
+ * True when a segment runs an elevation binary IN COMMAND POSITION.
753
+ *
754
+ * Anchored structurally rather than by token, for the reason `namesSelfPause`
755
+ * is: `SUDO_RE` required the literal token `sudo` at a command boundary, so
756
+ * **`/usr/bin/sudo rm -rf /` was ALLOWED** — a direct invocation, no
757
+ * obfuscation, one absolute path away from root on a `defaultEnabled` guard.
758
+ * Every other form the shell resolves before settling on the binary — prefix
759
+ * assignments, redirections, runners and their flags — is walked off first, and
760
+ * the comparison is on the BASENAME so a path cannot hide the name.
761
+ *
762
+ * `doas` is included because it is the same capability under a different name;
763
+ * a machine with `doas` installed and only `sudo` blocked is not blocked.
764
+ *
765
+ * This raises the bar; it does not close the class. `bash -c "sudo …"`, a
766
+ * variable (`S=sudo; $S …`) and base64-through-a-pipe all still reach root,
767
+ * because static inspection of one command string cannot follow them. Closing
768
+ * that properly needs enforcement below the shell, not a better regex.
769
+ */
770
+ /**
771
+ * Split a command into per-segment TOKEN LISTS the way a shell reads it.
772
+ *
773
+ * Distinct from `shellSegments` below, which returns raw segment STRINGS and is
774
+ * what the deletion policies want. Named apart deliberately: defining a second
775
+ * `shellSegments` here shadowed that one, silently broke every `rm -rf` check,
776
+ * and disabled this matcher too — caught only because `tsc` reports duplicate
777
+ * implementations.
778
+ *
779
+ * Segmentation happens on the RAW string and skips separators inside quotes,
780
+ * which is the whole point: stripping quoting FIRST turns `\|` — a literal pipe
781
+ * in a grep alternation — into a real separator, so `grep "a\|sudo b"` parses
782
+ * as a command called `sudo` and gets denied. That is a common enough pattern
783
+ * that over-blocking it is worse than the evasion it would catch.
784
+ *
785
+ * Each token is unquoted individually afterwards, which still defeats `\sudo`
786
+ * and `"sudo"` without ever letting quote removal change where the boundaries
787
+ * are. Quoted tokens are returned separately so a caller can decide whether
788
+ * their CONTENTS deserve a second look — see `EVAL_FLAG_RE`.
789
+ */
790
+ function quoteAwareSegments(command: string): Array<string[]> {
791
+ const segments: Array<string[]> = [];
792
+ let tokens: string[] = [];
793
+ let token = "";
794
+ let quote: '"' | "'" | null = null;
795
+ const endToken = () => {
796
+ if (token) tokens.push(token);
797
+ token = "";
798
+ };
799
+ const endSegment = () => {
800
+ endToken();
801
+ if (tokens.length) segments.push(tokens);
802
+ tokens = [];
803
+ };
804
+ for (let i = 0; i < command.length; i++) {
805
+ const c = command[i];
806
+ if (quote) {
807
+ // Inside single quotes a backslash is literal; inside double quotes it escapes.
808
+ if (c === "\\" && quote === '"' && i + 1 < command.length) {
809
+ token += command[++i];
810
+ continue;
811
+ }
812
+ if (c === quote) {
813
+ quote = null;
814
+ continue;
815
+ }
816
+ token += c;
817
+ continue;
818
+ }
819
+ if (c === "\\" && i + 1 < command.length) {
820
+ // An escaped character is literal — `\sudo` is still sudo, and `\|` is a
821
+ // pipe CHARACTER rather than a separator.
822
+ token += command[++i];
823
+ continue;
824
+ }
825
+ if (c === '"' || c === "'") {
826
+ quote = c;
827
+ continue;
828
+ }
829
+ if (/\s/.test(c)) {
830
+ endToken();
831
+ continue;
832
+ }
833
+ if (/[;&|\n\r(){}`]/.test(c)) {
834
+ endSegment();
835
+ continue;
836
+ }
837
+ token += c;
838
+ }
839
+ endSegment();
840
+ return segments;
841
+ }
842
+
843
+ /** `-c` and friends: the flags whose ARGUMENT is a command, not data. */
844
+ const EVAL_FLAG_RE = /^-{1,2}c$/;
845
+
846
+ /**
847
+ * True when a command runs an elevation binary IN COMMAND POSITION.
848
+ *
849
+ * Anchored structurally rather than by token, for the reason `namesSelfPause`
850
+ * is: `SUDO_RE` required the literal token `sudo` at a command boundary, so
851
+ * **`/usr/bin/sudo rm -rf /` was ALLOWED** — a direct invocation, no
852
+ * obfuscation, one absolute path away from root on a `defaultEnabled` guard.
853
+ * Every other form the shell resolves before settling on the binary — prefix
854
+ * assignments, redirections, runners and their flags — is walked off first, and
855
+ * the comparison is on the BASENAME so a path cannot hide the name.
856
+ *
857
+ * `doas` is included because it is the same capability under a different name;
858
+ * a machine with `doas` installed and only `sudo` blocked is not blocked.
859
+ *
860
+ * A quoted argument is re-examined ONLY when the command that owns it is a
861
+ * shell runner invoked with an eval flag (`bash -c "sudo …"`). That distinction
862
+ * is load-bearing: `bash -c "sudo x"` and `grep "a|sudo x"` are identical from
863
+ * the outside, and the only thing separating an execution from a search string
864
+ * is whether the receiving binary evaluates its argument.
865
+ *
866
+ * This raises the bar; it does not close the class. A variable (`S=sudo; $S …`),
867
+ * base64 through a pipe, and a wrapper script on disk all still reach root,
868
+ * because static inspection of one command string cannot follow them. Closing
869
+ * that properly needs enforcement below the shell, not a better matcher.
870
+ */
871
+ function namesElevation(command: string, depth = 0): boolean {
872
+ for (const tokens of quoteAwareSegments(command)) {
873
+ let i = 0;
874
+ while (i < tokens.length) {
875
+ const token = tokens[i];
876
+ const base = token.slice(token.lastIndexOf("/") + 1);
877
+ // `sudo`/`doas` are themselves in COMMAND_PREFIX_TOKENS (they legitimately
878
+ // stand in front of another binary), so they must be tested BEFORE the
879
+ // skip list or the walk would step straight over the thing being looked for.
880
+ if (base === "sudo" || base === "doas") return true;
881
+ const isSkippable =
882
+ ENV_ASSIGNMENT_RE.test(token) ||
883
+ token.startsWith("-") ||
884
+ token.startsWith(">") ||
885
+ token.startsWith("<") ||
886
+ RUNNER_OPERAND_RE.test(token) ||
887
+ COMMAND_PREFIX_TOKENS.has(base);
888
+ if (!isSkippable) break;
889
+ // `sh -c "…"` / `bash -c "…"`: the next token is a COMMAND. Bounded
890
+ // recursion so a nested `bash -c "bash -c …"` cannot spin.
891
+ if (EVAL_FLAG_RE.test(token) && depth < 3 && i + 1 < tokens.length) {
892
+ if (namesElevation(tokens[i + 1], depth + 1)) return true;
893
+ }
894
+ i++;
895
+ }
896
+ }
897
+ return false;
898
+ }
899
+
752
900
  function blockSudo(ctx: PolicyContext): PolicyResult {
753
901
  if (ctx.toolName !== "Bash") return allow();
754
902
  const cmd = getCommand(ctx).trimStart();
755
- if (SUDO_RE.test(cmd) || cmd.startsWith("sudo ")) {
903
+ // ONE pass. `shellSegments` unquotes each token individually, so `\sudo` and
904
+ // `"sudo"` are still caught — without a whole-string strip, which is what
905
+ // turned an escaped pipe in `grep "a\|sudo b"` into a segment separator and
906
+ // denied an ordinary search.
907
+ if (namesElevation(cmd)) {
756
908
  // Check allowPatterns — match against parsed tokens, not raw string
757
909
  const allowPatterns = ((ctx.params?.allowPatterns ?? []) as string[]);
758
910
  if (allowPatterns.some((p) => matchesAllowedPattern(cmd, p))) return allow();
@@ -72,6 +72,7 @@ import {
72
72
  daemonVersionSkew,
73
73
  primeElevation,
74
74
  setDaemonConfigured,
75
+ probeDaemon,
75
76
  probeDaemonEndToEnd,
76
77
  uninstallDaemonService,
77
78
  } from "./daemon-service";
@@ -1189,16 +1190,25 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
1189
1190
  // Checked HERE rather than inside `installDaemonService`, which can only
1190
1191
  // honestly report on the service: keeping them apart is also what lets the
1191
1192
  // install mechanics be tested against a stub binary.
1192
- if (!(await probeDaemonEndToEnd())) {
1193
- hookLogWarn("failproofaid was installed and running but did not answer a policy evaluation");
1193
+ const probe = await probeDaemon();
1194
+ if (!probe.ok) {
1195
+ hookLogWarn(
1196
+ `failproofaid was installed and running but did not answer a policy evaluation (${probe.reason})`,
1197
+ );
1198
+ // Two different faults, two different things to go and look at. Saying
1199
+ // "your worker will not start" at a machine whose socket simply never
1200
+ // came up sends someone to inspect a process that is fine.
1194
1201
  stdout.write(
1195
- "\nfailproofaid started but cannot evaluate policies — its worker process\n" +
1196
- "could not be run. Setup stopped before changing anything, because a machine\n" +
1197
- "configured to require a daemon that cannot answer denies every tool call.\n\n" +
1202
+ (probe.reason === "worker"
1203
+ ? "\nfailproofaid started but cannot evaluate policies — it is listening, and\n" +
1204
+ "its worker process could not be run.\n"
1205
+ : "\nfailproofaid started but could not be reached on its socket.\n") +
1206
+ "Setup stopped before changing anything, because a machine configured to\n" +
1207
+ "require a daemon that cannot answer denies every tool call.\n\n" +
1198
1208
  ` Check it with: ${daemonStatusCommand() ?? "systemctl status failproofaid"}\n` +
1199
1209
  " Then re-run: failproofai config\n\n",
1200
1210
  );
1201
- void emit("configure_aborted", { reason: "daemon_not_answering" });
1211
+ void emit("configure_aborted", { reason: `daemon_not_answering_${probe.reason}` });
1202
1212
  outro("Nothing was changed.", { ok: false }, stdout);
1203
1213
  return { applied: false, abort: "daemon_failed" };
1204
1214
  }
@@ -107,6 +107,41 @@ export function daemonSocketPresent(): boolean {
107
107
  }
108
108
  }
109
109
 
110
+ /**
111
+ * Whether the daemon socket accepts a connection *right now*.
112
+ *
113
+ * Narrower than `daemonSocketPresent()` (a stale socket file outlives the
114
+ * process that made it) and narrower than `attemptDaemonHook`, which reports
115
+ * `"unreachable"` for BOTH a refused connection and a request that was accepted
116
+ * and then never answered. Those two have different causes and different
117
+ * remedies — a socket that is not up yet versus a worker that cannot run — and
118
+ * telling them apart is the whole point of this function.
119
+ *
120
+ * Connect budget only; nothing is sent. Used by the health probe, never on the
121
+ * hook path.
122
+ */
123
+ export async function daemonAcceptsConnections(
124
+ timeoutMs = DAEMON_CONNECT_TIMEOUT_MS,
125
+ ): Promise<boolean> {
126
+ if (process.platform === "win32") return false;
127
+ return new Promise<boolean>((resolve) => {
128
+ let settled = false;
129
+ const done = (v: boolean) => {
130
+ if (settled) return;
131
+ settled = true;
132
+ clearTimeout(timer);
133
+ socket.removeAllListeners();
134
+ socket.destroy();
135
+ resolve(v);
136
+ };
137
+ const socket = createConnection({ path: socketPath() });
138
+ const timer = setTimeout(() => done(false), timeoutMs);
139
+ timer.unref?.();
140
+ socket.on("connect", () => done(true));
141
+ socket.on("error", () => done(false));
142
+ });
143
+ }
144
+
110
145
  function socketPath(): string {
111
146
  if (process.env.FAILPROOFAI_DAEMON_SOCKET) return process.env.FAILPROOFAI_DAEMON_SOCKET;
112
147
  return daemonSocketPath();