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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/_0tovk6q0miud6._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3ycqv17cl._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68lnh2daur._.js +2 -2
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4vkpxxu7._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_0_em82s16zfym._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00l2u6j86cb8y._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08ch3staq3ejw._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pwj2h2efe5f-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-ey9fgesfbc0._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15usvs-ms-0hf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gb3_92zvk5x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1avi2kypusyb_._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1r7drlqli-ado._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0b78b0j8iyv6l._.js → [root-of-the-server]__1vofkb2oq7p7e._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0bseae9u7g98r._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0eoura3_9gjb-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0mouhaflye1ro._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_1a3tubds7s7ow._.js → _0ouu886szh6yj._.js} +4 -4
- package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1fw0_nzkae5ao._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_20hn4gn87nsjc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_components_project-list_tsx_1ke6anr0vt64h._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
- 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
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0janj0i6dqko7._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_0agdmptetetdt._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_0mdwgjisv4fub._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589gp4p60j._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me658zqmh_._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/preview-props.json +3 -3
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
- package/.next/standalone/.next/static/chunks/{2-tf6_7u623kd.js → 0ay__-d2h1xb5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18433qz-8njv3.js → 0tbm3vjm62h-2.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1b32-afqacaen.js → 1c_jf6g5wnslk.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0_zfad4co-t2z.js → 1my3-5p47t7r-.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0jutamwqz8pg5.js → 20py8k1gd_i1m.js} +1 -1
- package/.next/standalone/.next/static/chunks/21hckpmsc4g70.js +6 -0
- package/.next/standalone/.next/static/chunks/2hmex93ppj1ke.js +1 -0
- package/.next/standalone/.next/static/chunks/{42e8zab314654.js → 2o6d37j425uci.js} +1 -1
- package/.next/standalone/.next/static/chunks/40oworn_-x12p.js +1 -0
- package/.next/standalone/.next/static/chunks/{24w3x2da9sk49.js → 42xptz88mtq3x.js} +1 -1
- package/.next/standalone/app/actions/update-jev-config.ts +5 -3
- package/.next/standalone/app/settings/jev-panel.tsx +1 -1
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +9 -18
- package/.next/standalone/fp-cloud-cli/README.md +7 -9
- package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1 -27
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +12 -216
- package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +7 -53
- package/.next/standalone/fp-cloud-cli/fp_cli/output.py +2 -71
- package/.next/standalone/fp-cloud-cli/skill/SKILL.md +1 -1
- package/.next/standalone/fp-cloud-cli/skill/references/commands.md +6 -7
- package/.next/standalone/fp-cloud-cli/tests/test_cloud_jev.py +32 -197
- package/.next/standalone/hermes-plugin/__init__.py +11 -0
- package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
- package/.next/standalone/package.json +11 -10
- package/.next/standalone/sdk/python/uv.lock +31 -8
- package/.next/standalone/server.js +1 -1
- package/dist/cli.mjs +1686 -612
- package/dist/worker.mjs +354 -49
- package/hermes-plugin/__init__.py +11 -0
- package/hermes-plugin/plugin.yaml +1 -1
- package/package.json +11 -10
- package/src/audit/open-browser.ts +24 -7
- package/src/hooks/agent-roster.ts +9 -1
- package/src/hooks/agent-skill.ts +203 -0
- package/src/hooks/cloud-connection.ts +27 -51
- package/src/hooks/cloud-introspect.ts +5 -2
- package/src/hooks/cloud-login.ts +248 -0
- package/src/hooks/cloud-managed-policies.ts +54 -4
- package/src/hooks/codex-system-hooks.ts +10 -4
- package/src/hooks/config-status.ts +9 -3
- package/src/hooks/configure-wizard.ts +356 -32
- package/src/hooks/delivery-health.ts +47 -2
- package/src/hooks/effective-reviewers.ts +14 -0
- package/src/hooks/fp-config.ts +19 -0
- package/src/hooks/handler.ts +21 -2
- package/src/hooks/hook-activity-store.ts +7 -1
- package/src/hooks/integrations.ts +18 -3
- package/src/hooks/jev-activity.ts +117 -1
- package/src/hooks/jev-cli.ts +7 -9
- package/src/hooks/manager.ts +80 -46
- package/src/hooks/policy-evaluator.ts +17 -0
- package/src/hooks/policy-reviewability.ts +4 -2
- package/src/hooks/semantic/cloud-jev.ts +219 -28
- package/src/hooks/semantic/combine.ts +26 -1
- package/src/hooks/semantic/jev-client.ts +1 -1
- package/src/hooks/semantic/jev-config.ts +6 -27
- package/src/hooks/semantic/jev-review.ts +90 -2
- package/src/hooks/tui.ts +280 -0
- package/src/hooks/uninstall-cli.ts +12 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0588st-tpiuia._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_128ebxr83nk6g._.js +0 -3
- package/.next/standalone/.next/static/chunks/114hbolvrv94e.js +0 -1
- package/.next/standalone/.next/static/chunks/1p-hsi58i-6h0.js +0 -6
- package/.next/standalone/.next/static/chunks/3-pku9qwv1yfu.js +0 -1
- /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_clientMiddlewareManifest.js +0 -0
- /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):
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (!
|
|
426
|
-
if (v.
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
437
|
-
if (!askedSet.has(o.policy)) malformed(
|
|
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(
|
|
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 (
|
|
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
|
|
448
|
-
reason
|
|
449
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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`)
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
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?: {
|
|
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
|
|
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: [] } : {}),
|