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,527 @@
|
|
|
1
|
+
"""FailproofAI Cloud Jev / regex / both policies in `fp` (CONTRACT C4, C6).
|
|
2
|
+
|
|
3
|
+
`fp policies publish --kind regex|jev|both [--source] [--semantic]`, list/show
|
|
4
|
+
carrying the kind and the Jev check names, deploys (no Jev mode: Cloud derives
|
|
5
|
+
it), and machines showing the policy errors they report.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import pytest
|
|
14
|
+
import respx
|
|
15
|
+
|
|
16
|
+
from fp_cli import client
|
|
17
|
+
from fp_cli.app import app
|
|
18
|
+
from fp_cli.enforcement import (
|
|
19
|
+
RefUsageError,
|
|
20
|
+
parse_semantic,
|
|
21
|
+
plan_deploy,
|
|
22
|
+
resolve_kind,
|
|
23
|
+
version_kinds,
|
|
24
|
+
)
|
|
25
|
+
from fp_cli.models import Deployment, Machine, PolicyRef, PolicyVersion
|
|
26
|
+
|
|
27
|
+
BASE = "http://dash.test"
|
|
28
|
+
FIXTURES = Path(__file__).resolve().parents[2] / "__tests__" / "fixtures" / "cloud-jev"
|
|
29
|
+
VALID = json.loads((FIXTURES / "semantic-valid.json").read_text())
|
|
30
|
+
|
|
31
|
+
DECL = {
|
|
32
|
+
"name": "acme-prod-db",
|
|
33
|
+
"title": "Tried to write to the production database",
|
|
34
|
+
"appliesTo": ["shell"],
|
|
35
|
+
"mode": "deny",
|
|
36
|
+
"userCanOverride": True,
|
|
37
|
+
"probes": [{"id": "writes", "instructions": "It writes.", "criteria": {"true": "yes", "false": "no"}}],
|
|
38
|
+
"guidance": "Ask first.",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
SERVER_BOTH = {
|
|
42
|
+
"id": "no-prod-db", "version": 3, "description": "prod db guard", "sha256": "a" * 64,
|
|
43
|
+
"source": "export default 1;", "createdAt": "2026-09-30T00:00:00Z", "createdBy": "me@test",
|
|
44
|
+
"disabled": False, "archived": False, "kind": "both", "semantic": [DECL],
|
|
45
|
+
"semanticSha256": "b" * 64, "authority": "reviewable", "reviewedBy": ["acme-prod-db"],
|
|
46
|
+
}
|
|
47
|
+
SERVER_JEV = {
|
|
48
|
+
"id": "secrets", "version": 1, "description": "", "sha256": "", "source": None,
|
|
49
|
+
"createdAt": "2026-09-30T00:00:00Z", "createdBy": "me@test", "disabled": False,
|
|
50
|
+
"archived": False, "kind": "jev", "semantic": [dict(DECL, name="acme-secrets")],
|
|
51
|
+
"semanticSha256": "c" * 64,
|
|
52
|
+
}
|
|
53
|
+
SERVER_REGEX_OLD = {
|
|
54
|
+
"id": "no-force-push", "version": 2, "description": "", "sha256": "d" * 64,
|
|
55
|
+
"source": "export default 2;", "createdAt": "2026-09-01T00:00:00Z", "createdBy": None,
|
|
56
|
+
"disabled": False, "archived": False,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── models ────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_a_policy_version_carries_its_kind_and_jev_checks():
|
|
64
|
+
both = PolicyVersion.from_dict(SERVER_BOTH)
|
|
65
|
+
assert (both.kind, both.jev_names, both.authority, both.reviewed_by) == (
|
|
66
|
+
"both", ["acme-prod-db"], "reviewable", ["acme-prod-db"])
|
|
67
|
+
out = both.to_dict()
|
|
68
|
+
assert out["kind"] == "both" and out["semantic"] == [DECL]
|
|
69
|
+
assert out["semanticSha256"] == "b" * 64 and out["reviewedBy"] == ["acme-prod-db"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_a_version_from_a_server_that_predates_kinds_is_regex():
|
|
73
|
+
old = PolicyVersion.from_dict(SERVER_REGEX_OLD)
|
|
74
|
+
assert old.kind == "regex" and old.jev_names == [] and old.semantic is None
|
|
75
|
+
out = old.to_dict()
|
|
76
|
+
assert out["kind"] == "regex"
|
|
77
|
+
assert "semantic" not in out and "reviewedBy" not in out and "authority" not in out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_machines_carry_policy_errors_and_jev_mode_only_when_reported():
|
|
81
|
+
reported = Machine.from_dict({
|
|
82
|
+
"machineId": "m1", "policyErrors": [{"id": "p", "version": 1, "kind": "jev", "message": "bad"}],
|
|
83
|
+
"policyErrorsAt": "2026-09-30T01:00:00Z", "jevMode": "observe",
|
|
84
|
+
})
|
|
85
|
+
assert reported.policy_errors == [{"id": "p", "version": 1, "kind": "jev", "message": "bad"}]
|
|
86
|
+
assert reported.jev_mode == "observe"
|
|
87
|
+
d = reported.to_dict()
|
|
88
|
+
assert d["policyErrors"][0]["message"] == "bad" and d["jevMode"] == "observe"
|
|
89
|
+
assert d["policyErrorsAt"] == "2026-09-30T01:00:00Z"
|
|
90
|
+
|
|
91
|
+
quiet = Machine.from_dict({"machineId": "m2"})
|
|
92
|
+
assert quiet.policy_errors is None and quiet.jev_mode is None
|
|
93
|
+
assert not {"policyErrors", "policyErrorsAt", "jevMode"} & set(quiet.to_dict())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_a_deployment_carries_its_jev_mode():
|
|
97
|
+
dep = Deployment.from_dict({"machineId": "m1", "deployment": 4, "policies": [], "jevMode": "enforce"})
|
|
98
|
+
assert dep.jev_mode == "enforce" and dep.to_dict()["jevMode"] == "enforce"
|
|
99
|
+
assert "jevMode" not in Deployment.from_dict({"machineId": "m1", "deployment": 4}).to_dict()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── kind + semantic (pure) ────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.parametrize("kind,src,sem,expected", [
|
|
106
|
+
(None, True, False, "regex"),
|
|
107
|
+
(None, False, True, "jev"),
|
|
108
|
+
(None, True, True, "both"),
|
|
109
|
+
("regex", True, False, "regex"),
|
|
110
|
+
("jev", False, True, "jev"),
|
|
111
|
+
("both", True, True, "both"),
|
|
112
|
+
])
|
|
113
|
+
def test_the_kind_follows_from_what_was_given(kind, src, sem, expected):
|
|
114
|
+
assert resolve_kind(kind, has_source=src, has_semantic=sem) == expected
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.parametrize("kind,src,sem,needle", [
|
|
118
|
+
("regex", True, True, "takes no --semantic"),
|
|
119
|
+
("jev", True, True, "takes no JavaScript"),
|
|
120
|
+
("jev", False, False, "needs --semantic"),
|
|
121
|
+
("both", True, False, "needs --semantic"),
|
|
122
|
+
("hybrid", True, False, "not one of"),
|
|
123
|
+
])
|
|
124
|
+
def test_a_kind_that_contradicts_its_inputs_is_a_usage_error(kind, src, sem, needle):
|
|
125
|
+
with pytest.raises(RefUsageError, match=needle):
|
|
126
|
+
resolve_kind(kind, has_source=src, has_semantic=sem)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_semantic_accepts_the_contract_fixtures_and_a_wrapper():
|
|
130
|
+
assert len(VALID) == 16
|
|
131
|
+
assert parse_semantic(json.dumps(VALID)) == VALID
|
|
132
|
+
assert parse_semantic(json.dumps({"semantic": VALID[:2]})) == VALID[:2]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.parametrize("text,needle", [
|
|
136
|
+
("{not json", "not valid JSON"),
|
|
137
|
+
(json.dumps({"name": "x"}), "JSON array"),
|
|
138
|
+
("[]", "no Jev declarations"),
|
|
139
|
+
(json.dumps([DECL] * 25), "at most 24"),
|
|
140
|
+
(json.dumps([{"title": "no name"}]), "#0 is not an object with a name"),
|
|
141
|
+
])
|
|
142
|
+
def test_semantic_refuses_what_is_not_a_declaration_list(text, needle):
|
|
143
|
+
with pytest.raises(RefUsageError, match=needle):
|
|
144
|
+
parse_semantic(text)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── deploy plan: no Jev mode ──────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _plan(**kw):
|
|
151
|
+
base = dict(current=[PolicyRef("a", 1)], base=5, latest={"a": 1, "j": 2})
|
|
152
|
+
base.update(kw)
|
|
153
|
+
return plan_deploy("m1", **base)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_a_plan_never_carries_a_jev_mode():
|
|
157
|
+
with pytest.raises(TypeError):
|
|
158
|
+
_plan(jev_mode="observe")
|
|
159
|
+
plan = _plan(add=["a"])
|
|
160
|
+
assert plan.is_noop and "jevMode" not in plan.to_dict()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_observe_applies_to_a_jev_policy_like_any_other():
|
|
164
|
+
kinds = version_kinds([PolicyVersion.from_dict(dict(SERVER_JEV, id="j", version=2))])
|
|
165
|
+
assert _plan(add=["j:observe"], kinds=kinds).added[0].effect == "observe"
|
|
166
|
+
assert _plan(add=["j"], kinds=kinds).added[0].effect == "enforce"
|
|
167
|
+
both = version_kinds([PolicyVersion.from_dict(dict(SERVER_BOTH, id="j", version=2))])
|
|
168
|
+
assert _plan(add=["j:observe"], kinds=both).added[0].effect == "observe"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── the wire ──────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_publish_sends_kind_and_semantic_and_no_source_for_jev(monkeypatch):
|
|
175
|
+
sent = []
|
|
176
|
+
monkeypatch.setattr(client, "_post_json", lambda ctx, path, body=None: sent.append(body) or {})
|
|
177
|
+
client.publish_policy(None, "secrets", None, "d", kind="jev", semantic=[DECL])
|
|
178
|
+
assert sent[-1] == {"id": "secrets", "description": "d", "kind": "jev", "semantic": [DECL]}
|
|
179
|
+
|
|
180
|
+
client.publish_policy(None, "p", "export default 1;", "")
|
|
181
|
+
assert sent[-1] == {"id": "p", "description": "", "source": "export default 1;"}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_publish_still_refuses_jev_fields_in_the_javascript(monkeypatch):
|
|
185
|
+
from fp_cli.errors import ApiError
|
|
186
|
+
|
|
187
|
+
monkeypatch.setattr(client, "_post_json", lambda *a, **k: pytest.fail("reached the server"))
|
|
188
|
+
src = 'semanticPolicies.add({ name: "x" });'
|
|
189
|
+
with pytest.raises(ApiError) as exc:
|
|
190
|
+
client.publish_policy(None, "p", src, "", kind="both", semantic=[DECL])
|
|
191
|
+
assert "--kind both --semantic" in (exc.value.hint or "")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_deploy_never_sends_a_jev_mode(monkeypatch):
|
|
195
|
+
bodies = []
|
|
196
|
+
monkeypatch.setattr(client, "_request_json",
|
|
197
|
+
lambda ctx, method, path, json_body=None: bodies.append(json_body) or {})
|
|
198
|
+
client.deploy_policies(None, "m1", [PolicyRef("a", 1)])
|
|
199
|
+
assert "jevMode" not in bodies[0]
|
|
200
|
+
assert not hasattr(client, "set_jev_mode")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── the commands, over HTTP ───────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@respx.mock
|
|
207
|
+
def test_publish_a_jev_policy_from_a_semantic_file(logged_in, runner, tmp_path):
|
|
208
|
+
checks = tmp_path / "checks.json"
|
|
209
|
+
checks.write_text(json.dumps([DECL]))
|
|
210
|
+
route = respx.post(f"{BASE}/api/enforcement/policies").mock(
|
|
211
|
+
return_value=httpx.Response(200, json=dict(SERVER_JEV, id="prod-db-intent", semantic=[DECL])))
|
|
212
|
+
respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=[]))
|
|
213
|
+
result = runner.invoke(app, ["--json", "policies", "publish", "prod-db-intent", "--semantic", str(checks)])
|
|
214
|
+
assert result.exit_code == 0, result.output
|
|
215
|
+
body = json.loads(route.calls.last.request.content)
|
|
216
|
+
assert body == {"id": "prod-db-intent", "description": "", "kind": "jev", "semantic": [DECL]}
|
|
217
|
+
out = json.loads(result.stdout)
|
|
218
|
+
assert out["kind"] == "jev" and out["syntax"]["checked"] is False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@respx.mock
|
|
222
|
+
def test_publish_both_sends_the_source_and_the_declarations(logged_in, runner, tmp_path):
|
|
223
|
+
rule = tmp_path / "rule.mjs"
|
|
224
|
+
rule.write_text('import { customPolicies, allow } from "failproofai";\n')
|
|
225
|
+
checks = tmp_path / "checks.json"
|
|
226
|
+
checks.write_text(json.dumps([DECL]))
|
|
227
|
+
route = respx.post(f"{BASE}/api/enforcement/policies").mock(
|
|
228
|
+
return_value=httpx.Response(200, json=SERVER_BOTH))
|
|
229
|
+
respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=[]))
|
|
230
|
+
result = runner.invoke(app, ["policies", "publish", "no-prod-db", "--kind", "both",
|
|
231
|
+
"--source", str(rule), "--semantic", str(checks), "--no-verify"])
|
|
232
|
+
assert result.exit_code == 0, result.output
|
|
233
|
+
body = json.loads(route.calls.last.request.content)
|
|
234
|
+
assert body["kind"] == "both" and body["semantic"] == [DECL] and "customPolicies" in body["source"]
|
|
235
|
+
assert "acme-prod-db" in result.stdout and "both" in result.stdout
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@respx.mock
|
|
239
|
+
def test_publish_jev_with_source_is_a_usage_error_before_anything_is_sent(logged_in, runner, tmp_path):
|
|
240
|
+
rule = tmp_path / "rule.mjs"
|
|
241
|
+
rule.write_text("export default 1;\n")
|
|
242
|
+
checks = tmp_path / "checks.json"
|
|
243
|
+
checks.write_text(json.dumps([DECL]))
|
|
244
|
+
result = runner.invoke(app, ["policies", "publish", "x", "--kind", "jev",
|
|
245
|
+
"--source", str(rule), "--semantic", str(checks)])
|
|
246
|
+
assert result.exit_code == 2
|
|
247
|
+
assert not respx.calls
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@respx.mock
|
|
251
|
+
def test_list_and_show_print_the_kind_and_the_jev_names(logged_in, runner):
|
|
252
|
+
respx.get(f"{BASE}/api/enforcement/policies").mock(
|
|
253
|
+
return_value=httpx.Response(200, json=[SERVER_BOTH, SERVER_JEV, SERVER_REGEX_OLD]))
|
|
254
|
+
respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=[]))
|
|
255
|
+
listed = runner.invoke(app, ["policies", "list"])
|
|
256
|
+
assert listed.exit_code == 0, listed.output
|
|
257
|
+
cells = [ln.strip().strip("│").strip() for ln in listed.stdout.splitlines()]
|
|
258
|
+
rows = {c.split()[0]: c for c in cells if c[:1].isalpha()}
|
|
259
|
+
assert "both" in rows["no-prod-db"] and "acme-prod-db" in rows["no-prod-db"]
|
|
260
|
+
assert "jev" in rows["secrets"] and "acme-secrets" in rows["secrets"]
|
|
261
|
+
assert "regex" in rows["no-force-push"]
|
|
262
|
+
|
|
263
|
+
shown = runner.invoke(app, ["policies", "show", "secrets"])
|
|
264
|
+
assert shown.exit_code == 0, shown.output
|
|
265
|
+
assert "jev" in shown.stdout and "acme-secrets" in shown.stdout
|
|
266
|
+
# The declarations themselves, printed where the JavaScript source is.
|
|
267
|
+
assert '"name": "acme-secrets"' in shown.stdout + (shown.stderr or "")
|
|
268
|
+
|
|
269
|
+
js = json.loads(runner.invoke(app, ["--json", "policies", "show", "no-prod-db"]).stdout)
|
|
270
|
+
assert js["kind"] == "both" and js["reviewedBy"] == ["acme-prod-db"] and js["semantic"] == [DECL]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_a_version_carries_its_jev_question_chars_and_regex_has_none():
|
|
274
|
+
jev = PolicyVersion.from_dict(dict(SERVER_JEV, jevChars=412))
|
|
275
|
+
assert jev.jev_chars == 412 and jev.to_dict()["jevChars"] == 412
|
|
276
|
+
regex = PolicyVersion.from_dict(dict(SERVER_REGEX_OLD, jevChars=None))
|
|
277
|
+
assert regex.jev_chars is None and "jevChars" not in regex.to_dict()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@respx.mock
|
|
281
|
+
def test_list_and_show_print_the_jev_question_chars(logged_in, runner):
|
|
282
|
+
respx.get(f"{BASE}/api/enforcement/policies").mock(return_value=httpx.Response(
|
|
283
|
+
200, json=[dict(SERVER_JEV, jevChars=18653), dict(SERVER_REGEX_OLD, jevChars=None)]))
|
|
284
|
+
respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=[]))
|
|
285
|
+
listed = runner.invoke(app, ["policies", "list"], env={"COLUMNS": "200"})
|
|
286
|
+
assert listed.exit_code == 0, listed.output
|
|
287
|
+
rows = {c.split()[0]: c for c in (ln.strip().strip("│").strip() for ln in listed.stdout.splitlines())
|
|
288
|
+
if c[:1].isalpha()}
|
|
289
|
+
assert "18,653 chars" in rows["secrets"]
|
|
290
|
+
assert "chars" not in rows["no-force-push"]
|
|
291
|
+
|
|
292
|
+
shown = runner.invoke(app, ["policies", "show", "secrets"], env={"COLUMNS": "200"})
|
|
293
|
+
assert shown.exit_code == 0, shown.output
|
|
294
|
+
assert "18,653 question chars" in shown.stdout
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@respx.mock
|
|
298
|
+
def test_a_jev_budget_refusal_names_each_policys_share_largest_first(logged_in, runner):
|
|
299
|
+
# The server's deploy 422 (CONTRACT C9.2): the message has the total, the
|
|
300
|
+
# body lists every contributing version — which is what says what to drop.
|
|
301
|
+
_fleet_routes([])
|
|
302
|
+
refusal = {
|
|
303
|
+
"error": "the Jev checks of this deployment are over the machine's question budget "
|
|
304
|
+
"(37756 of 27591 characters)",
|
|
305
|
+
"code": "jev_budget_exceeded", "used": 37756, "budget": 27591,
|
|
306
|
+
"policies": [{"id": "e2e-prod-db", "version": 1, "chars": 415},
|
|
307
|
+
{"id": "e2e-big", "version": 1, "chars": 18653},
|
|
308
|
+
{"id": "e2e-big2", "version": 1, "chars": 18688}],
|
|
309
|
+
}
|
|
310
|
+
respx.put(f"{BASE}/api/enforcement/deployments/m1").mock(return_value=httpx.Response(422, json=refusal))
|
|
311
|
+
human = runner.invoke(app, ["fleet", "deploy", "m1", "--add", "secrets", "--yes"], env={"COLUMNS": "400"})
|
|
312
|
+
assert human.exit_code == 1
|
|
313
|
+
text = " ".join((human.output + (human.stderr or "")).split())
|
|
314
|
+
assert "37756 of 27591" in text
|
|
315
|
+
assert "e2e-big2@1 18,688 · e2e-big@1 18,653 · e2e-prod-db@1 415" in text
|
|
316
|
+
|
|
317
|
+
js = runner.invoke(app, ["--json", "fleet", "deploy", "m1", "--add", "secrets", "--yes"])
|
|
318
|
+
out = json.loads(js.stdout)
|
|
319
|
+
assert out["status"] == 422
|
|
320
|
+
assert out["hint"].startswith("Jev question characters per policy: e2e-big2@1 18,688")
|
|
321
|
+
|
|
322
|
+
# Any other refusal carries no such line.
|
|
323
|
+
respx.put(f"{BASE}/api/enforcement/deployments/m1").mock(return_value=httpx.Response(
|
|
324
|
+
422, json={"error": "bad", "code": "jev_effect_unsupported"}))
|
|
325
|
+
other = json.loads(runner.invoke(app, ["--json", "fleet", "deploy", "m1", "--add", "secrets", "--yes"]).stdout)
|
|
326
|
+
assert "hint" not in other
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _fleet_routes(current_policies, jev_mode=None, errors=None):
|
|
330
|
+
machine = {"machineId": "m1", "deployment": 5, "appliedDeployment": 5, "deployed": True}
|
|
331
|
+
if errors is not None:
|
|
332
|
+
machine["policyErrors"] = errors
|
|
333
|
+
if jev_mode:
|
|
334
|
+
machine["jevMode"] = jev_mode
|
|
335
|
+
dep = {"machineId": "m1", "deployment": 5, "policies": current_policies}
|
|
336
|
+
if jev_mode:
|
|
337
|
+
dep["jevMode"] = jev_mode
|
|
338
|
+
respx.get(f"{BASE}/api/enforcement/machines").mock(return_value=httpx.Response(200, json=[machine]))
|
|
339
|
+
respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=[dep]))
|
|
340
|
+
respx.get(f"{BASE}/api/enforcement/policies").mock(
|
|
341
|
+
return_value=httpx.Response(200, json=[SERVER_BOTH, SERVER_JEV, SERVER_REGEX_OLD]))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@respx.mock
|
|
345
|
+
def test_fleet_deploy_adds_a_jev_policy_and_sends_no_mode(logged_in, runner):
|
|
346
|
+
_fleet_routes([], jev_mode="enforce")
|
|
347
|
+
put = respx.put(f"{BASE}/api/enforcement/deployments/m1").mock(return_value=httpx.Response(
|
|
348
|
+
200, json={"machineId": "m1", "deployment": 6, "jevMode": "enforce",
|
|
349
|
+
"policies": [{"id": "secrets", "version": 1, "effect": "enforce"}]}))
|
|
350
|
+
result = runner.invoke(app, ["fleet", "deploy", "m1", "--add", "secrets", "--yes"])
|
|
351
|
+
assert result.exit_code == 0, result.output
|
|
352
|
+
assert json.loads(put.calls.last.request.content) == {
|
|
353
|
+
"policies": [{"id": "secrets", "version": 1, "effect": "enforce"}]}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@respx.mock
|
|
357
|
+
def test_fleet_deploy_observes_a_jev_policy(logged_in, runner):
|
|
358
|
+
_fleet_routes([])
|
|
359
|
+
put = respx.put(f"{BASE}/api/enforcement/deployments/m1").mock(return_value=httpx.Response(
|
|
360
|
+
200, json={"machineId": "m1", "deployment": 6,
|
|
361
|
+
"policies": [{"id": "secrets", "version": 1, "effect": "observe"}]}))
|
|
362
|
+
result = runner.invoke(app, ["fleet", "deploy", "m1", "--add", "secrets:observe", "--yes"])
|
|
363
|
+
assert result.exit_code == 0, result.output
|
|
364
|
+
assert json.loads(put.calls.last.request.content) == {
|
|
365
|
+
"policies": [{"id": "secrets", "version": 1, "effect": "observe"}]}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@pytest.mark.parametrize("argv", [
|
|
369
|
+
["fleet", "jev-mode", "m1", "observe"],
|
|
370
|
+
["fleet", "deploy", "m1", "--jev-mode", "observe"],
|
|
371
|
+
])
|
|
372
|
+
def test_the_jev_mode_cannot_be_set_by_hand(logged_in, runner, argv):
|
|
373
|
+
with respx.mock(assert_all_called=False) as mock:
|
|
374
|
+
result = runner.invoke(app, argv)
|
|
375
|
+
assert result.exit_code == 2, result.output
|
|
376
|
+
assert not mock.calls
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@respx.mock
|
|
380
|
+
def test_fleet_show_and_list_show_the_jev_mode_and_the_reported_errors(logged_in, runner):
|
|
381
|
+
errors = [{"id": "secrets", "version": 1, "kind": "jev", "message": "not loaded: failed integrity verification"},
|
|
382
|
+
{"id": "jevMode", "version": None, "kind": "daemon", "message": "jev_unconfigured"}]
|
|
383
|
+
_fleet_routes([{"id": "secrets", "version": 1, "effect": "enforce"}], jev_mode="observe", errors=errors)
|
|
384
|
+
shown = runner.invoke(app, ["fleet", "show", "m1"])
|
|
385
|
+
assert shown.exit_code == 0, shown.output
|
|
386
|
+
assert "jev mode" in shown.stdout and "observe" in shown.stdout and "set by FailproofAI Cloud" in shown.stdout
|
|
387
|
+
assert "2 policy errors" in shown.stdout and "jev_unconfigured" in shown.stdout
|
|
388
|
+
assert "failed integrity verification" in shown.stdout
|
|
389
|
+
|
|
390
|
+
listed = runner.invoke(app, ["fleet", "list"])
|
|
391
|
+
assert listed.exit_code == 0, listed.output
|
|
392
|
+
assert "2 errors" in listed.stdout
|
|
393
|
+
|
|
394
|
+
js = json.loads(runner.invoke(app, ["--json", "fleet", "show", "m1"]).stdout)
|
|
395
|
+
assert js["machine"]["policyErrors"] == errors and js["machine"]["jevMode"] == "observe"
|
|
396
|
+
assert js["deployment"]["jevMode"] == "observe"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ── Rollback and history show the mode Cloud derived (m6) ────────────────────
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _fleet_many(deployments, machines=None):
|
|
403
|
+
"""`deployments`: {machine_id: jev_mode or None}; machines default to the same ids."""
|
|
404
|
+
ids = machines if machines is not None else list(deployments)
|
|
405
|
+
respx.get(f"{BASE}/api/enforcement/machines").mock(return_value=httpx.Response(200, json=[
|
|
406
|
+
{"machineId": m, "deployment": 5, "appliedDeployment": 5, "deployed": m in deployments} for m in ids]))
|
|
407
|
+
deps = []
|
|
408
|
+
for m, mode in deployments.items():
|
|
409
|
+
d = {"machineId": m, "deployment": 5, "policies": [{"id": "no-force-push", "version": 2, "effect": "enforce"}]}
|
|
410
|
+
if mode:
|
|
411
|
+
d["jevMode"] = mode
|
|
412
|
+
deps.append(d)
|
|
413
|
+
respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=deps))
|
|
414
|
+
respx.get(f"{BASE}/api/enforcement/policies").mock(
|
|
415
|
+
return_value=httpx.Response(200, json=[SERVER_BOTH, SERVER_JEV, SERVER_REGEX_OLD]))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@respx.mock
|
|
419
|
+
def test_fleet_rollback_says_which_jev_mode_it_restores(logged_in, runner):
|
|
420
|
+
_fleet_many({"m1": "off"})
|
|
421
|
+
respx.get(f"{BASE}/api/enforcement/deployments/m1/history").mock(return_value=httpx.Response(200, json=[
|
|
422
|
+
{"deployment": 5, "policies": [], "updatedAt": "2026-09-30T02:00:00Z", "jevMode": "off"},
|
|
423
|
+
{"deployment": 3, "policies": [], "updatedAt": "2026-09-29T02:00:00Z", "jevMode": "enforce"},
|
|
424
|
+
]))
|
|
425
|
+
respx.post(f"{BASE}/api/enforcement/deployments/m1/rollback").mock(return_value=httpx.Response(
|
|
426
|
+
200, json={"machineId": "m1", "deployment": 6, "rolledBackFrom": 3, "policies": [], "jevMode": "enforce"}))
|
|
427
|
+
human = runner.invoke(app, ["fleet", "rollback", "m1", "3", "--yes"])
|
|
428
|
+
assert human.exit_code == 0, human.output
|
|
429
|
+
text = human.output + (human.stderr or "")
|
|
430
|
+
assert "jev mode" in text and "off → enforce" in text
|
|
431
|
+
|
|
432
|
+
js = runner.invoke(app, ["--json", "fleet", "rollback", "m1", "3", "--yes"])
|
|
433
|
+
assert js.exit_code == 0, js.output
|
|
434
|
+
out = json.loads(js.stdout)
|
|
435
|
+
assert out["jevMode"] == "enforce" and out["jevModeBefore"] == "off"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@respx.mock
|
|
439
|
+
def test_fleet_history_shows_each_generations_jev_mode_and_a_mode_only_change(logged_in, runner):
|
|
440
|
+
_fleet_many({"m1": "observe"})
|
|
441
|
+
pols = [{"id": "no-force-push", "version": 2, "effect": "enforce"}]
|
|
442
|
+
respx.get(f"{BASE}/api/enforcement/deployments/m1/history").mock(return_value=httpx.Response(200, json=[
|
|
443
|
+
{"deployment": 6, "policies": pols, "updatedAt": "2026-09-30T02:00:00Z", "jevMode": "observe"},
|
|
444
|
+
{"deployment": 5, "policies": pols, "updatedAt": "2026-09-30T01:00:00Z"},
|
|
445
|
+
]))
|
|
446
|
+
result = runner.invoke(app, ["fleet", "history", "m1"])
|
|
447
|
+
assert result.exit_code == 0, result.output
|
|
448
|
+
assert "jev" in result.stdout
|
|
449
|
+
assert "local→observe" in result.stdout
|
|
450
|
+
lines = [ln for ln in result.stdout.splitlines() if "#6" in ln or "#5" in ln]
|
|
451
|
+
assert any("observe" in ln for ln in lines if "#6" in ln)
|
|
452
|
+
assert any("local" in ln for ln in lines if "#5" in ln)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ── CONTRACT C10.6: Jev checks run on FailproofAI Cloud (help text only) ──────
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@pytest.mark.parametrize(
|
|
459
|
+
"argv",
|
|
460
|
+
[
|
|
461
|
+
["fleet", "deploy", "--help"],
|
|
462
|
+
["policies", "publish", "--help"],
|
|
463
|
+
],
|
|
464
|
+
)
|
|
465
|
+
def test_the_help_says_jev_checks_run_on_cloud_and_nothing_is_installed(runner, argv):
|
|
466
|
+
result = runner.invoke(app, argv)
|
|
467
|
+
assert result.exit_code == 0, result.output
|
|
468
|
+
flat = " ".join(result.stdout.split())
|
|
469
|
+
assert "Jev checks run on FailproofAI Cloud; nothing is installed on the machine" in flat, argv
|
|
470
|
+
# The on-machine delivery it replaced is not described any more.
|
|
471
|
+
assert "puts its Jev checks on the machine" not in flat, argv
|
|
472
|
+
assert "AgentEye" not in flat, argv
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@pytest.mark.parametrize("argv", [["fleet", "show", "--help"]])
|
|
476
|
+
def test_the_help_names_the_reasons_a_machine_cannot_ask(runner, argv):
|
|
477
|
+
flat = " ".join(runner.invoke(app, argv).stdout.split())
|
|
478
|
+
assert "transcripts_disabled" in flat, argv
|
|
479
|
+
if argv[1] == "show":
|
|
480
|
+
assert "jev_unconfigured" in flat
|
|
481
|
+
assert "jev_budget" in flat
|
|
482
|
+
# Review M3 and the name-clash split: the machine's own reports.
|
|
483
|
+
for code in ("jev_rate_limited", "jev_unavailable", "jev_name_clash"):
|
|
484
|
+
assert code in flat
|
|
485
|
+
# A machine no longer parses Jev checks, so it cannot fail to.
|
|
486
|
+
assert "could not parse" not in flat
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ── e2e findings: exact-profile targets read as their label (B11) ─────────────
|
|
490
|
+
|
|
491
|
+
_AGT = "agt_" + "1f" * 16
|
|
492
|
+
_PROFILE_POLS = [{"id": "no-force-push", "version": 2, "effect": "enforce",
|
|
493
|
+
"agentTargets": [{"integration": "claude", "instanceId": _AGT}]}]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _roster_route(agents, status=200):
|
|
497
|
+
return respx.get(f"{BASE}/api/enforcement/machines/m1/agents").mock(return_value=httpx.Response(
|
|
498
|
+
status, json={"machineId": "m1", "generation": 3, "agents": agents} if status == 200
|
|
499
|
+
else {"error": "not found"}))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@respx.mock
|
|
503
|
+
def test_fleet_show_and_history_name_an_exact_profile_by_its_label(logged_in, runner):
|
|
504
|
+
_fleet_routes(_PROFILE_POLS)
|
|
505
|
+
_roster_route([{"instanceId": _AGT, "integration": "claude", "profileLabel": ".claude-work",
|
|
506
|
+
"scope": "user", "hookInstalled": True}])
|
|
507
|
+
respx.get(f"{BASE}/api/enforcement/deployments/m1/history").mock(return_value=httpx.Response(200, json=[
|
|
508
|
+
{"deployment": 5, "policies": _PROFILE_POLS, "updatedAt": "2026-09-30T01:00:00Z"}]))
|
|
509
|
+
|
|
510
|
+
shown = runner.invoke(app, ["fleet", "show", "m1"])
|
|
511
|
+
assert shown.exit_code == 0, shown.output
|
|
512
|
+
# The label a person recognises, with the id a selector needs beside it.
|
|
513
|
+
assert f"claude/.claude-work ({_AGT})" in shown.stdout
|
|
514
|
+
|
|
515
|
+
history = runner.invoke(app, ["fleet", "history", "m1"])
|
|
516
|
+
assert history.exit_code == 0, history.output
|
|
517
|
+
assert "claude/.claude-work" in history.stdout
|
|
518
|
+
assert _AGT not in history.stdout
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@respx.mock
|
|
522
|
+
def test_an_exact_profile_without_a_reported_label_keeps_the_bare_id(logged_in, runner):
|
|
523
|
+
_fleet_routes(_PROFILE_POLS)
|
|
524
|
+
_roster_route([], status=404)
|
|
525
|
+
shown = runner.invoke(app, ["fleet", "show", "m1"])
|
|
526
|
+
assert shown.exit_code == 0, shown.output
|
|
527
|
+
assert f"claude/{_AGT}" in shown.stdout
|
|
@@ -141,6 +141,52 @@ def test_set_replaces_the_whole_set():
|
|
|
141
141
|
assert plan.result[0].version == 5
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def test_agent_targets_round_trip_and_survive_an_unrelated_add():
|
|
145
|
+
scoped = PolicyRef.from_dict({
|
|
146
|
+
"id": "guard", "version": 2, "effect": "enforce",
|
|
147
|
+
"agentTargets": [{"integration": "hermes", "instanceId": "agt_1234567890abcdef"}],
|
|
148
|
+
})
|
|
149
|
+
assert scoped.to_dict()["agentTargets"] == [
|
|
150
|
+
{"integration": "hermes", "instanceId": "agt_1234567890abcdef"},
|
|
151
|
+
]
|
|
152
|
+
plan = plan_deploy("machine", current=[scoped], base=2, add=["guard@3"], latest={"guard": 3})
|
|
153
|
+
assert plan.result[0].agent_targets == scoped.agent_targets
|
|
154
|
+
assert plan.result[0].version == 3
|
|
155
|
+
assert scoped.version == 2
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_target_changes_are_a_diff_and_clearing_is_explicit():
|
|
159
|
+
initial = ref("guard")
|
|
160
|
+
narrowed = plan_deploy(
|
|
161
|
+
"machine", current=[initial], base=1,
|
|
162
|
+
targets=["guard=hermes/agt_1234567890abcdef", "guard=codex"],
|
|
163
|
+
)
|
|
164
|
+
assert narrowed.changed == [(initial, narrowed.result[0])]
|
|
165
|
+
assert narrowed.result[0].agent_targets == [
|
|
166
|
+
{"integration": "hermes", "instanceId": "agt_1234567890abcdef"},
|
|
167
|
+
{"integration": "codex"},
|
|
168
|
+
]
|
|
169
|
+
cleared = plan_deploy(
|
|
170
|
+
"machine", current=narrowed.result, base=2, all_agents=["guard"],
|
|
171
|
+
)
|
|
172
|
+
assert cleared.result[0].agent_targets is None
|
|
173
|
+
assert cleared.set_changes
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@pytest.mark.parametrize("targets,clears", [
|
|
177
|
+
(["guard=stranger"], []),
|
|
178
|
+
(["guard=hermes/bad"], []),
|
|
179
|
+
(["guard=hermes", "guard=hermes"], []),
|
|
180
|
+
(["guard=hermes"], ["guard"]),
|
|
181
|
+
(["missing=codex"], []),
|
|
182
|
+
(["guard@1=codex"], []),
|
|
183
|
+
])
|
|
184
|
+
def test_invalid_agent_target_request_cannot_write(targets, clears):
|
|
185
|
+
with pytest.raises(RefError):
|
|
186
|
+
plan_deploy("machine", current=[ref("guard")], base=1,
|
|
187
|
+
targets=targets, all_agents=clears)
|
|
188
|
+
|
|
189
|
+
|
|
144
190
|
def test_set_cannot_be_mixed_with_add_or_remove():
|
|
145
191
|
""""exactly these" and "these as well" have no single reading."""
|
|
146
192
|
with pytest.raises(RefError, match="cannot be combined"):
|
|
@@ -1590,6 +1590,29 @@ def test_history_says_no_change_only_when_nothing_moved(capsys):
|
|
|
1590
1590
|
assert "no change" in capsys.readouterr().out
|
|
1591
1591
|
|
|
1592
1592
|
|
|
1593
|
+
def test_history_keeps_gen_when_and_jev_on_an_80_column_terminal(capsys):
|
|
1594
|
+
"""At 80 columns every no_wrap cell got shaved at once and `gen`, `when` and
|
|
1595
|
+
`jev` vanished — but `gen` is what `fp fleet rollback` takes."""
|
|
1596
|
+
_wide_stdout(80)
|
|
1597
|
+
pols = [{"id": f"long-policy-name-{i:02d}", "version": 1, "effect": "enforce",
|
|
1598
|
+
"agentTargets": [{"integration": "claude", "instanceId": "agt_" + "ab" * 16}]}
|
|
1599
|
+
for i in range(4)]
|
|
1600
|
+
entries = [{"deployment": g, "updatedAt": f"2026-08-{10 + g:02d}T13:49:00Z",
|
|
1601
|
+
"jevMode": "observe" if g % 2 else None, "policies": pols[:g]}
|
|
1602
|
+
for g in range(1, 5)]
|
|
1603
|
+
output.render_deployment_history("m", entries)
|
|
1604
|
+
out = capsys.readouterr().out
|
|
1605
|
+
assert max(len(ln) for ln in out.splitlines()) <= 80
|
|
1606
|
+
header = next(ln for ln in out.splitlines() if "change" in ln)
|
|
1607
|
+
for col in ("gen", "when", "jev"):
|
|
1608
|
+
assert col in header, (col, out)
|
|
1609
|
+
for g in range(1, 5):
|
|
1610
|
+
row = next((ln for ln in out.splitlines() if f"#{g} " in ln), None)
|
|
1611
|
+
assert row is not None, (g, out)
|
|
1612
|
+
assert f"08-{10 + g:02d} 13:49:00" in row, out
|
|
1613
|
+
assert ("observe" if g % 2 else "local") in row, out
|
|
1614
|
+
|
|
1615
|
+
|
|
1593
1616
|
def test_clearing_a_label_is_not_reported_as_renaming_to_blank(capsys):
|
|
1594
1617
|
"""`fp fleet rename m ""` clears the override server-side, and the card
|
|
1595
1618
|
said `labelled m as ` — a sentence with a hole in it, describing neither
|
|
@@ -165,23 +165,25 @@ decision, because otherwise corrupted retry state could block a turn forever.
|
|
|
165
165
|
## Local protocol
|
|
166
166
|
|
|
167
167
|
Requests and responses use the existing length-prefixed failproofaid Unix
|
|
168
|
-
socket protocol, version
|
|
168
|
+
socket protocol, version 2. A v1 daemon must be upgraded before it can safely
|
|
169
|
+
evaluate agent/profile-scoped policies.
|
|
169
170
|
|
|
170
171
|
```json
|
|
171
172
|
{
|
|
172
173
|
"type": "policyEvaluation",
|
|
173
|
-
"protocolVersion":
|
|
174
|
+
"protocolVersion": 2,
|
|
174
175
|
"integration": "hermes",
|
|
175
176
|
"event": "pre_tool_call",
|
|
176
177
|
"payload": {},
|
|
177
|
-
"cwd": "/workspace/project"
|
|
178
|
+
"cwd": "/workspace/project",
|
|
179
|
+
"agentSettingsPath": "/path/to/the/hermes/profile"
|
|
178
180
|
}
|
|
179
181
|
```
|
|
180
182
|
|
|
181
183
|
```json
|
|
182
184
|
{
|
|
183
185
|
"type": "policyResult",
|
|
184
|
-
"protocolVersion":
|
|
186
|
+
"protocolVersion": 2,
|
|
185
187
|
"decision": "instruct",
|
|
186
188
|
"policyNames": ["custom/approved-write-route"],
|
|
187
189
|
"reason": "Use the approved write route.",
|