failproofai 1.0.8-beta.0 → 1.0.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 (116) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +4 -4
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  30. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +14 -14
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  38. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  41. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  54. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  57. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +3 -3
  58. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13t1zkw._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/{_0o4xkpl._.js → _0-vcssj._.js} +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/_13kfn90._.js +23 -0
  76. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_0aiy-os._.js +1 -1
  84. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  85. package/.next/standalone/.next/server/pages/404.html +1 -1
  86. package/.next/standalone/.next/server/pages/500.html +1 -1
  87. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  89. package/.next/standalone/.next/static/chunks/{3o3f1ibfci0p7.js → 06dnzbolj00mc.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{0ahfwmbkpgfw4.js → 0m-9d6yn9hx4j.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/{3bgot5v6f6s3c.js → 0wbjy0zo-m7is.js} +1 -1
  92. package/.next/standalone/.next/static/chunks/16f3fa-lx38hk.js +1 -0
  93. package/.next/standalone/.next/static/chunks/{1v_tp3hm8wyhe.js → 21uv-uusw329x.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/2g2tki08kdhie.js +1 -0
  95. package/.next/standalone/.next/static/chunks/{2vo7qbdbvnc0o.js → 2qdpj67x6ifk_.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/3-nbtkhg9y-1j.js +1 -0
  97. package/.next/standalone/.next/static/chunks/{36uhh9el_oz8e.js → 355km0ihuqo1p.js} +1 -1
  98. package/.next/standalone/.next/static/chunks/{2z7i-yg59w4pn.js → 3c3qbmosdjl6w.js} +1 -1
  99. package/.next/standalone/package.json +5 -5
  100. package/.next/standalone/sdk/typescript/CHANGELOG.md +18 -1
  101. package/.next/standalone/sdk/typescript/integration/fixtures/nextjs/package-lock.json +0 -274
  102. package/.next/standalone/server.js +1 -1
  103. package/dist/cli.mjs +114 -38
  104. package/dist/worker.mjs +109 -33
  105. package/package.json +5 -5
  106. package/src/hooks/semantic/decide.ts +134 -29
  107. package/src/hooks/semantic/facts.ts +91 -2
  108. package/src/hooks/semantic/types.ts +6 -0
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14o3ek1._.js +0 -3
  110. package/.next/standalone/.next/server/chunks/ssr/_1-7sqrb._.js +0 -23
  111. package/.next/standalone/.next/static/chunks/078gnymqoh3r4.js +0 -1
  112. package/.next/standalone/.next/static/chunks/1bu2-nv59ed6i.js +0 -1
  113. package/.next/standalone/.next/static/chunks/2a405_e1o26ol.js +0 -1
  114. /package/.next/standalone/.next/static/{O_b5R1axf5cb4NxIXq2kS → 5yKM8NoTOpUI0MoQl8eo5}/_buildManifest.js +0 -0
  115. /package/.next/standalone/.next/static/{O_b5R1axf5cb4NxIXq2kS → 5yKM8NoTOpUI0MoQl8eo5}/_clientMiddlewareManifest.js +0 -0
  116. /package/.next/standalone/.next/static/{O_b5R1axf5cb4NxIXq2kS → 5yKM8NoTOpUI0MoQl8eo5}/_ssgManifest.js +0 -0
package/dist/worker.mjs CHANGED
@@ -1162,7 +1162,7 @@ function instruct(reason) {
1162
1162
  }
1163
1163
 
1164
1164
  // package.json
1165
- var version = "1.0.8-beta.0";
1165
+ var version = "1.0.8";
1166
1166
  var init_package = () => {};
1167
1167
 
1168
1168
  // src/hooks/policy-types.ts
@@ -11905,6 +11905,19 @@ function classifyTool(toolName) {
11905
11905
  return { toolClass: "other", toolIsKnown: true };
11906
11906
  return { toolClass: "other", toolIsKnown: false };
11907
11907
  }
11908
+ function runsNestedShell(tokens) {
11909
+ let shellSeen = false;
11910
+ for (const tok of tokens) {
11911
+ if (shellSeen && /^-[a-z]*c[a-z]*$/i.test(tok))
11912
+ return true;
11913
+ const base = tok.slice(tok.lastIndexOf("/") + 1);
11914
+ if (base === "eval")
11915
+ return true;
11916
+ if (NESTED_SHELLS.has(base))
11917
+ shellSeen = true;
11918
+ }
11919
+ return false;
11920
+ }
11908
11921
  function scanCommand(command) {
11909
11922
  const text = command.length > MAX_SCAN_CHARS ? command.slice(0, MAX_SCAN_CHARS) : command;
11910
11923
  const segments = [];
@@ -11915,22 +11928,36 @@ function scanCommand(command) {
11915
11928
  let out = "";
11916
11929
  let commentsRemoved = false;
11917
11930
  const comments = [];
11931
+ let complete = command.length <= MAX_SCAN_CHARS;
11932
+ let braceDepth = 0;
11933
+ let braceSep = false;
11934
+ let bracketOpen = false;
11918
11935
  const endWord = () => {
11919
11936
  if (inWord)
11920
11937
  tokens.push(word);
11921
11938
  word = "";
11922
11939
  inWord = false;
11940
+ braceDepth = 0;
11941
+ braceSep = false;
11942
+ bracketOpen = false;
11923
11943
  };
11924
11944
  const endSegment = () => {
11925
11945
  endWord();
11926
- if (tokens.length > 0)
11946
+ if (tokens.length > 0) {
11947
+ if (complete && runsNestedShell(tokens))
11948
+ complete = false;
11927
11949
  segments.push(tokens);
11950
+ }
11928
11951
  tokens = [];
11929
11952
  };
11930
11953
  for (let i = 0;i < text.length; i++) {
11931
11954
  const c = text[i];
11932
11955
  if (quote) {
11933
11956
  out += c;
11957
+ if (quote === '"' && (c === "`" || c === "$" && EXPANSION_START.test(text[i + 1] ?? "") || c === "\\" && text[i + 1] === `
11958
+ `)) {
11959
+ complete = false;
11960
+ }
11934
11961
  if (c === quote) {
11935
11962
  quote = null;
11936
11963
  } else if (c === "\\" && quote === '"' && i + 1 < text.length) {
@@ -11941,6 +11968,10 @@ function scanCommand(command) {
11941
11968
  }
11942
11969
  continue;
11943
11970
  }
11971
+ if (c === "`" || c === "$" && (text[i + 1] === "'" || text[i + 1] === '"' || EXPANSION_START.test(text[i + 1] ?? "")) || (c === "<" || c === ">") && text[i + 1] === "(" || c === "<" && text[i + 1] === "<" || c === "\\" && text[i + 1] === `
11972
+ `) {
11973
+ complete = false;
11974
+ }
11944
11975
  if (c === "'" || c === '"') {
11945
11976
  quote = c;
11946
11977
  inWord = true;
@@ -11981,12 +12012,29 @@ function scanCommand(command) {
11981
12012
  out += c;
11982
12013
  continue;
11983
12014
  }
12015
+ if (c === "{") {
12016
+ braceDepth++;
12017
+ } else if (braceDepth > 0 && (c === "," || c === "." && text[i + 1] === ".")) {
12018
+ braceSep = true;
12019
+ } else if (c === "}" && braceDepth > 0) {
12020
+ braceDepth--;
12021
+ if (braceSep)
12022
+ complete = false;
12023
+ } else if (c === "*" || c === "?") {
12024
+ complete = false;
12025
+ } else if (c === "[") {
12026
+ bracketOpen = true;
12027
+ } else if (c === "]" && bracketOpen) {
12028
+ complete = false;
12029
+ }
11984
12030
  word += c;
11985
12031
  inWord = true;
11986
12032
  out += c;
11987
12033
  }
11988
12034
  endSegment();
11989
- return { segments, withoutComments: out.trimEnd(), commentsRemoved, comments };
12035
+ if (quote)
12036
+ complete = false;
12037
+ return { segments, withoutComments: out.trimEnd(), commentsRemoved, comments, complete };
11990
12038
  }
11991
12039
  function expandHome(token, home) {
11992
12040
  if (token === "~")
@@ -12119,7 +12167,7 @@ function computeFacts(toolName, toolInput, cwd, permissionMode, scanned, pinnedP
12119
12167
  permissionMode
12120
12168
  };
12121
12169
  }
12122
- var SHELL_TOOLS, WRITE_TOOLS, READ_TOOLS, NETWORK_TOOLS, INERT_TOOLS, MAX_SCAN_CHARS = 8192, PATH_LIKE, GLOB_CHARS, MAX_PATHS = 12;
12170
+ var SHELL_TOOLS, WRITE_TOOLS, READ_TOOLS, NETWORK_TOOLS, INERT_TOOLS, MAX_SCAN_CHARS = 8192, NESTED_SHELLS, EXPANSION_START, PATH_LIKE, GLOB_CHARS, MAX_PATHS = 12;
12123
12171
  var init_facts = __esm(() => {
12124
12172
  SHELL_TOOLS = new Set(["Bash", "BashOutput"]);
12125
12173
  WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
@@ -12138,6 +12186,8 @@ var init_facts = __esm(() => {
12138
12186
  "SlashCommand",
12139
12187
  "Skill"
12140
12188
  ]);
12189
+ NESTED_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "fish"]);
12190
+ EXPANSION_START = /[A-Za-z_0-9@*#?$!\-({]/;
12141
12191
  PATH_LIKE = /^(?:\/|~|\.\.?(?:\/|$)|[^:]*\/)/;
12142
12192
  GLOB_CHARS = /[*?[]/;
12143
12193
  });
@@ -12146,50 +12196,70 @@ var init_facts = __esm(() => {
12146
12196
  function tokensOf(text) {
12147
12197
  return text.toLowerCase().split(/[^a-z0-9._@-]+/).filter((t) => t.length >= 3 && !GENERIC_TOKENS.has(t) && !/^\d+$/.test(t));
12148
12198
  }
12149
- function targetTokens(toolInput) {
12150
- const out = new Set;
12199
+ function scanTargets(toolInput) {
12200
+ const groups = [];
12201
+ const addGroup = (tok) => {
12202
+ const words = tokensOf(tok);
12203
+ if (words.length > 0)
12204
+ groups.push(new Set(words));
12205
+ };
12151
12206
  const command = typeof toolInput.command === "string" ? toolInput.command : null;
12152
12207
  if (command) {
12153
- for (const seg of scanCommand(command).segments) {
12208
+ const scanned = scanCommand(command);
12209
+ for (const seg of scanned.segments) {
12154
12210
  for (const tok of seg.slice(1)) {
12155
- if (tok.startsWith("-"))
12156
- continue;
12157
- for (const t of tokensOf(tok))
12158
- out.add(t);
12211
+ if (!tok.startsWith("-"))
12212
+ addGroup(tok);
12159
12213
  }
12160
12214
  }
12161
- if (out.size === 0) {
12215
+ if (groups.length === 0) {
12162
12216
  for (const piece of command.split(/[;&|\n]+/)) {
12163
12217
  for (const tok of piece.trim().split(/\s+/).slice(1)) {
12164
12218
  if (!tok.startsWith("-"))
12165
- for (const t of tokensOf(tok))
12166
- out.add(t);
12219
+ addGroup(tok);
12167
12220
  }
12168
12221
  }
12169
12222
  }
12170
- return out;
12223
+ return { targets: new Set(groups.flatMap((g) => [...g])), groups, complete: scanned.complete };
12171
12224
  }
12225
+ const all = new Set;
12172
12226
  for (const [key, value] of Object.entries(toolInput)) {
12173
12227
  if (typeof value !== "string" || value.length > 300)
12174
12228
  continue;
12175
12229
  if (/content|old_string|new_string|body|text|prompt/i.test(key))
12176
12230
  continue;
12177
12231
  for (const t of tokensOf(value))
12178
- out.add(t);
12232
+ all.add(t);
12179
12233
  }
12180
- return out;
12234
+ return { targets: all, groups: all.size > 0 ? [all] : [], complete: true };
12181
12235
  }
12182
- function targetNamedByUser(targets, userSaid) {
12183
- if (userSaid.length === 0)
12236
+ function everyTargetNamed(scan, userSaid) {
12237
+ if (userSaid.length === 0 || scan.groups.length === 0)
12184
12238
  return false;
12185
- if (targets.size === 0)
12239
+ const said = userSaid.join(`
12240
+ `).toLowerCase();
12241
+ return scan.groups.every((g) => {
12242
+ for (const t of g)
12243
+ if (said.includes(t))
12244
+ return true;
12245
+ return false;
12246
+ });
12247
+ }
12248
+ function partlyNamed(scan, userSaid) {
12249
+ if (userSaid.length === 0 || scan.groups.length < 2)
12186
12250
  return false;
12187
12251
  const said = userSaid.join(`
12188
12252
  `).toLowerCase();
12189
- for (const t of targets)
12190
- if (said.includes(t))
12191
- return true;
12192
- return false;
12253
+ let named = 0;
12254
+ for (const g of scan.groups) {
12255
+ for (const t of g) {
12256
+ if (said.includes(t)) {
12257
+ named++;
12258
+ break;
12259
+ }
12260
+ }
12261
+ }
12262
+ return named > 0 && named < scan.groups.length;
12193
12263
  }
12194
12264
  function formatOutcome(p, o) {
12195
12265
  return `${p.title} (semantic/${p.name}, p=${o.evidence.toFixed(2)}). ${p.guidance}`;
@@ -12199,7 +12269,7 @@ function decide(selected, answers, toolInput, userSaid, thresholds = DEFAULT_THR
12199
12269
  const injected = injection !== null && injection >= thresholds.injection;
12200
12270
  const scope = typeof answers.scope === "number" ? answers.scope : null;
12201
12271
  const withinScope = scope !== null && scope >= thresholds.scope;
12202
- let targets = null;
12272
+ let scan = null;
12203
12273
  const outcomes = selected.map((p) => {
12204
12274
  const evidence = Math.min(...p.probes.map((probe) => answers[`${p.name}.${probe.id}`] ?? 0));
12205
12275
  const exempt = p.exempt ? answers[`${p.name}.exempt`] ?? 0 : null;
@@ -12222,9 +12292,11 @@ function decide(selected, answers, toolInput, userSaid, thresholds = DEFAULT_THR
12222
12292
  return { ...base, escalatedByInjection: true, verdict: "deny" };
12223
12293
  const fired = p.mode === "deny" && evidence >= thresholds.deny ? "deny" : "instruct";
12224
12294
  if (p.userCanOverride && userAsked !== null && userAsked >= thresholds.userAsked && withinScope) {
12225
- targets ??= targetTokens(toolInput);
12226
- const named = targets.size > 0 && targetNamedByUser(targets, userSaid);
12227
- if (targets.size === 0 || named || userSaidCut)
12295
+ scan ??= scanTargets(toolInput);
12296
+ if (!scan.complete)
12297
+ return { ...base, verdict: fired, targetScanIncomplete: true };
12298
+ const named = scan.groups.length > 0 && everyTargetNamed(scan, userSaid);
12299
+ if (scan.groups.length === 0 || named || userSaidCut)
12228
12300
  return { ...base, targetNamedByUser: named, verdict: "overridden" };
12229
12301
  }
12230
12302
  return { ...base, verdict: fired };
@@ -12274,12 +12346,14 @@ function decideV1(selected, answers, toolInput, userSaid, agentLastMessage, opts
12274
12346
  const task = num("task_step");
12275
12347
  const op = num("op_requested");
12276
12348
  const beyond = num("beyond_task");
12277
- let targets = null;
12349
+ let scan = null;
12350
+ const targetScan = () => scan ??= scanTargets(toolInput);
12351
+ const evidenceSaid = agentLastMessage ? [...userSaid, agentLastMessage] : userSaid;
12278
12352
  const targetOk = () => {
12279
- targets ??= targetTokens(toolInput);
12280
- if (targets.size === 0)
12353
+ const s = targetScan();
12354
+ if (s.groups.length === 0)
12281
12355
  return { ok: true, named: false };
12282
- const named = targetNamedByUser(targets, agentLastMessage ? [...userSaid, agentLastMessage] : userSaid);
12356
+ const named = everyTargetNamed(s, evidenceSaid);
12283
12357
  return { ok: named || userSaidCut, named };
12284
12358
  };
12285
12359
  const outcomes = selected.map((p) => {
@@ -12304,12 +12378,14 @@ function decideV1(selected, answers, toolInput, userSaid, agentLastMessage, opts
12304
12378
  const fired = p.mode === "deny" && evidence >= t.deny ? "deny" : "instruct";
12305
12379
  if (!p.userCanOverride)
12306
12380
  return { ...base, verdict: fired };
12381
+ if (!targetScan().complete)
12382
+ return { ...base, verdict: fired, targetScanIncomplete: true };
12307
12383
  if (op !== null && op >= t.opRequested && beyond !== null && beyond < t.opBeyondMax) {
12308
12384
  const target = targetOk();
12309
12385
  if (target.ok)
12310
12386
  return { ...base, targetNamedByUser: target.named, verdict: "overridden", intent: "op-requested" };
12311
12387
  }
12312
- if (taskClears && task !== null && task >= t.taskStep && beyond !== null && beyond < t.taskBeyondMax) {
12388
+ if (taskClears && task !== null && task >= t.taskStep && beyond !== null && beyond < t.taskBeyondMax && !(typeof toolInput.command === "string" && !userSaidCut && partlyNamed(targetScan(), evidenceSaid))) {
12313
12389
  if (fired === "instruct")
12314
12390
  return { ...base, verdict: "overridden", intent: "task-step" };
12315
12391
  return { ...base, verdict: "instruct", intent: "downgraded-task-step" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "1.0.8-beta.0",
3
+ "version": "1.0.8",
4
4
  "description": "Observability and enforcement for AI agent harnesses. 40 built-in policies hooked into 12 of them — Claude Code, Codex, Cursor, Hermes, OpenClaw and more — blocking the tool call before it runs. Local dashboard included, no account needed.",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs",
@@ -122,10 +122,10 @@
122
122
  "browserslist": "4.28.8"
123
123
  },
124
124
  "optionalDependencies": {
125
- "@failproofai/failproofaid-linux-x64": "1.0.8-beta.0",
126
- "@failproofai/failproofaid-linux-arm64": "1.0.8-beta.0",
127
- "@failproofai/failproofaid-darwin-x64": "1.0.8-beta.0",
128
- "@failproofai/failproofaid-darwin-arm64": "1.0.8-beta.0"
125
+ "@failproofai/failproofaid-linux-x64": "1.0.8",
126
+ "@failproofai/failproofaid-linux-arm64": "1.0.8",
127
+ "@failproofai/failproofaid-darwin-x64": "1.0.8",
128
+ "@failproofai/failproofaid-darwin-arm64": "1.0.8"
129
129
  },
130
130
  "failproofaidBinaries": {
131
131
  "linux-x64": "35b641f60e1724513fbb760db382123dad2b5fcdd67dda6373bda80651bd36bb",
@@ -13,9 +13,13 @@
13
13
  * - A `deny` policy blocks only on strong evidence; moderate evidence warns.
14
14
  * - The user may clear a policy only if (a) they explicitly asked for this
15
15
  * action, (b) everything the call affects stays inside what they asked for
16
- * (the `scope` probe), (c) when the call names identifiable targets, one of
17
- * them appears in what they typed — checked here, in code, not by the model
18
- * — and (d) the request does not look like it is talking to the reviewer.
16
+ * (the `scope` probe), (c) when the call names identifiable targets, EVERY
17
+ * one of them appears in what they typed — checked here, in code, not by the
18
+ * model — and (d) the request does not look like it is talking to the
19
+ * reviewer. When the local shell scan may have missed part of the command
20
+ * (`$'…'`, a heredoc, `$(…)`, …), (c) cannot be checked and nothing is
21
+ * cleared: the scan can see an innocent first target and stop before the
22
+ * destructive one.
19
23
  * A call that names no target is never cleared by default: the scope answer
20
24
  * has to carry it.
21
25
  * - The injection probe withdraws any override, and turns a policy that has
@@ -68,44 +72,117 @@ function tokensOf(text: string): string[] {
68
72
  .filter((t) => t.length >= 3 && !GENERIC_TOKENS.has(t) && !/^\d+$/.test(t));
69
73
  }
70
74
 
75
+ /**
76
+ * What a tool call acts on, as the local target check sees it.
77
+ *
78
+ * `groups` holds one entry per identifiable target — a non-flag argument of a
79
+ * shell command (path components and all), or, for any other tool, the whole
80
+ * of its argument values taken together — and each entry is the set of words
81
+ * that name it. `targets` is every word of every group.
82
+ *
83
+ * `complete` is false when the shell scan may have missed a word bash would
84
+ * run (`ScannedCommand.complete`). The groups are then a lower bound, and no
85
+ * clear may rest on them: see {@link everyTargetNamed}'s callers.
86
+ */
87
+ export interface TargetScan {
88
+ targets: Set<string>;
89
+ groups: Set<string>[];
90
+ complete: boolean;
91
+ }
92
+
71
93
  /**
72
94
  * The words that identify WHAT a tool call acts on: its non-flag arguments
73
95
  * (path components included), file paths, URL hosts, MCP argument values.
74
96
  * The verb is left to Jev's `user_asked` question; this only checks the noun.
75
97
  */
76
- export function targetTokens(toolInput: Record<string, unknown>): Set<string> {
77
- const out = new Set<string>();
98
+ export function scanTargets(toolInput: Record<string, unknown>): TargetScan {
99
+ const groups: Set<string>[] = [];
100
+ const addGroup = (tok: string) => {
101
+ const words = tokensOf(tok);
102
+ if (words.length > 0) groups.push(new Set(words));
103
+ };
78
104
  const command = typeof toolInput.command === "string" ? toolInput.command : null;
79
105
  if (command) {
80
- for (const seg of scanCommand(command).segments) {
106
+ const scanned = scanCommand(command);
107
+ for (const seg of scanned.segments) {
81
108
  for (const tok of seg.slice(1)) {
82
- if (tok.startsWith("-")) continue;
83
- for (const t of tokensOf(tok)) out.add(t);
109
+ if (!tok.startsWith("-")) addGroup(tok);
84
110
  }
85
111
  }
86
112
  // Empty reads as "names no target", and lets consent rest on Jev's answers
87
113
  // alone. But the scanner is not bash — a `#` inside `$'…'`, `${x:- # }` or
88
114
  // backticks ends its view early, and it reads only MAX_SCAN_CHARS — so an
89
115
  // empty scan may just have stopped before the target. Then judge the words
90
- // as written. Only ever stricter: an empty set already passed.
91
- if (out.size === 0) {
116
+ // as written. Only ever stricter: an empty set already passed. (Such a scan
117
+ // is also reported incomplete, which withholds the clear on its own; this
118
+ // keeps the recorded targets honest.)
119
+ if (groups.length === 0) {
92
120
  for (const piece of command.split(/[;&|\n]+/)) {
93
121
  for (const tok of piece.trim().split(/\s+/).slice(1)) {
94
- if (!tok.startsWith("-")) for (const t of tokensOf(tok)) out.add(t);
122
+ if (!tok.startsWith("-")) addGroup(tok);
95
123
  }
96
124
  }
97
125
  }
98
- return out;
126
+ return { targets: new Set(groups.flatMap((g) => [...g])), groups, complete: scanned.complete };
99
127
  }
128
+ const all = new Set<string>();
100
129
  for (const [key, value] of Object.entries(toolInput)) {
101
130
  if (typeof value !== "string" || value.length > 300) continue;
102
131
  if (/content|old_string|new_string|body|text|prompt/i.test(key)) continue;
103
- for (const t of tokensOf(value)) out.add(t);
132
+ for (const t of tokensOf(value)) all.add(t);
133
+ }
134
+ // A non-shell tool's fields qualify one another (`owner`, `repo`, `branch`)
135
+ // rather than naming separate targets, so they are ONE target: naming any of
136
+ // them names it.
137
+ return { targets: all, groups: all.size > 0 ? [all] : [], complete: true };
138
+ }
139
+
140
+ /** Every word of {@link scanTargets}, flattened. */
141
+ export function targetTokens(toolInput: Record<string, unknown>): Set<string> {
142
+ return scanTargets(toolInput).targets;
143
+ }
144
+
145
+ /**
146
+ * True when the user's own words name EVERY target the call acts on — each
147
+ * group through at least one of its words.
148
+ *
149
+ * Not "any one": `rm -rf build/ ~/important` after "clean the build" names
150
+ * `build` and not `important`, and a clear on the first would carry the
151
+ * second, which nobody asked for.
152
+ */
153
+ export function everyTargetNamed(scan: TargetScan, userSaid: ReadonlyArray<string>): boolean {
154
+ if (userSaid.length === 0 || scan.groups.length === 0) return false;
155
+ const said = userSaid.join("\n").toLowerCase();
156
+ return scan.groups.every((g) => {
157
+ for (const t of g) if (said.includes(t)) return true;
158
+ return false;
159
+ });
160
+ }
161
+
162
+ /**
163
+ * True when the user's words name SOME of the call's targets but not all:
164
+ * they drew a line and the call reaches past it. Naming none is not this — a
165
+ * goal ("fix the failing tests") names no path at all.
166
+ */
167
+ export function partlyNamed(scan: TargetScan, userSaid: ReadonlyArray<string>): boolean {
168
+ if (userSaid.length === 0 || scan.groups.length < 2) return false;
169
+ const said = userSaid.join("\n").toLowerCase();
170
+ let named = 0;
171
+ for (const g of scan.groups) {
172
+ for (const t of g) {
173
+ if (said.includes(t)) {
174
+ named++;
175
+ break;
176
+ }
177
+ }
104
178
  }
105
- return out;
179
+ return named > 0 && named < scan.groups.length;
106
180
  }
107
181
 
108
- /** True when the user's own words name at least one thing the call acts on. */
182
+ /**
183
+ * True when the user's own words name at least one of `targets`. The deciders
184
+ * do not use this any-one check — see {@link everyTargetNamed}.
185
+ */
109
186
  export function targetNamedByUser(targets: Set<string>, userSaid: ReadonlyArray<string>): boolean {
110
187
  if (userSaid.length === 0) return false;
111
188
  // Nothing identifiable to check is not a match. This used to return true,
@@ -136,7 +213,7 @@ export function decide(
136
213
  const injected = injection !== null && injection >= thresholds.injection;
137
214
  const scope = typeof answers.scope === "number" ? answers.scope : null;
138
215
  const withinScope = scope !== null && scope >= thresholds.scope;
139
- let targets: Set<string> | null = null;
216
+ let scan: TargetScan | null = null;
140
217
 
141
218
  const outcomes: PolicyOutcome[] = selected.map((p) => {
142
219
  const evidence = Math.min(...p.probes.map((probe) => answers[`${p.name}.${probe.id}`] ?? 0));
@@ -161,13 +238,18 @@ export function decide(
161
238
 
162
239
  const fired: PolicyOutcome["verdict"] = p.mode === "deny" && evidence >= thresholds.deny ? "deny" : "instruct";
163
240
  if (p.userCanOverride && userAsked !== null && userAsked >= thresholds.userAsked && withinScope) {
164
- targets ??= targetTokens(toolInput);
241
+ scan ??= scanTargets(toolInput);
242
+ // A shell scan that may have missed a word cannot say what the call
243
+ // touches, so it cannot say the user named it: no clear. Not rescued by
244
+ // `userSaidCut` — that gap is in what the human typed, this one is in
245
+ // the call.
246
+ if (!scan.complete) return { ...base, verdict: fired, targetScanIncomplete: true };
165
247
  // No identifiable target: the scope answer (already required) carries it.
166
- // Otherwise one of the targets must also appear in the user's own words —
248
+ // Otherwise EVERY target must also appear in the user's own words —
167
249
  // unless the words we hold were cut, when "absent" is not something this
168
250
  // check knows (see {@link DecideV1Options.userSaidCut}).
169
- const named = targets.size > 0 && targetNamedByUser(targets, userSaid);
170
- if (targets.size === 0 || named || userSaidCut) return { ...base, targetNamedByUser: named, verdict: "overridden" };
251
+ const named = scan.groups.length > 0 && everyTargetNamed(scan, userSaid);
252
+ if (scan.groups.length === 0 || named || userSaidCut) return { ...base, targetNamedByUser: named, verdict: "overridden" };
171
253
  }
172
254
  return { ...base, verdict: fired };
173
255
  });
@@ -293,12 +375,16 @@ export interface DecideV1Options {
293
375
  * - A policy fires exactly as in v0 (every probe holds, no exemption).
294
376
  * - Injection withdraws every clear and turns a fired policy into a block.
295
377
  * - The human asked for THIS operation on THIS target (`op_requested`), the
296
- * call reaches no further (`beyond_task`), and — when the call names a
297
- * target — that target appears in what the human typed or in the agent
298
- * proposal they replied to: the policy is cleared.
378
+ * call reaches no further (`beyond_task`), and — when the call names
379
+ * targets — every one of them appears in what the human typed or in the
380
+ * agent proposal they replied to: the policy is cleared.
381
+ * - A shell command the local scan could not read whole is cleared and
382
+ * softened by neither route: see `ScannedCommand.complete` in `facts.ts`.
299
383
  * - Otherwise, the call is a step toward the human's task (`task_step`) and
300
384
  * reaches no further: a warn-level outcome is cleared and a block is
301
- * softened to a warning. A goal never licenses a block on its own.
385
+ * softened to a warning. A goal never licenses a block on its own. On a
386
+ * shell command whose targets the human named only in part, this route
387
+ * does not apply either.
302
388
  * - Policies with `userCanOverride: false` are never cleared or softened.
303
389
  * - Nothing fired, but the call reaches beyond the task, is not a step toward
304
390
  * it, and some "does it do X" probe is at least half-raised: warn.
@@ -321,11 +407,13 @@ export function decideV1(
321
407
  const task = num("task_step");
322
408
  const op = num("op_requested");
323
409
  const beyond = num("beyond_task");
324
- let targets: Set<string> | null = null;
410
+ let scan: TargetScan | null = null;
411
+ const targetScan = (): TargetScan => (scan ??= scanTargets(toolInput));
412
+ const evidenceSaid = agentLastMessage ? [...userSaid, agentLastMessage] : userSaid;
325
413
  const targetOk = (): { ok: boolean; named: boolean } => {
326
- targets ??= targetTokens(toolInput);
327
- if (targets.size === 0) return { ok: true, named: false };
328
- const named = targetNamedByUser(targets, agentLastMessage ? [...userSaid, agentLastMessage] : userSaid);
414
+ const s = targetScan();
415
+ if (s.groups.length === 0) return { ok: true, named: false };
416
+ const named = everyTargetNamed(s, evidenceSaid);
329
417
  return { ok: named || userSaidCut, named };
330
418
  };
331
419
 
@@ -349,12 +437,29 @@ export function decideV1(
349
437
 
350
438
  const fired: PolicyOutcome["verdict"] = p.mode === "deny" && evidence >= t.deny ? "deny" : "instruct";
351
439
  if (!p.userCanOverride) return { ...base, verdict: fired };
440
+ // The shell scan may have missed a word bash would run (`$'…'`, a heredoc,
441
+ // `$(…)`, …): what the call touches is not known here, so neither intent
442
+ // route may clear or soften it. Jev's own deny or instruct stands.
443
+ if (!targetScan().complete) return { ...base, verdict: fired, targetScanIncomplete: true };
352
444
 
353
445
  if (op !== null && op >= t.opRequested && beyond !== null && beyond < t.opBeyondMax) {
354
446
  const target = targetOk();
355
447
  if (target.ok) return { ...base, targetNamedByUser: target.named, verdict: "overridden", intent: "op-requested" };
356
448
  }
357
- if (taskClears && task !== null && task >= t.taskStep && beyond !== null && beyond < t.taskBeyondMax) {
449
+ // The task-step route reaches `combine.ts` as a clear too — a warning it
450
+ // leaves behind clears a reviewable regex deny. It is consent by GOAL, so
451
+ // a shell command whose targets the human never named still rides on it
452
+ // ("fix the failing tests" → `rm -rf node_modules`). But once the human
453
+ // HAS named targets, they drew the line: a call reaching past it is not
454
+ // softened. After "clean the build", `rm -rf build/ ~/important` is not.
455
+ if (
456
+ taskClears &&
457
+ task !== null &&
458
+ task >= t.taskStep &&
459
+ beyond !== null &&
460
+ beyond < t.taskBeyondMax &&
461
+ !(typeof toolInput.command === "string" && !userSaidCut && partlyNamed(targetScan(), evidenceSaid))
462
+ ) {
358
463
  if (fired === "instruct") return { ...base, verdict: "overridden", intent: "task-step" };
359
464
  return { ...base, verdict: "instruct", intent: "downgraded-task-step" };
360
465
  }