failproofai 1.0.12-beta.1 → 1.0.12-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) 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 +2 -2
  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 +2 -2
  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 +3 -3
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  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 +3 -3
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  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 +2 -2
  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/_0tovk6q0miud6._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/_0trp3ycqv17cl._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/_1ek68lnh2daur._.js +2 -2
  60. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4vkpxxu7._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/src_hooks_0_em82s16zfym._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00l2u6j86cb8y._.js +3 -0
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08ch3staq3ejw._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pwj2h2efe5f-._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-ey9fgesfbc0._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15usvs-ms-0hf._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gb3_92zvk5x._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1avi2kypusyb_._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1r7drlqli-ado._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0b78b0j8iyv6l._.js → [root-of-the-server]__1vofkb2oq7p7e._.js} +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/_0bseae9u7g98r._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/_0eoura3_9gjb-._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/_0mouhaflye1ro._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/{_1a3tubds7s7ow._.js → _0ouu886szh6yj._.js} +4 -4
  78. package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/_1fw0_nzkae5ao._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/_20hn4gn87nsjc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/app_components_project-list_tsx_1ke6anr0vt64h._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_1i8oid1gc7s1f.js → node_modules_html-to-image_es_index_1fv03uigni8s5.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0janj0i6dqko7._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0agdmptetetdt._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0mdwgjisv4fub._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589gp4p60j._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me658zqmh_._.js +1 -1
  96. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  97. package/.next/standalone/.next/server/pages/404.html +1 -1
  98. package/.next/standalone/.next/server/pages/500.html +1 -1
  99. package/.next/standalone/.next/server/preview-props.json +3 -3
  100. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  101. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  102. package/.next/standalone/.next/static/chunks/{2-tf6_7u623kd.js → 0ay__-d2h1xb5.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/{18433qz-8njv3.js → 0tbm3vjm62h-2.js} +1 -1
  104. package/.next/standalone/.next/static/chunks/{1b32-afqacaen.js → 1c_jf6g5wnslk.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{0_zfad4co-t2z.js → 1my3-5p47t7r-.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{0jutamwqz8pg5.js → 20py8k1gd_i1m.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/21hckpmsc4g70.js +6 -0
  108. package/.next/standalone/.next/static/chunks/2hmex93ppj1ke.js +1 -0
  109. package/.next/standalone/.next/static/chunks/{42e8zab314654.js → 2o6d37j425uci.js} +1 -1
  110. package/.next/standalone/.next/static/chunks/40oworn_-x12p.js +1 -0
  111. package/.next/standalone/.next/static/chunks/{24w3x2da9sk49.js → 42xptz88mtq3x.js} +1 -1
  112. package/.next/standalone/app/actions/update-jev-config.ts +5 -3
  113. package/.next/standalone/app/settings/jev-panel.tsx +1 -1
  114. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +9 -18
  115. package/.next/standalone/fp-cloud-cli/README.md +7 -9
  116. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1 -27
  117. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +12 -216
  118. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +7 -53
  119. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +2 -71
  120. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +1 -1
  121. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +6 -7
  122. package/.next/standalone/fp-cloud-cli/tests/test_cloud_jev.py +32 -197
  123. package/.next/standalone/hermes-plugin/__init__.py +11 -0
  124. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  125. package/.next/standalone/package.json +11 -10
  126. package/.next/standalone/sdk/python/uv.lock +31 -8
  127. package/.next/standalone/server.js +1 -1
  128. package/dist/cli.mjs +1686 -612
  129. package/dist/worker.mjs +354 -49
  130. package/hermes-plugin/__init__.py +11 -0
  131. package/hermes-plugin/plugin.yaml +1 -1
  132. package/package.json +11 -10
  133. package/src/audit/open-browser.ts +24 -7
  134. package/src/hooks/agent-roster.ts +9 -1
  135. package/src/hooks/agent-skill.ts +203 -0
  136. package/src/hooks/cloud-connection.ts +27 -51
  137. package/src/hooks/cloud-introspect.ts +5 -2
  138. package/src/hooks/cloud-login.ts +248 -0
  139. package/src/hooks/cloud-managed-policies.ts +54 -4
  140. package/src/hooks/codex-system-hooks.ts +10 -4
  141. package/src/hooks/config-status.ts +9 -3
  142. package/src/hooks/configure-wizard.ts +356 -32
  143. package/src/hooks/delivery-health.ts +47 -2
  144. package/src/hooks/effective-reviewers.ts +14 -0
  145. package/src/hooks/fp-config.ts +19 -0
  146. package/src/hooks/handler.ts +21 -2
  147. package/src/hooks/hook-activity-store.ts +7 -1
  148. package/src/hooks/integrations.ts +18 -3
  149. package/src/hooks/jev-activity.ts +117 -1
  150. package/src/hooks/jev-cli.ts +7 -9
  151. package/src/hooks/manager.ts +80 -46
  152. package/src/hooks/policy-evaluator.ts +17 -0
  153. package/src/hooks/policy-reviewability.ts +4 -2
  154. package/src/hooks/semantic/cloud-jev.ts +219 -28
  155. package/src/hooks/semantic/combine.ts +26 -1
  156. package/src/hooks/semantic/jev-client.ts +1 -1
  157. package/src/hooks/semantic/jev-config.ts +6 -27
  158. package/src/hooks/semantic/jev-review.ts +90 -2
  159. package/src/hooks/tui.ts +280 -0
  160. package/src/hooks/uninstall-cli.ts +12 -1
  161. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0588st-tpiuia._.js +0 -3
  162. package/.next/standalone/.next/server/chunks/ssr/_128ebxr83nk6g._.js +0 -3
  163. package/.next/standalone/.next/static/chunks/114hbolvrv94e.js +0 -1
  164. package/.next/standalone/.next/static/chunks/1p-hsi58i-6h0.js +0 -6
  165. package/.next/standalone/.next/static/chunks/3-pku9qwv1yfu.js +0 -1
  166. /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_buildManifest.js +0 -0
  167. /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_clientMiddlewareManifest.js +0 -0
  168. /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_ssgManifest.js +0 -0
@@ -90,10 +90,28 @@ export interface CloudJevBlock {
90
90
 
91
91
  /** FailproofAI Cloud's reply block, validated. Its outcomes carry a pack-shaped `cloud:<id>` origin. */
92
92
  export interface CloudJevReply {
93
+ /** The ENFORCED checks' verdict: an observe assignment's outcomes are in `observed`, not here. */
93
94
  verdict: SemanticVerdict;
94
95
  asked: string[];
95
96
  droppedLocal: string[];
96
97
  droppedCloud: string[];
98
+ /** What the checks of an `observe` assignment would have done (deny / instruct only). */
99
+ observed: ObservedCloudCheck[];
100
+ /** Every check of an `observe` assignment Cloud asked (its name). */
101
+ observedAsked: string[];
102
+ }
103
+
104
+ /**
105
+ * One `observe` Cloud assignment's would-have: recorded on the activity row's
106
+ * `observed` list like an observe JS policy's, never applied, never a clear.
107
+ * One per policy, its most severe check.
108
+ */
109
+ export interface ObservedCloudCheck {
110
+ cloudPolicyId: string;
111
+ cloudVersion: number;
112
+ check: string;
113
+ decision: "deny" | "instruct";
114
+ reason: string;
97
115
  }
98
116
 
99
117
  /** What the Cloud review adds to a `SemanticOutcome`, for the verdict log and the budget report. */
@@ -102,6 +120,20 @@ export interface CloudReplySummary {
102
120
  /** The machine's own groups Cloud dropped, with the pack that declared each (null when none did). */
103
121
  droppedLocal: Array<{ name: string; packId: string | null }>;
104
122
  droppedCloud: string[];
123
+ /**
124
+ * TypeSafe's answers to the Cloud checks' own questions (`<check>.<probe>`,
125
+ * `<check>.exempt`), for the activity row's per-check record. Ids and
126
+ * numbers only; the machine never decides from these (Cloud did).
127
+ */
128
+ answers: Record<string, number>;
129
+ /**
130
+ * Checks of WATCHED (observe-assigned) Cloud policies, decided apart into
131
+ * the reply's `observed` block: recorded on the activity row only. They
132
+ * never reach the verdict, any clear, or the merge.
133
+ */
134
+ observedOutcomes: PolicyOutcome[];
135
+ observed: ObservedCloudCheck[];
136
+ observedAsked: string[];
105
137
  }
106
138
 
107
139
  export type CloudSemanticOutcome =
@@ -113,6 +145,8 @@ export interface CloudSemanticOptions extends SemanticOptions {
113
145
  machineId: string;
114
146
  /** Explicit null = v2 request with unresolved identity (no scoped checks). */
115
147
  agent?: AgentIdentity | null;
148
+ /** Cloud policy ids whose Jev assignment on this machine is `observe` (`active.json` `jevPolicies`). */
149
+ observedPolicies?: ReadonlyArray<string>;
116
150
  }
117
151
 
118
152
  // ── The request ──────────────────────────────────────────────────────────────
@@ -366,8 +400,19 @@ const DECISIONS = new Set(["allow", "deny", "instruct"]);
366
400
  const VERDICTS = new Set(["deny", "instruct", "overridden", "none"]);
367
401
  const INTENTS = new Set(["op-requested", "task-step", "downgraded-task-step"]);
368
402
 
403
+ /** One Cloud outcome as parsed: the outcome, and what only the reply can say about it. */
404
+ interface ParsedCloudOutcome {
405
+ outcome: PolicyOutcome;
406
+ cloudPolicyId: string;
407
+ cloudVersion: number;
408
+ /** The assignment's effect, when Cloud says (`effect`); absent reads as `enforce`. */
409
+ observe: boolean;
410
+ /** Cloud's own line for this check, when it sends one. */
411
+ reason: string | null;
412
+ }
413
+
369
414
  /** One Cloud outcome, rebuilt field by field so nothing but these fields crosses over. */
370
- function parseOutcome(raw: unknown, i: number): PolicyOutcome {
415
+ function parseOutcome(raw: unknown, i: number): ParsedCloudOutcome {
371
416
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) malformed(`outcome ${i} is not an object`);
372
417
  const o = raw as Record<string, unknown>;
373
418
  if (!isName(o.policy)) malformed(`outcome ${i} has no policy name`);
@@ -391,7 +436,9 @@ function parseOutcome(raw: unknown, i: number): PolicyOutcome {
391
436
  ) {
392
437
  malformed(`outcome ${i} has no Cloud origin`);
393
438
  }
394
- return {
439
+ if (o.effect !== undefined && o.effect !== "enforce" && o.effect !== "observe") malformed(`outcome ${i} has an unknown effect`);
440
+ if (o.reason !== undefined && o.reason !== null && typeof o.reason !== "string") malformed(`outcome ${i} reason is not text`);
441
+ const outcome: PolicyOutcome = {
395
442
  policy: o.policy,
396
443
  mode: o.mode,
397
444
  ...(typeof o.userCanOverride === "boolean" ? { userCanOverride: o.userCanOverride } : {}),
@@ -408,6 +455,48 @@ function parseOutcome(raw: unknown, i: number): PolicyOutcome {
408
455
  ...(o.intent !== undefined ? { intent: o.intent as PolicyOutcome["intent"] } : {}),
409
456
  ...(o.targetScanIncomplete === true ? { targetScanIncomplete: true as const } : {}),
410
457
  };
458
+ return {
459
+ outcome,
460
+ cloudPolicyId: origin.cloudPolicyId as string,
461
+ cloudVersion: origin.cloudVersion as number,
462
+ observe: o.effect === "observe",
463
+ reason: typeof o.reason === "string" ? capReason(o.reason) : null,
464
+ };
465
+ }
466
+
467
+ function capReason(reason: string): string {
468
+ return reason.length > MAX_CLOUD_REASON_CHARS ? `${reason.slice(0, MAX_CLOUD_REASON_CHARS - 1)}…` : reason;
469
+ }
470
+
471
+ const SEVERITY: Record<"deny" | "instruct", number> = { instruct: 1, deny: 2 };
472
+
473
+ /**
474
+ * The would-haves of the observed outcomes: per Cloud policy, its most severe
475
+ * check that fired (`deny`, or a warning). A check that found nothing, or that
476
+ * the human asked for, would have done nothing.
477
+ */
478
+ function observedChecks(
479
+ parsed: ReadonlyArray<ParsedCloudOutcome>,
480
+ watched: { v: Record<string, unknown>; parsed: ParsedCloudOutcome[] } | null = null,
481
+ ): ObservedCloudCheck[] {
482
+ // The observed verdict's own reason, when exactly one of its checks fired.
483
+ const firing = watched?.parsed.filter((p) => p.outcome.verdict === "deny" || p.outcome.verdict === "instruct") ?? [];
484
+ const watchedReason = firing.length === 1 && typeof watched?.v.reason === "string" ? capReason(watched.v.reason) : null;
485
+ const byPolicy = new Map<string, ObservedCloudCheck>();
486
+ for (const p of parsed) {
487
+ const verdict = p.outcome.verdict;
488
+ if (verdict !== "deny" && verdict !== "instruct") continue;
489
+ const prev = byPolicy.get(p.cloudPolicyId);
490
+ if (prev && SEVERITY[prev.decision] >= SEVERITY[verdict]) continue;
491
+ byPolicy.set(p.cloudPolicyId, {
492
+ cloudPolicyId: p.cloudPolicyId,
493
+ cloudVersion: p.cloudVersion,
494
+ check: p.outcome.policy,
495
+ decision: verdict,
496
+ reason: p.reason ?? (firing[0] === p ? watchedReason : null) ?? `Flagged by semantic review (semantic/${p.outcome.policy})`,
497
+ });
498
+ }
499
+ return [...byPolicy.values()];
411
500
  }
412
501
 
413
502
  /**
@@ -415,17 +504,20 @@ function parseOutcome(raw: unknown, i: number): PolicyOutcome {
415
504
  * A reply without one (a server that does not implement C10) is malformed:
416
505
  * nothing it says can be told apart from "Cloud has no checks here".
417
506
  */
418
- export function parseCloudReply(raw: unknown): CloudJevReply {
419
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) malformed("no cloud block");
420
- const block = raw as Record<string, unknown>;
421
- const v = block.verdict as Record<string, unknown> | undefined;
422
- if (!v || typeof v !== "object" || Array.isArray(v)) malformed("no verdict");
423
- if (typeof v.decision !== "string" || !DECISIONS.has(v.decision)) malformed("verdict has no decision");
424
- if (v.reason !== null && v.reason !== undefined && typeof v.reason !== "string") malformed("verdict reason is not text");
425
- if (!Array.isArray(v.outcomes) || v.outcomes.length > MAX_LIST) malformed("verdict has no outcomes list");
426
- if (v.beyondTask !== undefined && typeof v.beyondTask !== "boolean") malformed("verdict beyondTask is not a flag");
427
- const outcomes = v.outcomes.map(parseOutcome);
428
- const asked = names(block.asked, "asked");
507
+ /** One verdict block (`verdict` + `asked`) validated, with every rule a reply's verdict is held to. */
508
+ function parseVerdictBlock(
509
+ rawVerdict: unknown,
510
+ rawAsked: unknown,
511
+ what: string,
512
+ ): { v: Record<string, unknown>; parsed: ParsedCloudOutcome[]; asked: string[] } {
513
+ const v = rawVerdict as Record<string, unknown> | undefined;
514
+ if (!v || typeof v !== "object" || Array.isArray(v)) malformed(`no ${what}`);
515
+ if (typeof v.decision !== "string" || !DECISIONS.has(v.decision)) malformed(`${what} has no decision`);
516
+ if (v.reason !== null && v.reason !== undefined && typeof v.reason !== "string") malformed(`${what} reason is not text`);
517
+ if (!Array.isArray(v.outcomes) || v.outcomes.length > MAX_LIST) malformed(`${what} has no outcomes list`);
518
+ if (v.beyondTask !== undefined && typeof v.beyondTask !== "boolean") malformed(`${what} beyondTask is not a flag`);
519
+ const parsed = v.outcomes.map(parseOutcome);
520
+ const asked = names(rawAsked, `${what} asked`);
429
521
  // Each outcome must answer a check Cloud says it ASKED, once. The review
430
522
  // clears a `both` policy's regex half from these outcomes, so one that
431
523
  // `asked` does not vouch for — `asked: []` beside a fabricated `none` — is a
@@ -433,25 +525,65 @@ export function parseCloudReply(raw: unknown): CloudJevReply {
433
525
  // decided by the regex alone (security review of #873).
434
526
  const askedSet = new Set(asked);
435
527
  const seen = new Set<string>();
436
- outcomes.forEach((o, i) => {
437
- if (!askedSet.has(o.policy)) malformed(`outcome ${i} answers ${JSON.stringify(o.policy)}, which Cloud did not ask`);
528
+ parsed.forEach(({ outcome: o }, i) => {
529
+ if (!askedSet.has(o.policy)) malformed(`${what} outcome ${i} answers ${JSON.stringify(o.policy)}, which Cloud did not ask`);
438
530
  const key = `${o.origin?.packId ?? ""}\u0000${o.policy}`;
439
- if (seen.has(key)) malformed(`outcome ${i} answers ${JSON.stringify(o.policy)} twice`);
531
+ if (seen.has(key)) malformed(`${what} outcome ${i} answers ${JSON.stringify(o.policy)} twice`);
440
532
  seen.add(key);
441
533
  });
534
+ return { v, parsed, asked };
535
+ }
536
+
537
+ export function parseCloudReply(raw: unknown, observedPolicies: ReadonlyArray<string> = []): CloudJevReply {
538
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) malformed("no cloud block");
539
+ const block = raw as Record<string, unknown>;
540
+ const { v, parsed, asked } = parseVerdictBlock(block.verdict, block.asked, "verdict");
541
+ // `observed` (CONTRACT, fix round): the checks of watched (`observe`)
542
+ // assignments, decided apart, present only when one was asked. Held to the
543
+ // same rules as the verdict; recorded, never applied.
544
+ let watched: { v: Record<string, unknown>; parsed: ParsedCloudOutcome[]; asked: string[] } | null = null;
545
+ if (block.observed !== undefined && block.observed !== null) {
546
+ const o = block.observed as Record<string, unknown>;
547
+ if (typeof o !== "object" || Array.isArray(o)) malformed("observed is not an object");
548
+ watched = parseVerdictBlock(o.verdict, o.asked, "observed verdict");
549
+ if (watched.parsed.some((p) => p.outcome.verdict === "deny") && watched.v.decision !== "deny") {
550
+ malformed("an observed check denied but its verdict does not");
551
+ }
552
+ }
553
+ // An `observe` assignment's checks (Cloud says so per outcome, or this
554
+ // machine's deployment does) are recorded, never applied: they leave the
555
+ // verdict, so they can neither deny nor clear anything.
556
+ const observeIds = new Set(observedPolicies);
557
+ const isObserved = (p: ParsedCloudOutcome) => p.observe || observeIds.has(p.cloudPolicyId);
558
+ const enforced = parsed.filter((p) => !isObserved(p)).map((p) => p.outcome);
559
+ const observedHere = parsed.filter(isObserved);
560
+ const observed = [...observedHere, ...(watched?.parsed ?? [])];
442
561
  // A check that fired deny makes the verdict a deny; a reply that says
443
562
  // otherwise contradicts itself.
444
- if (outcomes.some((o) => o.verdict === "deny") && v.decision !== "deny") malformed("a check denied but the verdict does not");
563
+ if (enforced.some((o) => o.verdict === "deny") && v.decision !== "deny") malformed("a check denied but the verdict does not");
564
+ const cloudReason = typeof v.reason === "string" ? capReason(v.reason) : null;
565
+ let decision = v.decision as SemanticVerdict["decision"];
566
+ let reason = cloudReason;
567
+ if (observedHere.length > 0) {
568
+ // Decided again from the enforced checks alone, never above what Cloud
569
+ // said. Cloud's reason stands when the decision does; otherwise the
570
+ // combine's generic line names the deciding check.
571
+ const own: SemanticVerdict["decision"] = enforced.some((o) => o.verdict === "deny")
572
+ ? "deny"
573
+ : enforced.some((o) => o.verdict === "instruct") || v.beyondTask === true
574
+ ? "instruct"
575
+ : "allow";
576
+ const capped = RANK[own] <= RANK[decision] ? own : decision;
577
+ if (capped !== decision) reason = null;
578
+ decision = capped;
579
+ }
445
580
  return {
581
+ observed: observedChecks(observed, watched),
582
+ observedAsked: [...observedHere.map((p) => p.outcome.policy), ...(watched?.asked ?? [])],
446
583
  verdict: {
447
- decision: v.decision as SemanticVerdict["decision"],
448
- reason:
449
- typeof v.reason === "string"
450
- ? v.reason.length > MAX_CLOUD_REASON_CHARS
451
- ? `${v.reason.slice(0, MAX_CLOUD_REASON_CHARS - 1)}…`
452
- : v.reason
453
- : null,
454
- outcomes,
584
+ decision,
585
+ reason,
586
+ outcomes: enforced,
455
587
  injectionSuspected: nullableProbability(v.injectionSuspected, "injectionSuspected"),
456
588
  scopeWithinRequest: nullableProbability(v.scopeWithinRequest, "scopeWithinRequest"),
457
589
  beyondTask: v.beyondTask === true,
@@ -462,6 +594,34 @@ export function parseCloudReply(raw: unknown): CloudJevReply {
462
594
  };
463
595
  }
464
596
 
597
+ /**
598
+ * The reply's optional `observed: {verdict, asked}` — the outcomes of checks
599
+ * from watched Cloud policies, which never block. Read leniently and apart
600
+ * from {@link parseCloudReply}: they are only recorded, so a malformed block
601
+ * costs the record, never the call's verdict. Only outcomes `observed.asked`
602
+ * names, each once.
603
+ */
604
+ export function parseObservedOutcomes(raw: unknown): PolicyOutcome[] {
605
+ try {
606
+ const block = (raw as Record<string, unknown> | null)?.observed as Record<string, unknown> | undefined;
607
+ if (!block || typeof block !== "object" || Array.isArray(block)) return [];
608
+ const asked = new Set(names(block.asked, "observed.asked"));
609
+ const outcomes = ((block.verdict as Record<string, unknown> | undefined)?.outcomes ?? []) as unknown[];
610
+ if (!Array.isArray(outcomes) || outcomes.length > MAX_LIST) return [];
611
+ const seen = new Set<string>();
612
+ const out: PolicyOutcome[] = [];
613
+ outcomes.forEach((o, i) => {
614
+ const parsed = parseOutcome(o, i).outcome;
615
+ if (!asked.has(parsed.policy) || seen.has(parsed.policy)) return;
616
+ seen.add(parsed.policy);
617
+ out.push(parsed);
618
+ });
619
+ return out;
620
+ } catch {
621
+ return [];
622
+ }
623
+ }
624
+
465
625
  // ── The merge (CONTRACT C10.5) ───────────────────────────────────────────────
466
626
 
467
627
  const RANK: Record<SemanticVerdict["decision"], number> = { allow: 0, instruct: 1, deny: 2 };
@@ -539,6 +699,28 @@ function expectedQuestions(request: JevRequest, prepared: PreparedCall, dropped:
539
699
  return { ...request, questions };
540
700
  }
541
701
 
702
+ /** A question id's own part, as declarations allow it (`force_push`, `exempt`). */
703
+ const QUESTION_PART_RE = /^[a-z][a-z0-9_]{0,31}$/;
704
+
705
+ /**
706
+ * The answers to the questions Cloud added for the checks it asked: every
707
+ * `<check>.<part>` id whose check is in `asked`, read leniently (a missing or
708
+ * malformed one is simply not recorded — Cloud has already decided).
709
+ */
710
+ export function cloudCheckAnswers(response: JevResponse, asked: ReadonlyArray<string>): Record<string, number> {
711
+ const out: Record<string, number> = {};
712
+ if (asked.length === 0) return out;
713
+ const names = new Set(asked);
714
+ const answers = response.answers && typeof response.answers === "object" ? response.answers : {};
715
+ for (const [id, a] of Object.entries(answers)) {
716
+ const dot = id.lastIndexOf(".");
717
+ if (dot <= 0 || !names.has(id.slice(0, dot)) || !QUESTION_PART_RE.test(id.slice(dot + 1))) continue;
718
+ const p = (a as { noul?: unknown } | null)?.noul;
719
+ if (isProbability(p)) out[id] = p;
720
+ }
721
+ return out;
722
+ }
723
+
542
724
  /** Whatever answers a short-circuit reply carries, read leniently: none is required. */
543
725
  function presentAnswers(response: JevResponse): Record<string, number> {
544
726
  const out: Record<string, number> = {};
@@ -579,7 +761,7 @@ export async function evaluateCloudSemantic(input: SemanticInput, opts: CloudSem
579
761
  model: opts.model ?? DEFAULT_JEV_MODEL,
580
762
  modelVerified: true,
581
763
  via: "none",
582
- cloud: { asked: [], droppedLocal: [], droppedCloud: [] },
764
+ cloud: { asked: [], droppedLocal: [], droppedCloud: [], answers: {}, observed: [], observedAsked: [], observedOutcomes: [] },
583
765
  };
584
766
  }
585
767
  let prepared: PreparedCall;
@@ -623,7 +805,11 @@ export async function evaluateCloudSemantic(input: SemanticInput, opts: CloudSem
623
805
  const timeout = AbortSignal.timeout(opts.timeoutMs ?? envNumber("FAILPROOFAI_JEV_TIMEOUT_MS", DEFAULT_JEV_TIMEOUT_MS));
624
806
  const signal = opts.signal ? AbortSignal.any([timeout, opts.signal]) : timeout;
625
807
  const response = await transport(request, signal);
626
- const reply = parseCloudReply(response.cloud);
808
+ const reply = parseCloudReply(response.cloud, opts.observedPolicies);
809
+ // The full outcomes of the watched checks the reply was vetted to have asked
810
+ // (`parseCloudReply` refuses the whole reply otherwise), for the per-check record.
811
+ const observedAsked = new Set(reply.observedAsked);
812
+ const observedOutcomes = parseObservedOutcomes(response.cloud).filter((o) => observedAsked.has(o.policy));
627
813
 
628
814
  // Only the machine's own groups can be dropped from its side; a name it
629
815
  // did not send is not its to act on.
@@ -636,7 +822,8 @@ export async function evaluateCloudSemantic(input: SemanticInput, opts: CloudSem
636
822
  // answers to demand. The local verdict is then an empty allow whatever the
637
823
  // answers — `decide` with nothing selected reads no per-policy answer, and
638
824
  // no warning can fire without one.
639
- const shortCircuit = kept.length === 0 && reply.asked.length === 0 && reply.verdict.outcomes.length === 0;
825
+ const shortCircuit =
826
+ kept.length === 0 && reply.asked.length === 0 && reply.verdict.outcomes.length === 0 && reply.observedAsked.length === 0;
640
827
  const answers = shortCircuit ? presentAnswers(response) : readAnswers(expectedQuestions(request, prepared, dropped), response);
641
828
 
642
829
  const local =
@@ -664,6 +851,10 @@ export async function evaluateCloudSemantic(input: SemanticInput, opts: CloudSem
664
851
  asked: reply.asked,
665
852
  droppedLocal: [...dropped].map((name) => ({ name, packId: own.get(name)?.origin?.packId ?? null })),
666
853
  droppedCloud: reply.droppedCloud,
854
+ answers: cloudCheckAnswers(response, [...reply.asked, ...reply.observedAsked]),
855
+ observed: reply.observed,
856
+ observedAsked: reply.observedAsked,
857
+ observedOutcomes,
667
858
  },
668
859
  };
669
860
  } catch (err) {
@@ -236,7 +236,7 @@ import type { PolicyAuthority } from "../policy-types";
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
- import { JEV_MODEL_RE, JEV_REASON_REQUEST_CUT } from "../jev-activity";
239
+ import { JEV_MODEL_RE, JEV_REASON_REQUEST_CUT, type JevCheckRecord } from "../jev-activity";
240
240
 
241
241
  export type Decision = "allow" | "deny" | "instruct";
242
242
  export type JevMode = "observe" | "enforce";
@@ -287,6 +287,11 @@ export type JevReview =
287
287
  origin?: { packId: string; packVersion?: string };
288
288
  /** Semantic policies whose questions were in the request that was answered. */
289
289
  asked: readonly string[];
290
+ /**
291
+ * What each asked check concluded (`jevChecks` on the activity row).
292
+ * Recorded only; nothing here decides from it. Absent: not recorded.
293
+ */
294
+ checks?: readonly JevCheckRecord[];
290
295
  /**
291
296
  * Of `asked`, the ones whose answer lets a reviewable regex verdict be
292
297
  * cleared: `none`, `overridden`, or a deny the human's task softened to
@@ -330,6 +335,14 @@ export type JevReview =
330
335
  * / `request-cut`.
331
336
  */
332
337
  requestCut: boolean;
338
+ /**
339
+ * What the checks of an `observe` FailproofAI Cloud assignment would have
340
+ * done. Recorded on the row's `observed` list, never applied: they are in
341
+ * neither `asked` nor `notDenied`, so they clear nothing either.
342
+ */
343
+ observedCloud?: ReadonlyArray<ObservedCloudVerdict>;
344
+ /** The `observe` Cloud assignments' checks that were asked (names); never in `asked`. */
345
+ observedAsked?: readonly string[];
333
346
  /**
334
347
  * Null when nothing had to be sent (no semantic policy applied), or when
335
348
  * the answer came from the throttle's cache: a hit's ~0 ms is not a
@@ -358,6 +371,8 @@ export interface TwoTierReview {
358
371
  * network instead of holding every hook on the machine behind it.
359
372
  */
360
373
  releaseRegistry?: () => void;
374
+ /** The review goes to FailproofAI Cloud (its Jev mode asked), not a local `jev.json`. */
375
+ cloud?: boolean;
361
376
  /** Effective authority + the reviewing semantic policies (empty unless reviewable). */
362
377
  authorityOf(p: { name: string; authority?: PolicyAuthority; reviewedBy?: string[] }): {
363
378
  authority: PolicyAuthority;
@@ -380,6 +395,7 @@ export interface JevActivityFields {
380
395
  jevLatencyMs?: number;
381
396
  jevModel?: string;
382
397
  jevMode: JevMode;
398
+ jevChecks?: JevCheckRecord[];
383
399
  }
384
400
 
385
401
  /**
@@ -389,6 +405,14 @@ export interface JevActivityFields {
389
405
  * and pack policies already use, so FailproofAI Cloud's policy page counts it
390
406
  * with no change on its side.
391
407
  */
408
+ /** One observe Cloud assignment's would-have (see `JevReview.observedCloud`). */
409
+ export interface ObservedCloudVerdict {
410
+ cloudPolicyId: string;
411
+ cloudVersion: number;
412
+ decision: "deny" | "instruct";
413
+ reason: string;
414
+ }
415
+
392
416
  export interface ObserveVerdict {
393
417
  /** `semantic/<check>` — the name enforce mode would have attributed it to. */
394
418
  policyName: string;
@@ -518,6 +542,7 @@ export function combineTwoTier(
518
542
  ...(review.latencyMs !== null ? { jevLatencyMs: review.latencyMs } : {}),
519
543
  ...(review.model ? { jevModel: review.model } : {}),
520
544
  jevMode: mode,
545
+ ...(review.checks ? { jevChecks: [...review.checks] } : {}),
521
546
  };
522
547
 
523
548
  if (mode === "observe") {
@@ -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 observe mode only); a redirect would hand the
472
+ // (https, or loopback http); 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",
@@ -327,8 +327,7 @@ function credentialQueryProblem(url: URL): string | null {
327
327
  * An endpoint base URL: https, or http to a loopback host only (a local proxy).
328
328
  * No credentials in the URL — not as userinfo, not as a query parameter — and no
329
329
  * fragment, because a key belongs in the key field, where it is sent as a bearer
330
- * and never printed. `validateJevConfig` further accepts the loopback http form
331
- * only in observe mode.
330
+ * and never printed. Loopback http is accepted in every mode.
332
331
  */
333
332
  export function validateBaseUrl(raw: unknown): ValidationResult<string> {
334
333
  if (typeof raw !== "string" || raw.trim() === "") return { ok: false, problem: "baseUrl must be a non-empty string" };
@@ -483,14 +482,6 @@ export function providerHostConflict(provider: string, url: string): string | nu
483
482
  : null;
484
483
  }
485
484
 
486
- function isPlainHttp(url: string): boolean {
487
- try {
488
- return new URL(url).protocol === "http:";
489
- } catch {
490
- return false;
491
- }
492
- }
493
-
494
485
  /**
495
486
  * The origin a bearer token would be sent to: https, or http to a loopback host
496
487
  * — the same schemes `validateBaseUrl` admits. Null for anything else, so a
@@ -697,23 +688,11 @@ export function validateJevConfig(
697
688
  cfg.mode = mode;
698
689
  }
699
690
 
700
- // Plain http reaches only a loopback host (`validateBaseUrl`), and nothing
701
- // authenticates the server there: while the local proxy is down, any process
702
- // of this user — the agent being judged included — can bind its port and
703
- // answer "none" to every question. In enforce mode that answer clears
704
- // reviewable denies; in observe mode it changes nothing, and `off` sends
705
- // nothing at all, so enforce is the one mode it is refused in.
706
- if (cfg.baseUrl !== undefined && isPlainHttp(cfg.baseUrl) && cfg.mode === "enforce") {
707
- return {
708
- ok: false,
709
- problem:
710
- "plain http (to localhost) is accepted only with mode observe: in enforce mode Jev's answers can clear a deny, " +
711
- "and while the local proxy is down any process on this machine could take its port and answer. " +
712
- (cfg.provider === JEV_CLOUD_PROVIDER
713
- ? "Reconnect to an https FailproofAI Cloud URL (failproofai config --token <key> --url https://…), or keep mode observe"
714
- : "Use https, or mode observe"),
715
- };
716
- }
691
+ // Plain http reaches only a loopback host (`validateBaseUrl`) and is
692
+ // accepted in every mode, enforce included, so a local stack can be tested
693
+ // as it runs in production. Risk: nothing authenticates a loopback server,
694
+ // so while it is down any process of this user could bind its port and
695
+ // answer; use https wherever that matters.
717
696
 
718
697
  return { ok: true, value: cfg };
719
698
  }
@@ -73,6 +73,7 @@ import { DEFAULT_JEV_MODE, type JevConfig } from "./jev-config";
73
73
  import * as jevThrottle from "./jev-throttle";
74
74
  import { sessionProjectRoot } from "./session-root";
75
75
  import type { PolicyOutcome, SemanticInput } from "./types";
76
+ import { JEV_CHECKS_MAX, JEV_CHECK_PROBES_MAX, JEV_PROBE_ID_RE, round2, type JevCheckRecord } from "../jev-activity";
76
77
 
77
78
  /**
78
79
  * T5's throttle as this file calls it. §7 declares only
@@ -172,7 +173,14 @@ export interface JevCallContext {
172
173
  * to Cloud with this machine's id, and the deployment it was made under
173
174
  * scopes the answer cache (a new deployment can change Cloud's checks).
174
175
  */
175
- cloud?: { machineId: string; deployment: number | null; mode: "observe" | "enforce"; agent?: AgentIdentity | null };
176
+ cloud?: {
177
+ machineId: string;
178
+ deployment: number | null;
179
+ mode: "observe" | "enforce";
180
+ agent?: AgentIdentity | null;
181
+ /** Cloud policies whose Jev assignment here is `observe`: recorded, never applied. */
182
+ observedPolicies?: ReadonlyArray<string>;
183
+ };
176
184
  /**
177
185
  * A session pause is active. It suspends local policy, so no installed
178
186
  * pack's check is asked; FailproofAI Cloud's checks are exempt from a pause
@@ -248,6 +256,66 @@ export function fallbackCode(reason: string): string {
248
256
  return /^[a-z0-9][a-z0-9-]{0,63}$/.test(code) ? code : "error";
249
257
  }
250
258
 
259
+ /** Why an outcome ended where it did (`JevCheckRecord.why`), from its own fields. */
260
+ function checkWhy(o: PolicyOutcome): JevCheckRecord["why"] {
261
+ switch (o.verdict) {
262
+ case "none":
263
+ // Both deciders: under `fire` nothing fired; at or over it only the exemption stops it.
264
+ return o.evidence < DEFAULT_THRESHOLDS_V1.fire ? "not-detected" : "exempt";
265
+ case "overridden":
266
+ return o.intent === "op-requested" ? "op-requested" : o.intent === "task-step" ? "task-step" : "user-asked";
267
+ case "deny":
268
+ return o.escalatedByInjection ? "injection" : "fired";
269
+ default:
270
+ if (o.intent === "downgraded-task-step") return "downgraded";
271
+ return o.targetScanIncomplete ? "scan-incomplete" : "fired";
272
+ }
273
+ }
274
+
275
+ /** Who declared a check, as a registered-name prefix: `cloud/<id>@<v>` or `pack/<id>@<v>`. */
276
+ function checkPolicy(o: PolicyOutcome): string | undefined {
277
+ const packId = o.origin?.packId;
278
+ if (!packId) return undefined;
279
+ const at = o.origin?.packVersion ? `@${o.origin.packVersion}` : "";
280
+ return packId.startsWith("cloud:") ? `cloud/${packId.slice("cloud:".length)}${at}` : `pack/${packId}${at}`;
281
+ }
282
+
283
+ /**
284
+ * Every asked check's answer, for the activity row (`jevChecks`): verdict,
285
+ * why, evidence, exempt and each probe's probability. `answers` holds the
286
+ * machine's own answers and Cloud's for its checks (`<check>.<probe>`).
287
+ */
288
+ export function checkRecords(
289
+ outcomes: ReadonlyArray<PolicyOutcome>,
290
+ answers: Readonly<Record<string, number>>,
291
+ watched: ReadonlyArray<PolicyOutcome> = [],
292
+ ): JevCheckRecord[] {
293
+ const all = [...outcomes.map((o) => ({ o, watched: false })), ...watched.map((o) => ({ o, watched: true }))];
294
+ return all.slice(0, JEV_CHECKS_MAX).map(({ o, watched: isWatched }) => {
295
+ const probes: Record<string, number> = {};
296
+ const prefix = `${o.policy}.`;
297
+ for (const [id, p] of Object.entries(answers)) {
298
+ if (Object.keys(probes).length >= JEV_CHECK_PROBES_MAX) break;
299
+ if (!id.startsWith(prefix)) continue;
300
+ const part = id.slice(prefix.length);
301
+ // `exempt` has its own field; `user_asked` (v0) is a consent question, not a probe.
302
+ if (part === "exempt" || part === "user_asked" || !JEV_PROBE_ID_RE.test(part)) continue;
303
+ probes[part] = round2(p);
304
+ }
305
+ const policy = checkPolicy(o);
306
+ return {
307
+ check: o.policy,
308
+ ...(policy ? { policy } : {}),
309
+ verdict: o.verdict,
310
+ why: checkWhy(o),
311
+ p: round2(o.evidence),
312
+ ...(o.exempt !== null ? { exempt: round2(o.exempt) } : {}),
313
+ ...(Object.keys(probes).length > 0 ? { probes } : {}),
314
+ ...(isWatched ? { watched: true as const } : {}),
315
+ };
316
+ });
317
+ }
318
+
251
319
  /**
252
320
  * @param cached - the answer came from T5's cache, not the provider: it is
253
321
  * applied like any other, but its ~0 ms is not a latency (`latencyMs`
@@ -269,7 +337,8 @@ export function toReview(outcome: SemanticOutcome, cached = false): JevReview {
269
337
  // outcome is what clears a `both` policy's regex half. One it cannot vouch
270
338
  // for — or a Cloud-origin outcome with no Cloud reply at all — means the
271
339
  // picture is not one to clear anything on: the regex decides alone.
272
- const cloudAsked = "cloud" in outcome ? new Set((outcome as CloudSemanticOutcome & { status: "ok" }).cloud.asked) : null;
340
+ const cloudSummary = "cloud" in outcome ? (outcome as CloudSemanticOutcome & { status: "ok" }).cloud : null;
341
+ const cloudAsked = cloudSummary ? new Set(cloudSummary.asked) : null;
273
342
  const unvouched = outcomes.some((o) => isCloudOutcome(o) && !(cloudAsked?.has(o.policy) ?? false));
274
343
  if (unvouched) return { kind: "fallback", reason: "malformed", latencyMs, model: null };
275
344
  // `null` when the injection probe was not in the request, which now means
@@ -280,9 +349,14 @@ export function toReview(outcome: SemanticOutcome, cached = false): JevReview {
280
349
  // not downgrade from a deny (see "A check that fired without consent keeps
281
350
  // the floor" in `combine.ts`).
282
351
  const warned = (o: (typeof outcomes)[number]) => o.verdict === "instruct" && o.intent !== "downgraded-task-step";
352
+ const cloudAnswers = cloudSummary?.answers ?? {};
353
+ const watched = cloudSummary?.observedOutcomes ?? [];
283
354
  return {
284
355
  kind: "answered",
285
356
  decision: outcome.verdict.decision,
357
+ // What each asked check concluded, for the activity row. `[]` when the
358
+ // request carried no check (globals only, or Cloud's short-circuit).
359
+ checks: sent ? checkRecords(outcomes, { ...outcome.answers, ...cloudAnswers }, watched) : [],
286
360
  reason: outcome.verdict.reason,
287
361
  policyName: jevPolicyName(outcome),
288
362
  ...(jevDecider(outcome)?.origin ? { origin: jevDecider(outcome)?.origin } : {}),
@@ -324,6 +398,18 @@ export function toReview(outcome: SemanticOutcome, cached = false): JevReview {
324
398
  requestCut: outcome.requestCut && sent,
325
399
  latencyMs,
326
400
  model,
401
+ // An `observe` Cloud assignment's checks: asked, recorded, never applied.
402
+ ...(sent && cloudSummary?.observedAsked?.length ? { observedAsked: cloudSummary.observedAsked } : {}),
403
+ ...(cloudSummary?.observed?.length
404
+ ? {
405
+ observedCloud: cloudSummary.observed.map((o) => ({
406
+ cloudPolicyId: o.cloudPolicyId,
407
+ cloudVersion: o.cloudVersion,
408
+ decision: o.decision,
409
+ reason: o.reason,
410
+ })),
411
+ }
412
+ : {}),
327
413
  };
328
414
  }
329
415
 
@@ -348,6 +434,7 @@ export function startJevReview(cfg: JevConfig, call: JevCallContext): TwoTierRev
348
434
  if (!settled) controller.abort();
349
435
  },
350
436
  authorityOf,
437
+ ...(call.cloud ? { cloud: true } : {}),
351
438
  });
352
439
 
353
440
  let route: ReturnType<typeof transportForConfig>;
@@ -429,6 +516,7 @@ export function startJevReview(cfg: JevConfig, call: JevCallContext): TwoTierRev
429
516
  ...options,
430
517
  machineId: cloud.machineId,
431
518
  agent: cloud.agent,
519
+ observedPolicies: cloud.observedPolicies ?? [],
432
520
  // Paused: no installed pack's check is sent, only the globals, and
433
521
  // Cloud's own checks are selected and decided on Cloud as ever.
434
522
  ...(call.localPaused ? { policies: [] } : {}),