failproofai 1.0.8-beta.0 → 1.0.9-beta.0

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 (163) 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 +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 +15 -15
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  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 +15 -15
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  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 +3 -3
  42. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  55. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +20 -20
  59. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +2 -2
  62. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__14o3ek1._.js → [root-of-the-server]__0tmz862._.js} +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/{_0o4xkpl._.js → _0n0lja6._.js} +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{_1-7sqrb._.js → _1p3iz2d._.js} +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +3 -3
  82. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_0aiy-os._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +1 -1
  90. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  91. package/.next/standalone/.next/server/pages/404.html +1 -1
  92. package/.next/standalone/.next/server/pages/500.html +1 -1
  93. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  94. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  95. package/.next/standalone/.next/static/chunks/{2z7i-yg59w4pn.js → 0247hc9ath5t-.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{0ahfwmbkpgfw4.js → 03001k9jy-kst.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2vo7qbdbvnc0o.js → 06k7gsfnb7jkh.js} +1 -1
  98. package/.next/standalone/.next/static/chunks/0qejem4-zt-hy.js +1 -0
  99. package/.next/standalone/.next/static/chunks/{1v_tp3hm8wyhe.js → 0snylg3y1i47a.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/1h0if8ky362as.js +1 -0
  101. package/.next/standalone/.next/static/chunks/{3bgot5v6f6s3c.js → 2qsrrrl2maqsb.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{0bhidk90e-07f.css → 3_e_wg5_oay87.css} +1 -1
  103. package/.next/standalone/.next/static/chunks/3ns-egfh8i8i5.js +6 -0
  104. package/.next/standalone/.next/static/chunks/3rq7fjj9we0v3.js +1 -0
  105. package/.next/standalone/.next/static/chunks/42m0f2hh8vfb1.js +1 -0
  106. package/.next/standalone/app/actions/get-jev-config.ts +2 -2
  107. package/.next/standalone/app/actions/update-jev-config.ts +12 -9
  108. package/.next/standalone/app/components/jev-notices.tsx +10 -10
  109. package/.next/standalone/app/settings/jev-panel.tsx +11 -11
  110. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +2 -0
  111. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +26 -0
  112. package/.next/standalone/fp-cloud-cli/tests/test_output.py +27 -0
  113. package/.next/standalone/hermes-plugin/README.md +18 -8
  114. package/.next/standalone/package.json +10 -10
  115. package/.next/standalone/sdk/typescript/CHANGELOG.md +18 -1
  116. package/.next/standalone/sdk/typescript/integration/fixtures/nextjs/package-lock.json +0 -274
  117. package/.next/standalone/server.js +1 -1
  118. package/bin/failproofai.mjs +33 -7
  119. package/dist/cli.mjs +664 -553
  120. package/dist/worker.mjs +164 -407
  121. package/hermes-plugin/README.md +18 -8
  122. package/package.json +10 -10
  123. package/scripts/build-policy-pack.mjs +6 -9
  124. package/src/hooks/cloud-connection.ts +12 -6
  125. package/src/hooks/custom-hooks-loader.ts +1 -1
  126. package/src/hooks/effective-reviewers.ts +50 -43
  127. package/src/hooks/handler.ts +25 -16
  128. package/src/hooks/hermes-update.ts +74 -0
  129. package/src/hooks/hook-activity-store.ts +3 -3
  130. package/src/hooks/integrations.ts +349 -55
  131. package/src/hooks/jev-activity.ts +3 -3
  132. package/src/hooks/jev-cli.ts +17 -17
  133. package/src/hooks/jev-cloud-connection.ts +4 -4
  134. package/src/hooks/manager.ts +3 -2
  135. package/src/hooks/pack-cli.ts +24 -23
  136. package/src/hooks/pack-manifest.ts +25 -2
  137. package/src/hooks/pack-store.ts +2 -2
  138. package/src/hooks/policy-authority.ts +51 -32
  139. package/src/hooks/policy-evaluator.ts +5 -5
  140. package/src/hooks/policy-registry.ts +5 -7
  141. package/src/hooks/policy-reviewability.ts +26 -10
  142. package/src/hooks/policy-types.ts +4 -4
  143. package/src/hooks/semantic/combine.ts +13 -13
  144. package/src/hooks/semantic/decide.ts +134 -29
  145. package/src/hooks/semantic/evaluator.ts +5 -5
  146. package/src/hooks/semantic/facts.ts +91 -2
  147. package/src/hooks/semantic/jev-client.ts +1 -1
  148. package/src/hooks/semantic/jev-config.ts +21 -12
  149. package/src/hooks/semantic/jev-review.ts +3 -3
  150. package/src/hooks/semantic/jev-stats.ts +17 -17
  151. package/src/hooks/semantic/pack-policies.ts +48 -48
  152. package/src/hooks/semantic/policies.ts +13 -425
  153. package/src/hooks/semantic/precondition-names.ts +1 -1
  154. package/src/hooks/semantic/preconditions.ts +1 -1
  155. package/src/hooks/semantic/types.ts +7 -1
  156. package/.next/standalone/.next/static/chunks/078gnymqoh3r4.js +0 -1
  157. package/.next/standalone/.next/static/chunks/1bu2-nv59ed6i.js +0 -1
  158. package/.next/standalone/.next/static/chunks/2a405_e1o26ol.js +0 -1
  159. package/.next/standalone/.next/static/chunks/36uhh9el_oz8e.js +0 -1
  160. package/.next/standalone/.next/static/chunks/3o3f1ibfci0p7.js +0 -6
  161. /package/.next/standalone/.next/static/{O_b5R1axf5cb4NxIXq2kS → z1C0kJVF0NCtWn5R88hW3}/_buildManifest.js +0 -0
  162. /package/.next/standalone/.next/static/{O_b5R1axf5cb4NxIXq2kS → z1C0kJVF0NCtWn5R88hW3}/_clientMiddlewareManifest.js +0 -0
  163. /package/.next/standalone/.next/static/{O_b5R1axf5cb4NxIXq2kS → z1C0kJVF0NCtWn5R88hW3}/_ssgManifest.js +0 -0
@@ -22,7 +22,7 @@
22
22
  * | Jev answered | reviewable denies/instructs Jev covered are cleared; final =|
23
23
  * | | the most severe of {remaining regex results, Jev's verdict} |
24
24
  *
25
- * `shadow` mode computes and records all of it, and still returns the regex
25
+ * `observe` mode computes and records all of it, and still returns the regex
26
26
  * result.
27
27
  *
28
28
  * ## Where this departs from plan §4
@@ -232,14 +232,14 @@ import type { PolicyAuthority } from "../policy-types";
232
232
  // The only value imports, and deliberately from the activity vocabulary rather
233
233
  // than local literals: the fallback reason this module writes itself has to
234
234
  // be a code the activity store's closed list names, or it is stored and
235
- // shipped as `other`, and a shadow verdict's model id has to pass the same
235
+ // shipped as `other`, and an observe-mode verdict's model id has to pass the same
236
236
  // shape check the row's own `jevModel` does. Importing them makes a rename a
237
237
  // compile error here. `jev-activity.ts` is pure (no node imports, no semantic
238
238
  // modules), so this costs the hook path nothing.
239
239
  import { JEV_MODEL_RE, JEV_REASON_REQUEST_CUT } from "../jev-activity";
240
240
 
241
241
  export type Decision = "allow" | "deny" | "instruct";
242
- export type JevMode = "shadow" | "enforce";
242
+ export type JevMode = "observe" | "enforce";
243
243
 
244
244
  /** One regex policy's verdict, in evaluation order. */
245
245
  export interface RegexVerdict {
@@ -383,13 +383,13 @@ export interface JevActivityFields {
383
383
  }
384
384
 
385
385
  /**
386
- * Jev's own deny or instruct in SHADOW mode — the verdict enforce mode would
386
+ * Jev's own deny or instruct in OBSERVE mode — the verdict enforce mode would
387
387
  * have applied, recorded rather than applied. The handler files it in the
388
388
  * activity row's `observed` list, the "would have" record observe-mode cloud
389
389
  * and pack policies already use, so FailproofAI Cloud's policy page counts it
390
390
  * with no change on its side.
391
391
  */
392
- export interface ShadowVerdict {
392
+ export interface ObserveVerdict {
393
393
  /** `semantic/<check>` — the name enforce mode would have attributed it to. */
394
394
  policyName: string;
395
395
  decision: "deny" | "instruct";
@@ -401,7 +401,7 @@ export interface ShadowVerdict {
401
401
 
402
402
  export interface CombineOutcome {
403
403
  final: FinalVerdict;
404
- /** Reviewable regex policies Jev cleared (in shadow: would have cleared). */
404
+ /** Reviewable regex policies Jev cleared (in observe mode: would have cleared). */
405
405
  cleared: string[];
406
406
  /**
407
407
  * True when the first entry of `final` came from THIS TIER rather than from
@@ -414,11 +414,11 @@ export interface CombineOutcome {
414
414
  decidedByJev: boolean;
415
415
  activity: JevActivityFields;
416
416
  /**
417
- * Shadow mode only, and only when Jev's own verdict was deny or instruct.
417
+ * Observe mode only, and only when Jev's own verdict was deny or instruct.
418
418
  * Absent otherwise — including every enforce outcome, where the verdict was
419
419
  * APPLIED and `decidedByJev` / the final entries already say so.
420
420
  */
421
- shadowVerdict?: ShadowVerdict;
421
+ observeVerdict?: ObserveVerdict;
422
422
  }
423
423
 
424
424
  /**
@@ -495,7 +495,7 @@ export function combineTwoTier(
495
495
  const notDenied = new Set(review.notDenied);
496
496
  const cleared = wholePicture && !review.unclearableWarned ? verdicts.filter((v) => clears(v, asked, notDenied)).map((v) => v.policyName) : [];
497
497
 
498
- // Built once, for both modes: enforce applies it, shadow records it, and the
498
+ // Built once, for both modes: enforce applies it, observe records it, and the
499
499
  // two must never disagree about what the verdict WAS.
500
500
  const jevEntry = { policyName: review.policyName, reason: review.reason ?? `Flagged by semantic review (${review.policyName})` };
501
501
  const enforced = resolveEnforce(verdicts, cleared, review.decision, jevEntry);
@@ -510,7 +510,7 @@ export function combineTwoTier(
510
510
  evaluator: review.requestCut ? "jev-fallback" : "jev",
511
511
  ...(review.requestCut ? { jevFallbackReason: JEV_REASON_REQUEST_CUT } : {}),
512
512
  jevDecision: review.decision,
513
- // Only a clear that SOFTENED the call (in shadow: would have). One that
513
+ // Only a clear that SOFTENED the call (in observe mode: would have). One that
514
514
  // Jev's own deny, or another regex deny, still decided over changed
515
515
  // nothing — and `jev status` and the policy page's "Cleared by Jev" both
516
516
  // read `jevCleared` as calls Jev let through.
@@ -520,11 +520,11 @@ export function combineTwoTier(
520
520
  jevMode: mode,
521
521
  };
522
522
 
523
- if (mode === "shadow") {
523
+ if (mode === "observe") {
524
524
  // Jev's own deny or instruct, exactly as enforce mode would have applied it
525
525
  // (upward only: a cut or injected call keeps it, as the enforce branch
526
526
  // below does). A clear is recorded in `jevCleared`, not here.
527
- const shadowVerdict: ShadowVerdict | undefined =
527
+ const observeVerdict: ObserveVerdict | undefined =
528
528
  review.decision === "deny" || review.decision === "instruct"
529
529
  ? {
530
530
  policyName: jevEntry.policyName,
@@ -535,7 +535,7 @@ export function combineTwoTier(
535
535
  version: review.model && JEV_MODEL_RE.test(review.model) ? review.model : "jev",
536
536
  }
537
537
  : undefined;
538
- return { final: legacy, cleared, decidedByJev: false, activity, ...(shadowVerdict ? { shadowVerdict } : {}) };
538
+ return { final: legacy, cleared, decidedByJev: false, activity, ...(observeVerdict ? { observeVerdict } : {}) };
539
539
  }
540
540
 
541
541
  return { ...enforced, cleared, activity };
@@ -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
  }
@@ -98,7 +98,7 @@ export interface SemanticOptions {
98
98
  */
99
99
  signal?: AbortSignal;
100
100
  model?: string;
101
- /** Overrides the resolved set (an installed pack's, else the compiled-in one). */
101
+ /** Overrides the resolved set (the installed packs' checks; empty with none). */
102
102
  policies?: ReadonlyArray<SemanticPolicy>;
103
103
  /** The agent the call is for: a pack scoped to other agents contributes no checks. */
104
104
  cli?: string;
@@ -306,8 +306,8 @@ export function prepareSemantic(input: SemanticInput, opts: SemanticOptions = {}
306
306
  scanned,
307
307
  input.projectRoot ?? null,
308
308
  );
309
- // The set an installed pack declared, or the compiled-in one when no pack
310
- // declares any — see `pack-policies.ts` for the replacement rule. Resolved
309
+ // The checks the installed packs declare — none when no pack declares any,
310
+ // and then nothing is asked (see `pack-policies.ts`). Resolved
311
311
  // here rather than by the caller because this is the one place the policy set
312
312
  // is read, and a second resolution site is a second answer to "what does this
313
313
  // machine ask Jev". A caller that supplies `policies` (a replay, an ablation,
@@ -496,7 +496,7 @@ export interface VerdictLogMeta {
496
496
  eventType: string;
497
497
  /**
498
498
  * What the handler did with the outcome: combined it with the regex results
499
- * (`two-tier`), logged it while enforcing the regex result (`shadow`), or
499
+ * (`two-tier`), logged it while enforcing the regex result (`observe`), or
500
500
  * kept the regex result because Jev never answered (`legacy-fallback`).
501
501
  *
502
502
  * A TRUNCATED call is `two-tier`, not `legacy-fallback`: its clears were
@@ -504,7 +504,7 @@ export interface VerdictLogMeta {
504
504
  * `combine.ts`). `truncated` on the same row is what says the clearing half
505
505
  * was off for it; the activity row records `jev-fallback` / `truncated`.
506
506
  */
507
- applied: "two-tier" | "shadow" | "legacy-fallback";
507
+ applied: "two-tier" | "observe" | "legacy-fallback";
508
508
  }
509
509
 
510
510
  export function verdictLogRow(input: SemanticInput, outcome: SemanticOutcome, meta: VerdictLogMeta): Record<string, unknown> {
@@ -49,6 +49,39 @@ export interface ScannedCommand {
49
49
  commentsRemoved: boolean;
50
50
  /** The removed comment text, so the injection probe can still see it. */
51
51
  comments: string[];
52
+ /**
53
+ * False when the scan may not have seen every word bash would run: the
54
+ * command was longer than `MAX_SCAN_CHARS`, or it uses syntax this scanner
55
+ * does not model (see {@link scanCommand}). Then `segments` is a GUESS — a
56
+ * `#` it read as a comment may not be one, and a word it never reached may be
57
+ * a target — and a caller must not rest a clear on what the segments omit.
58
+ */
59
+ complete: boolean;
60
+ }
61
+
62
+ /** Shells whose `-c` argument is a second command line this scan does not parse. */
63
+ const NESTED_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "fish"]);
64
+
65
+ /**
66
+ * A `$` followed by this starts an expansion bash performs outside single
67
+ * quotes: `$NAME`, `$1`, `$@ $* $# $? $$ $! $-`, `${…}`, `$(…)` and `$((…))`.
68
+ * The scan does not resolve any of them — `DANGER=/critical; rm -rf $DANGER`
69
+ * names `/critical` only through the variable — so each makes it incomplete.
70
+ * A `$` before anything else (a space, `/`, the end) is a literal dollar.
71
+ */
72
+ const EXPANSION_START = /[A-Za-z_0-9@*#?$!\-({]/;
73
+
74
+ /** A segment that hands a string to a shell to parse again: `eval …`, `bash -c …`, `sudo sh -lc …`. */
75
+ function runsNestedShell(tokens: string[]): boolean {
76
+ // One pass, so a segment of ten thousand `sh` words stays linear.
77
+ let shellSeen = false;
78
+ for (const tok of tokens) {
79
+ if (shellSeen && /^-[a-z]*c[a-z]*$/i.test(tok)) return true;
80
+ const base = tok.slice(tok.lastIndexOf("/") + 1);
81
+ if (base === "eval") return true;
82
+ if (NESTED_SHELLS.has(base)) shellSeen = true;
83
+ }
84
+ return false;
52
85
  }
53
86
 
54
87
  /**
@@ -56,6 +89,17 @@ export interface ScannedCommand {
56
89
  * operator splitting and comment removal. It is deliberately not a full shell
57
90
  * parser — it only needs to find words, segment boundaries and comments, and
58
91
  * it must stay linear no matter what the agent sends.
92
+ *
93
+ * Because it is not bash, it says when it may be wrong: `complete` is false on
94
+ * any construct whose quoting or word boundaries it does not follow — `$'…'`
95
+ * and `$"…"`, any parameter expansion (`$NAME`, `$1`, `$@`, `${…}`, `$((…))`),
96
+ * `$(…)` and backticks, unquoted brace expansion (`{a,b}`, `{1..3}`) and
97
+ * globs (`*`, `?`, `[…]`), `<(…)` / `>(…)`, heredocs and
98
+ * here-strings (`<<`), a backslash-newline, a quote left open at the end,
99
+ * `eval` or a shell's `-c` string, and a command cut at `MAX_SCAN_CHARS`.
100
+ * Each of those can put a `#` where the scan sees a comment and bash does not
101
+ * (`echo $'x\' # '; rm -rf /critical` scans as `echo x`), or put a command
102
+ * where the scan sees one word. The flag costs one comparison per character.
59
103
  */
60
104
  export function scanCommand(command: string): ScannedCommand {
61
105
  const text = command.length > MAX_SCAN_CHARS ? command.slice(0, MAX_SCAN_CHARS) : command;
@@ -67,15 +111,27 @@ export function scanCommand(command: string): ScannedCommand {
67
111
  let out = "";
68
112
  let commentsRemoved = false;
69
113
  const comments: string[] = [];
114
+ let complete = command.length <= MAX_SCAN_CHARS;
115
+ // Per-word, unquoted: an open `{` not yet closed, whether a `,` or `..`
116
+ // followed it, and an open `[`. Enough to spot brace expansion and globs.
117
+ let braceDepth = 0;
118
+ let braceSep = false;
119
+ let bracketOpen = false;
70
120
 
71
121
  const endWord = () => {
72
122
  if (inWord) tokens.push(word);
73
123
  word = "";
74
124
  inWord = false;
125
+ braceDepth = 0;
126
+ braceSep = false;
127
+ bracketOpen = false;
75
128
  };
76
129
  const endSegment = () => {
77
130
  endWord();
78
- if (tokens.length > 0) segments.push(tokens);
131
+ if (tokens.length > 0) {
132
+ if (complete && runsNestedShell(tokens)) complete = false;
133
+ segments.push(tokens);
134
+ }
79
135
  tokens = [];
80
136
  };
81
137
 
@@ -83,6 +139,11 @@ export function scanCommand(command: string): ScannedCommand {
83
139
  const c = text[i];
84
140
  if (quote) {
85
141
  out += c;
142
+ // Inside "…", bash still expands `$NAME`, `$(…)`, `${…}` and backticks,
143
+ // and a backslash-newline is a continuation: none of that is followed here.
144
+ if (quote === '"' && (c === "`" || (c === "$" && EXPANSION_START.test(text[i + 1] ?? "")) || (c === "\\" && text[i + 1] === "\n"))) {
145
+ complete = false;
146
+ }
86
147
  if (c === quote) {
87
148
  quote = null;
88
149
  } else if (c === "\\" && quote === '"' && i + 1 < text.length) {
@@ -93,6 +154,16 @@ export function scanCommand(command: string): ScannedCommand {
93
154
  }
94
155
  continue;
95
156
  }
157
+ // Unquoted constructs whose contents the scan does not parse as bash does.
158
+ if (
159
+ c === "`" ||
160
+ (c === "$" && (text[i + 1] === "'" || text[i + 1] === '"' || EXPANSION_START.test(text[i + 1] ?? ""))) ||
161
+ ((c === "<" || c === ">") && text[i + 1] === "(") ||
162
+ (c === "<" && text[i + 1] === "<") ||
163
+ (c === "\\" && text[i + 1] === "\n")
164
+ ) {
165
+ complete = false;
166
+ }
96
167
  if (c === "'" || c === '"') {
97
168
  quote = c;
98
169
  inWord = true;
@@ -131,12 +202,30 @@ export function scanCommand(command: string): ScannedCommand {
131
202
  out += c;
132
203
  continue;
133
204
  }
205
+ // Brace expansion and globs, unquoted: bash turns `{build,/critical}` and
206
+ // `/crit*` into words this scan never sees. A `{` or `}` word on its own
207
+ // (`{ ls; }`, `-exec rm {} \;`) and `{}` are not expansions.
208
+ if (c === "{") {
209
+ braceDepth++;
210
+ } else if (braceDepth > 0 && (c === "," || (c === "." && text[i + 1] === "."))) {
211
+ braceSep = true;
212
+ } else if (c === "}" && braceDepth > 0) {
213
+ braceDepth--;
214
+ if (braceSep) complete = false;
215
+ } else if (c === "*" || c === "?") {
216
+ complete = false;
217
+ } else if (c === "[") {
218
+ bracketOpen = true;
219
+ } else if (c === "]" && bracketOpen) {
220
+ complete = false;
221
+ }
134
222
  word += c;
135
223
  inWord = true;
136
224
  out += c;
137
225
  }
138
226
  endSegment();
139
- return { segments, withoutComments: out.trimEnd(), commentsRemoved, comments };
227
+ if (quote) complete = false;
228
+ return { segments, withoutComments: out.trimEnd(), commentsRemoved, comments, complete };
140
229
  }
141
230
 
142
231
  const PATH_LIKE = /^(?:\/|~|\.\.?(?:\/|$)|[^:]*\/)/;
@@ -469,7 +469,7 @@ async function postJson(url: string, bearer: string, body: unknown, signal: Abor
469
469
  body: JSON.stringify(body),
470
470
  signal,
471
471
  // Never followed. The configured URL is the one `validateBaseUrl` checked
472
- // (https, or loopback http in shadow mode only); a redirect would hand the
472
+ // (https, or loopback http in observe mode only); a redirect would hand the
473
473
  // answer — the thing that can clear a deny — to an origin nobody checked,
474
474
  // plain http included. No provider redirects this POST.
475
475
  redirect: "manual",
@@ -55,7 +55,7 @@
55
55
  * The one provider whose key is NOT in this file. `failproofai config --token`
56
56
  * with a key carrying `jev:evaluate` stores that key in the `jev` slot of
57
57
  * `credentials.json` and, when there is no `jev.json` yet, writes one naming
58
- * this provider, the Cloud origin + `/enforcement/v1/jev` and `mode: "shadow"`.
58
+ * this provider, the Cloud origin + `/enforcement/v1/jev` and `mode: "observe"`.
59
59
  * So for this provider:
60
60
  *
61
61
  * - the key comes from `credentials.json` (`readJevCloudCredential`), read with
@@ -80,7 +80,7 @@
80
80
  *
81
81
  * # `mode: "off"`
82
82
  *
83
- * Every provider accepts `off | shadow | enforce`. `off` keeps the file — the
83
+ * Every provider accepts `off | observe | enforce`. `off` keeps the file — the
84
84
  * endpoint, and for BYOK the key — while Jev does not run at all:
85
85
  * `loadJevConfig` returns null exactly as for an absent file. It exists so the
86
86
  * dashboard can switch the Cloud route off without deleting the file that
@@ -96,8 +96,16 @@ export type { JevCloudCredential } from "../fp-config";
96
96
 
97
97
  export type JevProviderKind = "typesafe" | "openrouter" | "vercel" | "cloudflare" | "custom" | "failproofai";
98
98
 
99
- /** `off` does not run Jev at all; `shadow` logs Jev and enforces regex; `enforce` applies the combine rules. */
100
- export type JevConfigMode = "off" | "shadow" | "enforce";
99
+ /** `off` does not run Jev at all; `observe` logs Jev and enforces regex; `enforce` applies the combine rules. */
100
+ export type JevConfigMode = "off" | "observe" | "enforce";
101
+
102
+ /**
103
+ * A mode as read from a file, a flag or a request: `off`, `observe` or
104
+ * `enforce`. Null for anything else.
105
+ */
106
+ export function parseJevMode(raw: unknown): JevConfigMode | null {
107
+ return raw === "off" || raw === "observe" || raw === "enforce" ? raw : null;
108
+ }
101
109
 
102
110
  export interface JevConfig {
103
111
  provider: JevProviderKind;
@@ -130,7 +138,7 @@ export interface JevConfig {
130
138
  credentialOrigin?: string;
131
139
  }
132
140
 
133
- export const DEFAULT_JEV_MODE: "shadow" | "enforce" = "enforce";
141
+ export const DEFAULT_JEV_MODE: "observe" | "enforce" = "enforce";
134
142
 
135
143
  export const JEV_PROVIDER_KINDS: readonly JevProviderKind[] = ["typesafe", "openrouter", "vercel", "cloudflare", "custom", "failproofai"];
136
144
 
@@ -311,7 +319,7 @@ function credentialQueryProblem(url: URL): string | null {
311
319
  * No credentials in the URL — not as userinfo, not as a query parameter — and no
312
320
  * fragment, because a key belongs in the key field, where it is sent as a bearer
313
321
  * and never printed. `validateJevConfig` further accepts the loopback http form
314
- * only in shadow mode.
322
+ * only in observe mode.
315
323
  */
316
324
  export function validateBaseUrl(raw: unknown): ValidationResult<string> {
317
325
  if (typeof raw !== "string" || raw.trim() === "") return { ok: false, problem: "baseUrl must be a non-empty string" };
@@ -667,25 +675,26 @@ export function validateJevConfig(
667
675
  }
668
676
 
669
677
  if (o.mode !== undefined) {
670
- if (o.mode !== "off" && o.mode !== "shadow" && o.mode !== "enforce") return { ok: false, problem: 'mode must be "off", "shadow" or "enforce"' };
671
- cfg.mode = o.mode;
678
+ const mode = parseJevMode(o.mode);
679
+ if (mode === null) return { ok: false, problem: 'mode must be "off", "observe" or "enforce"' };
680
+ cfg.mode = mode;
672
681
  }
673
682
 
674
683
  // Plain http reaches only a loopback host (`validateBaseUrl`), and nothing
675
684
  // authenticates the server there: while the local proxy is down, any process
676
685
  // of this user — the agent being judged included — can bind its port and
677
686
  // answer "none" to every question. In enforce mode that answer clears
678
- // reviewable denies; in shadow mode it changes nothing, and `off` sends
687
+ // reviewable denies; in observe mode it changes nothing, and `off` sends
679
688
  // nothing at all, so enforce is the one mode it is refused in.
680
689
  if (cfg.baseUrl !== undefined && isPlainHttp(cfg.baseUrl) && cfg.mode === "enforce") {
681
690
  return {
682
691
  ok: false,
683
692
  problem:
684
- "plain http (to localhost) is accepted only with mode shadow: in enforce mode Jev's answers can clear a deny, " +
693
+ "plain http (to localhost) is accepted only with mode observe: in enforce mode Jev's answers can clear a deny, " +
685
694
  "and while the local proxy is down any process on this machine could take its port and answer. " +
686
695
  (cfg.provider === JEV_CLOUD_PROVIDER
687
- ? "Reconnect to an https FailproofAI Cloud URL (failproofai config --token <key> --url https://…), or keep mode shadow"
688
- : "Use https, or mode shadow"),
696
+ ? "Reconnect to an https FailproofAI Cloud URL (failproofai config --token <key> --url https://…), or keep mode observe"
697
+ : "Use https, or mode observe"),
689
698
  };
690
699
  }
691
700