failproofai 1.0.12-beta.0 → 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 +22 -22
- 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 +1 -1
- 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 +4 -4
- 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 +7 -7
- 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/{[externals]__0-ha5ydqq0mws._.js → [externals]__0vt9i6fqinjne._.js} +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__20xe05hm5u8_1._.js → [root-of-the-server]__0f_4g-vazrvxv._.js} +1 -1
- package/.next/standalone/.next/server/chunks/_0hf5q4xzyg40j._.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/_19011-u44143j._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68lnh2daur._.js +2 -2
- package/.next/standalone/.next/server/chunks/{_0kc-piux3hady._.js → _1y590mjzpkbos._.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 +5 -0
- package/.next/standalone/.next/server/chunks/src_hooks_builtin-policies_ts_1yq5ybgx1v98e._.js +3 -0
- package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3h94wnn._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_daemon-client_ts_0il_yu1zc0291._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn_4twyh._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ts0fg5yih1c1._.js → [root-of-the-server]__00l2u6j86cb8y._.js} +2 -2
- 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]__1_xbg2kx30wp-._.js → [root-of-the-server]__0naevg4bcoh54._.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]__0vcyntk4snf0f._.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/{_1gb0ifpkowwub._.js → _0ouu886szh6yj._.js} +10 -9
- 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/{_1ve8y151ua7qx._.js → _1fw0_nzkae5ao._.js} +2 -2
- 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_00jxj8foxqz5t.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_06kzv9ddabn3_._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_0agdmptetetdt._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_0mdwgjisv4fub._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4l1tzrm._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_daemon-client_ts_0k41_i7jg6ndv._.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/{0pjxnp927y7_j.js → 0ay__-d2h1xb5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2znt1784j9gd9.js → 0tbm3vjm62h-2.js} +1 -1
- package/.next/standalone/.next/static/chunks/1c_jf6g5wnslk.js +1 -0
- package/.next/standalone/.next/static/chunks/{3tn1bzk094e8e.js → 1my3-5p47t7r-.js} +1 -1
- package/.next/standalone/.next/static/chunks/20py8k1gd_i1m.js +1 -0
- 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/2o6d37j425uci.js +1 -0
- package/.next/standalone/.next/static/chunks/40oworn_-x12p.js +1 -0
- package/.next/standalone/.next/static/chunks/{2qz5piiir-qqm.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 +32 -0
- package/.next/standalone/fp-cloud-cli/README.md +40 -1
- package/.next/standalone/fp-cloud-cli/fp_cli/client.py +91 -12
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +78 -16
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +84 -18
- package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +124 -6
- package/.next/standalone/fp-cloud-cli/fp_cli/models.py +89 -6
- package/.next/standalone/fp-cloud-cli/fp_cli/output.py +189 -20
- package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +8 -5
- package/.next/standalone/fp-cloud-cli/skill/SKILL.md +2 -2
- package/.next/standalone/fp-cloud-cli/skill/references/commands.md +14 -7
- package/.next/standalone/fp-cloud-cli/tests/test_cloud_jev.py +527 -0
- package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +46 -0
- package/.next/standalone/fp-cloud-cli/tests/test_output.py +23 -0
- package/.next/standalone/hermes-plugin/README.md +6 -4
- package/.next/standalone/hermes-plugin/__init__.py +27 -0
- package/.next/standalone/hermes-plugin/client.py +5 -1
- 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/bin/failproofai.mjs +17 -1
- package/dist/cli.mjs +3507 -1250
- package/dist/worker.mjs +1779 -169
- package/hermes-plugin/README.md +6 -4
- package/hermes-plugin/__init__.py +27 -0
- package/hermes-plugin/client.py +5 -1
- package/hermes-plugin/plugin.yaml +1 -1
- package/package.json +11 -10
- package/src/audit/open-browser.ts +6 -7
- package/src/hooks/agent-roster.ts +180 -0
- package/src/hooks/agent-skill.ts +203 -0
- package/src/hooks/agent-targets.ts +60 -0
- package/src/hooks/cloud-connection.ts +91 -45
- package/src/hooks/cloud-enrollment-cli.ts +7 -4
- package/src/hooks/cloud-login.ts +6 -3
- package/src/hooks/cloud-managed-policies.ts +430 -20
- package/src/hooks/cloud-policy-errors.ts +481 -0
- package/src/hooks/codex-system-hooks.ts +10 -4
- package/src/hooks/config-status.ts +10 -4
- package/src/hooks/configure-wizard.ts +54 -26
- package/src/hooks/custom-hooks-loader.ts +26 -3
- package/src/hooks/daemon-client.ts +9 -4
- package/src/hooks/daemon-service.ts +2 -1
- package/src/hooks/delivery-health.ts +47 -2
- package/src/hooks/effective-reviewers.ts +36 -1
- package/src/hooks/fp-config.ts +19 -0
- package/src/hooks/fp-home.ts +6 -0
- package/src/hooks/handler.ts +255 -19
- package/src/hooks/health-probe.ts +15 -0
- package/src/hooks/hook-activity-store.ts +7 -1
- package/src/hooks/integrations.ts +36 -14
- package/src/hooks/jev-activity.ts +117 -1
- package/src/hooks/jev-cli.ts +196 -1
- package/src/hooks/jev-cloud-connection.ts +92 -0
- package/src/hooks/manager.ts +174 -34
- package/src/hooks/pack-failclosed.ts +2 -0
- package/src/hooks/pack-manifest.ts +8 -1
- package/src/hooks/policy-evaluator.ts +22 -1
- package/src/hooks/policy-registry.ts +3 -2
- package/src/hooks/policy-reviewability.ts +23 -5
- package/src/hooks/semantic/cloud-jev-health.ts +227 -0
- package/src/hooks/semantic/cloud-jev.ts +868 -0
- package/src/hooks/semantic/combine.ts +26 -1
- package/src/hooks/semantic/jev-client.ts +19 -2
- package/src/hooks/semantic/jev-config.ts +179 -30
- package/src/hooks/semantic/jev-review.ts +217 -10
- package/src/hooks/semantic/pack-policies.ts +2 -1
- package/src/hooks/semantic/types.ts +8 -0
- package/src/hooks/session-pause-cli.ts +1 -1
- package/src/hooks/tui.ts +13 -4
- package/src/hooks/uninstall-cli.ts +12 -1
- package/src/hooks/worker-server.ts +5 -1
- package/.next/standalone/.next/server/chunks/src_hooks_1aveq0uhs2t1b._.js +0 -5
- package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7cyqlb2._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo55j16x._.js +0 -5
- package/.next/standalone/.next/static/chunks/0a-ccsp3xmypz.js +0 -1
- package/.next/standalone/.next/static/chunks/13jv-xvabeb62.js +0 -6
- package/.next/standalone/.next/static/chunks/2ank2u3apy8m6.js +0 -1
- package/.next/standalone/.next/static/chunks/2meqb9kygrncb.js +0 -1
- package/.next/standalone/.next/static/chunks/2ohktp_-lkv3x.js +0 -1
- package/.next/standalone/.next/static/chunks/2py0eldhwz8ay.js +0 -1
- /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FailproofAI Cloud Jev (CONTRACT C10): one tool call in, one verdict out, with
|
|
3
|
+
* the checks FailproofAI Cloud deployed to this machine decided ON CLOUD.
|
|
4
|
+
*
|
|
5
|
+
* Jev policies, and the Jev half of `both` policies, never reach the machine.
|
|
6
|
+
* When Cloud sets this machine's Jev mode to `observe` or `enforce`, every
|
|
7
|
+
* gated tool call is sent to `POST /enforcement/v1/jev/systemone` on the Cloud
|
|
8
|
+
* Jev credential as today's Jev request — the machine's own questions, which
|
|
9
|
+
* are the global intent questions (ALWAYS, even with no installed pack's check
|
|
10
|
+
* applying) plus its installed packs' per-policy questions — and a `cloud`
|
|
11
|
+
* block of tool-call metadata (`buildCloudBlock`). Cloud strips the block,
|
|
12
|
+
* adds this machine's deployed Cloud checks, asks TypeSafe once, decides its
|
|
13
|
+
* own checks with a port of `decide` / `decideV1`, and replies with every
|
|
14
|
+
* answer plus `cloud.verdict`.
|
|
15
|
+
*
|
|
16
|
+
* Here the machine decides its pack checks from the answers exactly as the
|
|
17
|
+
* local path does (minus any group Cloud dropped, `droppedLocal`) and merges
|
|
18
|
+
* the two verdicts (`mergeCloudVerdict`). The merged verdict then goes to
|
|
19
|
+
* `combine.ts` unchanged, via `toReview` in `jev-review.ts`.
|
|
20
|
+
*
|
|
21
|
+
* Never throws, and never guesses: anything short of a complete, well-formed
|
|
22
|
+
* reply — no transport, timeout, HTTP error, a reply without a valid `cloud`
|
|
23
|
+
* block, a missing answer — is `degraded`, and the combine keeps the regex
|
|
24
|
+
* result for that call (a `both` policy's regex half stays hard).
|
|
25
|
+
*/
|
|
26
|
+
import { DEFAULT_JEV_MODEL, MAX_REQUEST_CHARS } from "./compile";
|
|
27
|
+
import { DEFAULT_THRESHOLDS, decide, decideV1, everyTargetNamed, partlyNamed, scanTargets, type TargetScan } from "./decide";
|
|
28
|
+
import { redactSecrets } from "./envelope";
|
|
29
|
+
import { classifyTool } from "./facts";
|
|
30
|
+
import { DEFAULT_JEV_TIMEOUT_MS, prepareSemantic, type PreparedCall, type SemanticOptions, type SemanticOutcome } from "./evaluator";
|
|
31
|
+
import { JevError, readAnswers, type JevTransport } from "./jev-client";
|
|
32
|
+
import type { JevProviderKind } from "./jev-config";
|
|
33
|
+
import { INJECTION_PROBE, SCOPE_PROBE, TASK_PROBES } from "./policies";
|
|
34
|
+
import { redactSecretsDetailed } from "./redact";
|
|
35
|
+
import type {
|
|
36
|
+
Facts,
|
|
37
|
+
IntentMode,
|
|
38
|
+
JevRequest,
|
|
39
|
+
JevResponse,
|
|
40
|
+
NoulQuestion,
|
|
41
|
+
PolicyOutcome,
|
|
42
|
+
SemanticInput,
|
|
43
|
+
SemanticPolicy,
|
|
44
|
+
SemanticVerdict,
|
|
45
|
+
} from "./types";
|
|
46
|
+
import type { AgentIdentity } from "../agent-targets";
|
|
47
|
+
|
|
48
|
+
/** The `cloud` block's version (CONTRACT C10.3). */
|
|
49
|
+
export const CLOUD_BLOCK_VERSION = 1;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How many characters of `targetScan.groups` (serialized) are sent. The one
|
|
53
|
+
* part of the block that grows with the tool input: an MCP call with thousands
|
|
54
|
+
* of string fields, or a huge command whose scan fell back to its raw words.
|
|
55
|
+
* Past it a prefix of the groups goes with `complete: false`, which is strictly
|
|
56
|
+
* safer — an incomplete scan clears nothing (`decide.ts`).
|
|
57
|
+
*/
|
|
58
|
+
export const MAX_TARGET_SCAN_CHARS = 64_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The block's serialized UTF-8 bytes may not exceed this: Cloud answers a
|
|
62
|
+
* larger one with a 400 (256 KiB there), and a refused call is a fallback.
|
|
63
|
+
* Kept a little under Cloud's cap. The rest of the block is bounded by the
|
|
64
|
+
* envelope's own caps (three human turns of at most `MAX_USER_MESSAGE_CHARS`,
|
|
65
|
+
* one capped agent message, the capped facts); if it still does not fit, the
|
|
66
|
+
* human's turns are left out — the stricter direction, since no consent can
|
|
67
|
+
* then be read.
|
|
68
|
+
*/
|
|
69
|
+
export const MAX_CLOUD_BLOCK_BYTES = 240_000;
|
|
70
|
+
|
|
71
|
+
const blockBytes = (block: CloudJevBlock): number => Buffer.byteLength(JSON.stringify(block), "utf8");
|
|
72
|
+
|
|
73
|
+
/** What a secret word in a target group becomes: no human's words can contain it. */
|
|
74
|
+
export const REDACTED_TARGET_WORD = "\u0000redacted";
|
|
75
|
+
|
|
76
|
+
/** The `cloud` block of a request (CONTRACT C10.3). */
|
|
77
|
+
export interface CloudJevBlock {
|
|
78
|
+
v: 1 | 2;
|
|
79
|
+
machineId: string;
|
|
80
|
+
agent?: AgentIdentity;
|
|
81
|
+
intentMode: IntentMode;
|
|
82
|
+
facts: Facts;
|
|
83
|
+
targetScan: { groups: string[][]; complete: boolean };
|
|
84
|
+
userSaid: string[];
|
|
85
|
+
agentLastMessage: string | null;
|
|
86
|
+
userSaidCut: boolean;
|
|
87
|
+
/** The machine's own per-policy question groups, in the order Cloud drops them from (last first). */
|
|
88
|
+
localPolicies: string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** FailproofAI Cloud's reply block, validated. Its outcomes carry a pack-shaped `cloud:<id>` origin. */
|
|
92
|
+
export interface CloudJevReply {
|
|
93
|
+
/** The ENFORCED checks' verdict: an observe assignment's outcomes are in `observed`, not here. */
|
|
94
|
+
verdict: SemanticVerdict;
|
|
95
|
+
asked: string[];
|
|
96
|
+
droppedLocal: string[];
|
|
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;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What the Cloud review adds to a `SemanticOutcome`, for the verdict log and the budget report. */
|
|
118
|
+
export interface CloudReplySummary {
|
|
119
|
+
asked: string[];
|
|
120
|
+
/** The machine's own groups Cloud dropped, with the pack that declared each (null when none did). */
|
|
121
|
+
droppedLocal: Array<{ name: string; packId: string | null }>;
|
|
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[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type CloudSemanticOutcome =
|
|
140
|
+
| (Extract<SemanticOutcome, { status: "ok" }> & { cloud: CloudReplySummary })
|
|
141
|
+
| Extract<SemanticOutcome, { status: "degraded" }>;
|
|
142
|
+
|
|
143
|
+
export interface CloudSemanticOptions extends SemanticOptions {
|
|
144
|
+
/** This machine's FailproofAI Cloud machine id, from `credentials.json`. */
|
|
145
|
+
machineId: string;
|
|
146
|
+
/** Explicit null = v2 request with unresolved identity (no scoped checks). */
|
|
147
|
+
agent?: AgentIdentity | null;
|
|
148
|
+
/** Cloud policy ids whose Jev assignment on this machine is `observe` (`active.json` `jevPolicies`). */
|
|
149
|
+
observedPolicies?: ReadonlyArray<string>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── The request ──────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function noul(probe: { instructions: string; criteria?: { true: string; false: string } }): NoulQuestion {
|
|
155
|
+
return {
|
|
156
|
+
type: "noul",
|
|
157
|
+
instructions: probe.instructions,
|
|
158
|
+
...(probe.criteria ? { criteria: { ...probe.criteria } } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The global intent questions a Cloud call ALWAYS carries, whatever the
|
|
164
|
+
* machine's own checks are — Cloud's checks are decided on them:
|
|
165
|
+
*
|
|
166
|
+
* - `injection`, always (`compile.ts` asks it on every call it sends);
|
|
167
|
+
* - v1: the three task probes whenever a human message was recorded, as
|
|
168
|
+
* `compileRequest` asks them;
|
|
169
|
+
* - v0: `scope` whenever a human message was recorded. `compileRequest` asks
|
|
170
|
+
* it only when a SELECTED policy may be overridden, and the machine cannot
|
|
171
|
+
* know whether a Cloud check may be — without it no Cloud check could ever
|
|
172
|
+
* be cleared in v0. `decide` reads `scope` only inside the override branch,
|
|
173
|
+
* so the extra question changes no local verdict.
|
|
174
|
+
*/
|
|
175
|
+
export function cloudGlobalQuestions(intent: IntentMode, userSaidCount: number): Record<string, NoulQuestion> {
|
|
176
|
+
const questions: Record<string, NoulQuestion> = { [INJECTION_PROBE.id]: noul(INJECTION_PROBE) };
|
|
177
|
+
if (userSaidCount > 0) {
|
|
178
|
+
if (intent === "v1") for (const probe of TASK_PROBES) questions[probe.id] = noul(probe);
|
|
179
|
+
else questions[SCOPE_PROBE.id] = noul(SCOPE_PROBE);
|
|
180
|
+
}
|
|
181
|
+
return questions;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* How much of the tool input's text is searched for secrets. The groups sent
|
|
186
|
+
* are cut at {@link MAX_TARGET_SCAN_CHARS} and come from the text in order
|
|
187
|
+
* (the shell scan itself reads far less), so this covers every word that can
|
|
188
|
+
* be sent, while a 1 MB heredoc does not cost the hook a second full pass.
|
|
189
|
+
*/
|
|
190
|
+
const MAX_SECRET_SCAN_CHARS = 4 * MAX_TARGET_SCAN_CHARS;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Secret-shaped strings in the text `scanTargets` reads, lowercased like its
|
|
194
|
+
* words: the command, or — for any other tool — the string values it takes a
|
|
195
|
+
* target from (the same ≤300-character, non-content fields).
|
|
196
|
+
*/
|
|
197
|
+
function secretWords(toolInput: Record<string, unknown>): string[] {
|
|
198
|
+
const text = (
|
|
199
|
+
typeof toolInput.command === "string"
|
|
200
|
+
? toolInput.command
|
|
201
|
+
: Object.entries(toolInput)
|
|
202
|
+
.filter(([key, v]) => typeof v === "string" && v.length <= 300 && !/content|old_string|new_string|body|text|prompt/i.test(key))
|
|
203
|
+
.map(([, v]) => v as string)
|
|
204
|
+
.join("\n")
|
|
205
|
+
).slice(0, MAX_SECRET_SCAN_CHARS);
|
|
206
|
+
try {
|
|
207
|
+
return redactSecretsDetailed(text, { blunt: false })
|
|
208
|
+
.found.map((s) => s.toLowerCase())
|
|
209
|
+
.filter((s) => s.length > 0);
|
|
210
|
+
} catch {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `scanTargets(toolInput)` as the `cloud` block carries it: each group's Set as
|
|
217
|
+
* a sorted array, `targets` left out (it is the groups' union).
|
|
218
|
+
*
|
|
219
|
+
* Two changes, both in the stricter direction only:
|
|
220
|
+
*
|
|
221
|
+
* - A word that is (part of) a secret the narrow redactor finds in the tool
|
|
222
|
+
* input is replaced by {@link REDACTED_TARGET_WORD}, which no human's words
|
|
223
|
+
* can contain. The envelope already keeps that secret out of what Jev reads;
|
|
224
|
+
* this keeps it out of the metadata too. The group is kept, so the call still
|
|
225
|
+
* names as many targets — one the human can no longer be matched against.
|
|
226
|
+
* - Past {@link MAX_TARGET_SCAN_CHARS}, a prefix of the groups with
|
|
227
|
+
* `complete: false`: an incomplete scan clears nothing.
|
|
228
|
+
*/
|
|
229
|
+
export function cloudTargetScan(toolInput: Record<string, unknown>, scan: TargetScan = scanTargets(toolInput)): CloudJevBlock["targetScan"] {
|
|
230
|
+
const secrets = scan.groups.length > 0 ? secretWords(toolInput) : [];
|
|
231
|
+
const hidden = (word: string): boolean =>
|
|
232
|
+
secrets.some((s) => s === word || (word.length >= 8 && s.includes(word)) || (s.length >= 8 && word.includes(s)));
|
|
233
|
+
const groups: string[][] = [];
|
|
234
|
+
let chars = 2;
|
|
235
|
+
let complete = scan.complete;
|
|
236
|
+
for (const group of scan.groups) {
|
|
237
|
+
const words = [...new Set([...group].map((w) => (hidden(w) ? REDACTED_TARGET_WORD : w)))].sort();
|
|
238
|
+
const cost = JSON.stringify(words).length + 1;
|
|
239
|
+
if (chars + cost > MAX_TARGET_SCAN_CHARS) {
|
|
240
|
+
complete = false;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
chars += cost;
|
|
244
|
+
groups.push(words);
|
|
245
|
+
}
|
|
246
|
+
return { groups, complete };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The machine's facts with their strings passed through the narrow redactor, as the envelope sends them. */
|
|
250
|
+
function cloudFacts(facts: Facts): Facts {
|
|
251
|
+
const scrub = (s: string | null): string | null => (s === null ? null : redactSecrets(s, { blunt: false }).text);
|
|
252
|
+
return {
|
|
253
|
+
toolName: facts.toolName,
|
|
254
|
+
toolClass: facts.toolClass,
|
|
255
|
+
toolIsKnown: facts.toolIsKnown,
|
|
256
|
+
cwd: scrub(facts.cwd),
|
|
257
|
+
projectRoot: scrub(facts.projectRoot),
|
|
258
|
+
currentGitBranch: scrub(facts.currentGitBranch),
|
|
259
|
+
paths: facts.paths.map((p) => ({ asWritten: scrub(p.asWritten) ?? "", resolved: scrub(p.resolved) ?? "", relation: p.relation })),
|
|
260
|
+
permissionMode: facts.permissionMode,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The `cloud` block for a prepared call (CONTRACT C10.3): what the local
|
|
266
|
+
* decider reads, so Cloud decides its checks on the same evidence.
|
|
267
|
+
*
|
|
268
|
+
* - `facts`: the machine's `Facts`, strings redacted as the envelope's are.
|
|
269
|
+
* - `targetScan`: {@link cloudTargetScan}.
|
|
270
|
+
* - `userSaid`: the human turns the envelope CARRIES, uncut (`prepared.userSaid`,
|
|
271
|
+
* cleaned in v1), each with secrets redacted by the rules the envelope uses
|
|
272
|
+
* for everything that leaves the machine (`blunt`), and each redaction marker
|
|
273
|
+
* then replaced by {@link REDACTED_SAID}, which has no letters: a marker's
|
|
274
|
+
* label ("GitHub personal access token", "assigned secret") would otherwise
|
|
275
|
+
* NAME a target the human never named.
|
|
276
|
+
* - `agentLastMessage`: the string the envelope SENT (v1), null in v0.
|
|
277
|
+
* - `userSaidCut`, `intentMode`: as the local decider takes them.
|
|
278
|
+
* - `localPolicies`: the names of this call's own per-policy question groups.
|
|
279
|
+
*
|
|
280
|
+
* Redaction changes the words Cloud's target checks compare, and a substring
|
|
281
|
+
* check can flip either way (a hidden target word no longer matches; a hidden
|
|
282
|
+
* human word no longer names one). So the checks Cloud runs on the block are
|
|
283
|
+
* run here on both the block and the unredacted data ({@link namingReadings});
|
|
284
|
+
* where they differ, the scan is sent `complete: false`, which withholds every
|
|
285
|
+
* clear and every softening of an overridable check — Cloud is then stricter
|
|
286
|
+
* than the local decider, never laxer.
|
|
287
|
+
*/
|
|
288
|
+
export function buildCloudBlock(prepared: PreparedCall, input: SemanticInput, machineId: string, agent?: AgentIdentity | null): CloudJevBlock {
|
|
289
|
+
const scan = scanTargets(input.toolInput);
|
|
290
|
+
const block: CloudJevBlock = {
|
|
291
|
+
v: agent === undefined ? CLOUD_BLOCK_VERSION : 2,
|
|
292
|
+
machineId,
|
|
293
|
+
...(agent ? { agent } : {}),
|
|
294
|
+
intentMode: prepared.intent,
|
|
295
|
+
facts: cloudFacts(prepared.facts),
|
|
296
|
+
targetScan: cloudTargetScan(input.toolInput, scan),
|
|
297
|
+
userSaid: prepared.userSaid.map((turn) => redactSecrets(turn, { blunt: true }).text.replace(REDACTION_MARKER, REDACTED_SAID)),
|
|
298
|
+
agentLastMessage: prepared.agentLastMessage,
|
|
299
|
+
userSaidCut: prepared.userSaidCut,
|
|
300
|
+
localPolicies: prepared.selected.map((p) => p.name),
|
|
301
|
+
};
|
|
302
|
+
// Bounded by the caps above in practice; this is the floor under them. The
|
|
303
|
+
// human's turns go first, then the target scan; each time the scan is marked
|
|
304
|
+
// incomplete, which clears nothing. `[""]`, not `[]`: it names nothing, but
|
|
305
|
+
// it still says a human spoke, so Cloud's beyond-the-task flag (which needs
|
|
306
|
+
// one) is not switched off by the size of the call.
|
|
307
|
+
if (blockBytes(block) > MAX_CLOUD_BLOCK_BYTES) {
|
|
308
|
+
block.userSaid = prepared.userSaid.length > 0 ? [""] : [];
|
|
309
|
+
block.userSaidCut = false;
|
|
310
|
+
block.targetScan = { ...block.targetScan, complete: false };
|
|
311
|
+
}
|
|
312
|
+
if (blockBytes(block) > MAX_CLOUD_BLOCK_BYTES) {
|
|
313
|
+
block.targetScan = { groups: [], complete: false };
|
|
314
|
+
}
|
|
315
|
+
if (
|
|
316
|
+
block.targetScan.complete &&
|
|
317
|
+
namingReadings(block.intentMode, scan.groups, prepared.userSaid, prepared.agentLastMessage) !==
|
|
318
|
+
namingReadings(
|
|
319
|
+
block.intentMode,
|
|
320
|
+
block.targetScan.groups.map((g) => new Set(g)),
|
|
321
|
+
block.userSaid,
|
|
322
|
+
block.agentLastMessage,
|
|
323
|
+
)
|
|
324
|
+
) {
|
|
325
|
+
block.targetScan = { ...block.targetScan, complete: false };
|
|
326
|
+
}
|
|
327
|
+
return block;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A redaction marker (`<redacted:label>`, `redact.ts`) in the human's words. */
|
|
331
|
+
const REDACTION_MARKER = /<redacted:[^>]*>/g;
|
|
332
|
+
|
|
333
|
+
/** What a redaction marker in `userSaid` becomes: no letters, so it names no target. */
|
|
334
|
+
export const REDACTED_SAID = "\u0000";
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Everything Cloud's port of `decide` / `decideV1` reads from the target groups
|
|
338
|
+
* and the human's words, as one comparable string: the group count, whether a
|
|
339
|
+
* human spoke, `everyTargetNamed`, and (v1) `partlyNamed` — over the human's
|
|
340
|
+
* words plus, in v1, the agent message they replied to, exactly as the deciders
|
|
341
|
+
* build their evidence.
|
|
342
|
+
*/
|
|
343
|
+
function namingReadings(
|
|
344
|
+
intent: IntentMode,
|
|
345
|
+
groups: Set<string>[],
|
|
346
|
+
userSaid: ReadonlyArray<string>,
|
|
347
|
+
agentLastMessage: string | null,
|
|
348
|
+
): string {
|
|
349
|
+
const scan: TargetScan = { targets: new Set(), groups, complete: true };
|
|
350
|
+
const said = intent === "v1" && agentLastMessage ? [...userSaid, agentLastMessage] : userSaid;
|
|
351
|
+
return [groups.length, userSaid.length > 0, everyTargetNamed(scan, said), intent === "v1" && partlyNamed(scan, said)].join();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The request sent to FailproofAI Cloud: today's, the global questions always, and the block. */
|
|
355
|
+
export function buildCloudRequest(prepared: PreparedCall, input: SemanticInput, machineId: string, agent?: AgentIdentity | null): JevRequest {
|
|
356
|
+
const own = prepared.compiled.request;
|
|
357
|
+
return {
|
|
358
|
+
model: own.model,
|
|
359
|
+
state: own.state,
|
|
360
|
+
questions: { ...own.questions, ...cloudGlobalQuestions(prepared.intent, prepared.userSaid.length) },
|
|
361
|
+
cloud: buildCloudBlock(prepared, input, machineId, agent) as unknown as Record<string, unknown>,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── The reply ────────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
/** A Cloud policy id, as `cloud-managed-policies.ts` accepts one. */
|
|
368
|
+
const POLICY_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
369
|
+
/** Longest check name accepted back; declarations are far shorter. */
|
|
370
|
+
const MAX_NAME_CHARS = 256;
|
|
371
|
+
/** Most outcomes / names accepted back: 24 declarations per policy leaves this far out of reach. */
|
|
372
|
+
const MAX_LIST = 4096;
|
|
373
|
+
/**
|
|
374
|
+
* Longest Cloud reason kept (review n8). Cloud builds it from bounded titles
|
|
375
|
+
* and guidance, so this is never reached by a working server; past it the
|
|
376
|
+
* reason is cut rather than refused, since refusing would drop Cloud's verdict.
|
|
377
|
+
*/
|
|
378
|
+
export const MAX_CLOUD_REASON_CHARS = 8_192;
|
|
379
|
+
|
|
380
|
+
function malformed(what: string): never {
|
|
381
|
+
throw new JevError("malformed", `FailproofAI Cloud's reply: ${what}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const isProbability = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1;
|
|
385
|
+
const isName = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= MAX_NAME_CHARS;
|
|
386
|
+
|
|
387
|
+
function nullableProbability(v: unknown, what: string): number | null {
|
|
388
|
+
if (v === null || v === undefined) return null;
|
|
389
|
+
if (!isProbability(v)) malformed(`${what} is not a probability`);
|
|
390
|
+
return v;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function names(v: unknown, what: string): string[] {
|
|
394
|
+
if (v === undefined || v === null) return [];
|
|
395
|
+
if (!Array.isArray(v) || v.length > MAX_LIST || !v.every(isName)) malformed(`${what} is not a list of names`);
|
|
396
|
+
return [...v];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const DECISIONS = new Set(["allow", "deny", "instruct"]);
|
|
400
|
+
const VERDICTS = new Set(["deny", "instruct", "overridden", "none"]);
|
|
401
|
+
const INTENTS = new Set(["op-requested", "task-step", "downgraded-task-step"]);
|
|
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
|
+
|
|
414
|
+
/** One Cloud outcome, rebuilt field by field so nothing but these fields crosses over. */
|
|
415
|
+
function parseOutcome(raw: unknown, i: number): ParsedCloudOutcome {
|
|
416
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) malformed(`outcome ${i} is not an object`);
|
|
417
|
+
const o = raw as Record<string, unknown>;
|
|
418
|
+
if (!isName(o.policy)) malformed(`outcome ${i} has no policy name`);
|
|
419
|
+
if (o.mode !== "deny" && o.mode !== "instruct") malformed(`outcome ${i} has no mode`);
|
|
420
|
+
if (!isProbability(o.evidence)) malformed(`outcome ${i} has no evidence`);
|
|
421
|
+
if (typeof o.targetNamedByUser !== "boolean" || typeof o.escalatedByInjection !== "boolean") {
|
|
422
|
+
malformed(`outcome ${i} is missing a flag`);
|
|
423
|
+
}
|
|
424
|
+
if (typeof o.verdict !== "string" || !VERDICTS.has(o.verdict)) malformed(`outcome ${i} has no verdict`);
|
|
425
|
+
if (o.intent !== undefined && (typeof o.intent !== "string" || !INTENTS.has(o.intent))) malformed(`outcome ${i} has an unknown intent`);
|
|
426
|
+
if (o.targetScanIncomplete !== undefined && o.targetScanIncomplete !== true) malformed(`outcome ${i} has a bad targetScanIncomplete`);
|
|
427
|
+
if (o.userCanOverride !== undefined && typeof o.userCanOverride !== "boolean") malformed(`outcome ${i} has a bad userCanOverride`);
|
|
428
|
+
const origin = o.origin as Record<string, unknown> | undefined;
|
|
429
|
+
if (
|
|
430
|
+
!origin ||
|
|
431
|
+
typeof origin !== "object" ||
|
|
432
|
+
typeof origin.cloudPolicyId !== "string" ||
|
|
433
|
+
!POLICY_ID_RE.test(origin.cloudPolicyId) ||
|
|
434
|
+
!Number.isSafeInteger(origin.cloudVersion) ||
|
|
435
|
+
(origin.cloudVersion as number) < 0
|
|
436
|
+
) {
|
|
437
|
+
malformed(`outcome ${i} has no Cloud origin`);
|
|
438
|
+
}
|
|
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 = {
|
|
442
|
+
policy: o.policy,
|
|
443
|
+
mode: o.mode,
|
|
444
|
+
...(typeof o.userCanOverride === "boolean" ? { userCanOverride: o.userCanOverride } : {}),
|
|
445
|
+
// Pack-shaped, so the attribution the hook path already has files it as
|
|
446
|
+
// `cloudPolicyId`/`cloudVersion` (`jevAttribution` in `handler.ts`), and the
|
|
447
|
+
// review keys it as that policy's own reviewer (`cloudReviewerName`).
|
|
448
|
+
origin: { packId: `cloud:${origin.cloudPolicyId}`, packVersion: String(origin.cloudVersion) },
|
|
449
|
+
evidence: o.evidence,
|
|
450
|
+
exempt: nullableProbability(o.exempt, `outcome ${i} exempt`),
|
|
451
|
+
userAsked: nullableProbability(o.userAsked, `outcome ${i} userAsked`),
|
|
452
|
+
targetNamedByUser: o.targetNamedByUser as boolean,
|
|
453
|
+
escalatedByInjection: o.escalatedByInjection as boolean,
|
|
454
|
+
verdict: o.verdict as PolicyOutcome["verdict"],
|
|
455
|
+
...(o.intent !== undefined ? { intent: o.intent as PolicyOutcome["intent"] } : {}),
|
|
456
|
+
...(o.targetScanIncomplete === true ? { targetScanIncomplete: true as const } : {}),
|
|
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()];
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The reply's `cloud` block, validated and rebuilt, or a `malformed` JevError.
|
|
504
|
+
* A reply without one (a server that does not implement C10) is malformed:
|
|
505
|
+
* nothing it says can be told apart from "Cloud has no checks here".
|
|
506
|
+
*/
|
|
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`);
|
|
521
|
+
// Each outcome must answer a check Cloud says it ASKED, once. The review
|
|
522
|
+
// clears a `both` policy's regex half from these outcomes, so one that
|
|
523
|
+
// `asked` does not vouch for — `asked: []` beside a fabricated `none` — is a
|
|
524
|
+
// contradiction, not a clear: the whole reply is refused, and the call is
|
|
525
|
+
// decided by the regex alone (security review of #873).
|
|
526
|
+
const askedSet = new Set(asked);
|
|
527
|
+
const seen = new Set<string>();
|
|
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`);
|
|
530
|
+
const key = `${o.origin?.packId ?? ""}\u0000${o.policy}`;
|
|
531
|
+
if (seen.has(key)) malformed(`${what} outcome ${i} answers ${JSON.stringify(o.policy)} twice`);
|
|
532
|
+
seen.add(key);
|
|
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 ?? [])];
|
|
561
|
+
// A check that fired deny makes the verdict a deny; a reply that says
|
|
562
|
+
// otherwise contradicts itself.
|
|
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
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
observed: observedChecks(observed, watched),
|
|
582
|
+
observedAsked: [...observedHere.map((p) => p.outcome.policy), ...(watched?.asked ?? [])],
|
|
583
|
+
verdict: {
|
|
584
|
+
decision,
|
|
585
|
+
reason,
|
|
586
|
+
outcomes: enforced,
|
|
587
|
+
injectionSuspected: nullableProbability(v.injectionSuspected, "injectionSuspected"),
|
|
588
|
+
scopeWithinRequest: nullableProbability(v.scopeWithinRequest, "scopeWithinRequest"),
|
|
589
|
+
beyondTask: v.beyondTask === true,
|
|
590
|
+
},
|
|
591
|
+
asked,
|
|
592
|
+
droppedLocal: names(block.droppedLocal, "droppedLocal"),
|
|
593
|
+
droppedCloud: names(block.droppedCloud, "droppedCloud"),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
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
|
+
|
|
625
|
+
// ── The merge (CONTRACT C10.5) ───────────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
const RANK: Record<SemanticVerdict["decision"], number> = { allow: 0, instruct: 1, deny: 2 };
|
|
628
|
+
|
|
629
|
+
/** Lines of the given reasons, first seen first, each once. */
|
|
630
|
+
function joinLines(reasons: ReadonlyArray<string | null>): string | null {
|
|
631
|
+
const lines: string[] = [];
|
|
632
|
+
for (const reason of reasons) {
|
|
633
|
+
if (!reason) continue;
|
|
634
|
+
for (const line of reason.split("\n")) if (!lines.includes(line)) lines.push(line);
|
|
635
|
+
}
|
|
636
|
+
return lines.length > 0 ? lines.join("\n") : null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Cloud's verdict and the machine's own, as ONE verdict for `combine.ts`:
|
|
641
|
+
*
|
|
642
|
+
* - outcomes: Cloud's first, then the machine's;
|
|
643
|
+
* - decision: the most severe (deny > instruct > allow);
|
|
644
|
+
* - reason: the deny's (Cloud's first) for a deny; every warning line (Cloud's
|
|
645
|
+
* first, each once — a beyond-the-task line both sides raised is one line)
|
|
646
|
+
* for an instruct; the allow notes (Cloud's first) for an allow;
|
|
647
|
+
* - `beyondTask`: either side's; `injectionSuspected` / `scopeWithinRequest`:
|
|
648
|
+
* the machine's own reading of the shared answer, else Cloud's.
|
|
649
|
+
*
|
|
650
|
+
* Pure.
|
|
651
|
+
*/
|
|
652
|
+
export function mergeCloudVerdict(cloud: SemanticVerdict, local: SemanticVerdict): SemanticVerdict {
|
|
653
|
+
const decision = RANK[cloud.decision] >= RANK[local.decision] ? cloud.decision : local.decision;
|
|
654
|
+
let reason: string | null;
|
|
655
|
+
if (decision === "deny") {
|
|
656
|
+
reason = cloud.decision === "deny" ? cloud.reason ?? local.reason : local.reason;
|
|
657
|
+
} else if (decision === "instruct") {
|
|
658
|
+
reason = joinLines([cloud, local].filter((v) => v.decision === "instruct").map((v) => v.reason));
|
|
659
|
+
} else {
|
|
660
|
+
reason = joinLines([cloud.reason, local.reason]);
|
|
661
|
+
}
|
|
662
|
+
const beyond = cloud.beyondTask === undefined && local.beyondTask === undefined ? undefined : cloud.beyondTask === true || local.beyondTask === true;
|
|
663
|
+
return {
|
|
664
|
+
decision,
|
|
665
|
+
reason,
|
|
666
|
+
outcomes: [...cloud.outcomes, ...local.outcomes],
|
|
667
|
+
injectionSuspected: local.injectionSuspected ?? cloud.injectionSuspected,
|
|
668
|
+
scopeWithinRequest: local.scopeWithinRequest ?? cloud.scopeWithinRequest,
|
|
669
|
+
...(beyond !== undefined ? { beyondTask: beyond } : {}),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── The call ─────────────────────────────────────────────────────────────────
|
|
674
|
+
|
|
675
|
+
/** An allow with nothing decided: Cloud's short-circuit verdict. */
|
|
676
|
+
const EMPTY_ALLOW: SemanticVerdict = {
|
|
677
|
+
decision: "allow",
|
|
678
|
+
reason: null,
|
|
679
|
+
outcomes: [],
|
|
680
|
+
injectionSuspected: null,
|
|
681
|
+
scopeWithinRequest: null,
|
|
682
|
+
beyondTask: false,
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
function envNumber(name: string, fallback: number): number {
|
|
686
|
+
const n = Number(process.env[name]);
|
|
687
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The answers the machine needs back: its own questions, minus the groups Cloud dropped. */
|
|
691
|
+
function expectedQuestions(request: JevRequest, prepared: PreparedCall, dropped: ReadonlySet<string>): JevRequest {
|
|
692
|
+
if (dropped.size === 0) return request;
|
|
693
|
+
const questions = Object.fromEntries(
|
|
694
|
+
Object.entries(request.questions).filter(([id]) => {
|
|
695
|
+
const owner = prepared.compiled.owners.get(id);
|
|
696
|
+
return !(typeof owner === "string" && dropped.has(owner));
|
|
697
|
+
}),
|
|
698
|
+
);
|
|
699
|
+
return { ...request, questions };
|
|
700
|
+
}
|
|
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
|
+
|
|
724
|
+
/** Whatever answers a short-circuit reply carries, read leniently: none is required. */
|
|
725
|
+
function presentAnswers(response: JevResponse): Record<string, number> {
|
|
726
|
+
const out: Record<string, number> = {};
|
|
727
|
+
const answers = response.answers && typeof response.answers === "object" ? response.answers : {};
|
|
728
|
+
for (const [id, a] of Object.entries(answers)) {
|
|
729
|
+
const p = (a as { noul?: unknown } | null)?.noul;
|
|
730
|
+
if (isProbability(p)) out[id] = p;
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Ask FailproofAI Cloud about one call and return the merged verdict. Mirrors
|
|
737
|
+
* `evaluateSemantic` in everything but where the verdict comes from.
|
|
738
|
+
*/
|
|
739
|
+
export async function evaluateCloudSemantic(input: SemanticInput, opts: CloudSemanticOptions): Promise<CloudSemanticOutcome> {
|
|
740
|
+
const started = performance.now();
|
|
741
|
+
const elapsed = () => Math.round(performance.now() - started);
|
|
742
|
+
// A known tool with no side effects (TodoWrite, Task, …) selects no check
|
|
743
|
+
// anywhere — `selectPolicies` answers `[]` for it before reading a single
|
|
744
|
+
// declaration, and Cloud selects its checks with an exact port of that
|
|
745
|
+
// function — so Cloud's reply is its short-circuit, an empty allow, every
|
|
746
|
+
// time. Not asked for: a round trip on every such call would buy nothing.
|
|
747
|
+
// Decided from the tool's name alone, before the envelope and its redaction
|
|
748
|
+
// passes are built for nothing (review n10).
|
|
749
|
+
const tool = classifyTool(input.toolName);
|
|
750
|
+
if (tool.toolIsKnown && tool.toolClass === "other") {
|
|
751
|
+
return {
|
|
752
|
+
status: "ok",
|
|
753
|
+
verdict: { ...EMPTY_ALLOW, outcomes: [] },
|
|
754
|
+
answers: {},
|
|
755
|
+
latencyMs: elapsed(),
|
|
756
|
+
inputTokens: null,
|
|
757
|
+
questionCount: 0,
|
|
758
|
+
truncated: false,
|
|
759
|
+
requestCut: false,
|
|
760
|
+
redactions: 0,
|
|
761
|
+
model: opts.model ?? DEFAULT_JEV_MODEL,
|
|
762
|
+
modelVerified: true,
|
|
763
|
+
via: "none",
|
|
764
|
+
cloud: { asked: [], droppedLocal: [], droppedCloud: [], answers: {}, observed: [], observedAsked: [], observedOutcomes: [] },
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
let prepared: PreparedCall;
|
|
768
|
+
let request: JevRequest;
|
|
769
|
+
try {
|
|
770
|
+
prepared = prepareSemantic(input, opts);
|
|
771
|
+
request = buildCloudRequest(prepared, input, opts.machineId, opts.agent);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
return {
|
|
774
|
+
status: "degraded",
|
|
775
|
+
reason: `prepare: ${err instanceof Error ? err.message : String(err)}`,
|
|
776
|
+
latencyMs: elapsed(),
|
|
777
|
+
questionCount: 0,
|
|
778
|
+
truncated: false,
|
|
779
|
+
requestCut: false,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
const questionCount = Object.keys(request.questions).length;
|
|
783
|
+
// What Cloud forwards is `{model, state, questions}` plus its own checks; the
|
|
784
|
+
// machine's share is checked here the way `prepareSemantic` checks it.
|
|
785
|
+
const { cloud: _block, ...forwarded } = request;
|
|
786
|
+
void _block;
|
|
787
|
+
const oversized = prepared.oversized || JSON.stringify(forwarded).length > MAX_REQUEST_CHARS;
|
|
788
|
+
const truncated = prepared.truncated || oversized;
|
|
789
|
+
const requestCut = prepared.requestCut || oversized;
|
|
790
|
+
const degraded = (reason: string): CloudSemanticOutcome => ({
|
|
791
|
+
status: "degraded",
|
|
792
|
+
reason,
|
|
793
|
+
latencyMs: elapsed(),
|
|
794
|
+
questionCount,
|
|
795
|
+
truncated,
|
|
796
|
+
requestCut,
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
const transport: JevTransport | undefined = opts.transport;
|
|
800
|
+
if (!transport) return degraded("no-transport");
|
|
801
|
+
const via: JevProviderKind = opts.via ?? "failproofai";
|
|
802
|
+
if (opts.signal?.aborted) return degraded("aborted");
|
|
803
|
+
|
|
804
|
+
try {
|
|
805
|
+
const timeout = AbortSignal.timeout(opts.timeoutMs ?? envNumber("FAILPROOFAI_JEV_TIMEOUT_MS", DEFAULT_JEV_TIMEOUT_MS));
|
|
806
|
+
const signal = opts.signal ? AbortSignal.any([timeout, opts.signal]) : timeout;
|
|
807
|
+
const response = await transport(request, signal);
|
|
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));
|
|
813
|
+
|
|
814
|
+
// Only the machine's own groups can be dropped from its side; a name it
|
|
815
|
+
// did not send is not its to act on.
|
|
816
|
+
const own = new Map(prepared.selected.map((p) => [p.name, p] as const));
|
|
817
|
+
const dropped = new Set(reply.droppedLocal.filter((name) => own.has(name)));
|
|
818
|
+
const kept: SemanticPolicy[] = prepared.selected.filter((p) => !dropped.has(p.name));
|
|
819
|
+
|
|
820
|
+
// Short-circuit (C10.4 step 6): nothing of the machine's left to decide and
|
|
821
|
+
// no Cloud check asked, so Cloud made no TypeSafe call and there are no
|
|
822
|
+
// answers to demand. The local verdict is then an empty allow whatever the
|
|
823
|
+
// answers — `decide` with nothing selected reads no per-policy answer, and
|
|
824
|
+
// no warning can fire without one.
|
|
825
|
+
const shortCircuit =
|
|
826
|
+
kept.length === 0 && reply.asked.length === 0 && reply.verdict.outcomes.length === 0 && reply.observedAsked.length === 0;
|
|
827
|
+
const answers = shortCircuit ? presentAnswers(response) : readAnswers(expectedQuestions(request, prepared, dropped), response);
|
|
828
|
+
|
|
829
|
+
const local =
|
|
830
|
+
prepared.intent === "v1"
|
|
831
|
+
? decideV1(kept, answers, input.toolInput, prepared.userSaid, prepared.agentLastMessage, {
|
|
832
|
+
...opts.v1,
|
|
833
|
+
userSaidCut: prepared.userSaidCut,
|
|
834
|
+
})
|
|
835
|
+
: decide(kept, answers, input.toolInput, prepared.userSaid, opts.thresholds ?? DEFAULT_THRESHOLDS, prepared.userSaidCut);
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
status: "ok",
|
|
839
|
+
verdict: mergeCloudVerdict(reply.verdict, local),
|
|
840
|
+
answers,
|
|
841
|
+
latencyMs: elapsed(),
|
|
842
|
+
inputTokens: typeof response.usage?.input_tokens === "number" ? response.usage.input_tokens : null,
|
|
843
|
+
questionCount,
|
|
844
|
+
truncated,
|
|
845
|
+
requestCut,
|
|
846
|
+
redactions: prepared.envelope.redactions,
|
|
847
|
+
model: response.model || request.model,
|
|
848
|
+
modelVerified: response.modelUnverified !== true,
|
|
849
|
+
via,
|
|
850
|
+
cloud: {
|
|
851
|
+
asked: reply.asked,
|
|
852
|
+
droppedLocal: [...dropped].map((name) => ({ name, packId: own.get(name)?.origin?.packId ?? null })),
|
|
853
|
+
droppedCloud: reply.droppedCloud,
|
|
854
|
+
answers: cloudCheckAnswers(response, [...reply.asked, ...reply.observedAsked]),
|
|
855
|
+
observed: reply.observed,
|
|
856
|
+
observedAsked: reply.observedAsked,
|
|
857
|
+
observedOutcomes,
|
|
858
|
+
},
|
|
859
|
+
};
|
|
860
|
+
} catch (err) {
|
|
861
|
+
// Checked first: a transport that surfaces the abort as its own timeout
|
|
862
|
+
// error must still read as the caller's abort, not as Jev being slow.
|
|
863
|
+
if (opts.signal?.aborted) return degraded("aborted");
|
|
864
|
+
if (err instanceof JevError) return degraded(err.code);
|
|
865
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) return degraded("timeout");
|
|
866
|
+
return degraded(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
867
|
+
}
|
|
868
|
+
}
|