failproofai 1.0.0-beta.2 → 1.0.0-beta.3
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 +6 -6
- 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/build-manifest.json +3 -3
- 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 +7 -7
- 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 +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- 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 +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- 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/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/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/reminder/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/build-manifest.json +3 -3
- 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 +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- 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/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- 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/build-manifest.json +3 -3
- 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/build-manifest.json +3 -3
- 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/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- 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/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2195 -770
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
//! Hook-activity source: transform correctness, rotation, and resume.
|
|
2
|
+
//!
|
|
3
|
+
//! Row shapes here are verbatim from `~/.failproofai/cache/hook-activity/` on a
|
|
4
|
+
//! real machine (19,339 rows over 17 days, 12 distinct event types).
|
|
5
|
+
|
|
6
|
+
use std::fs;
|
|
7
|
+
use std::path::{Path, PathBuf};
|
|
8
|
+
use std::sync::Arc;
|
|
9
|
+
use std::sync::atomic::AtomicBool;
|
|
10
|
+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
11
|
+
|
|
12
|
+
use fpai_collect::config::HooksVerbosity;
|
|
13
|
+
use fpai_collect::sources::hooks;
|
|
14
|
+
use fpai_collect::sources::hooks::transform::{self, HookRow};
|
|
15
|
+
use fpai_collect::supervisor::Shutdown;
|
|
16
|
+
use serde_json::Value;
|
|
17
|
+
|
|
18
|
+
fn tmpdir(name: &str) -> PathBuf {
|
|
19
|
+
let d = std::env::temp_dir().join(format!(
|
|
20
|
+
"fpai-hooks-{}-{}-{}",
|
|
21
|
+
name,
|
|
22
|
+
std::process::id(),
|
|
23
|
+
SystemTime::now()
|
|
24
|
+
.duration_since(UNIX_EPOCH)
|
|
25
|
+
.unwrap()
|
|
26
|
+
.as_nanos()
|
|
27
|
+
));
|
|
28
|
+
fs::create_dir_all(&d).unwrap();
|
|
29
|
+
d
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// A deny row, verbatim in shape from the real store.
|
|
33
|
+
fn deny_row() -> String {
|
|
34
|
+
serde_json::json!({
|
|
35
|
+
"timestamp": 1785740912184i64,
|
|
36
|
+
"eventType": "PreToolUse",
|
|
37
|
+
"integration": "claude",
|
|
38
|
+
"toolName": "Bash",
|
|
39
|
+
"policyName": "failproofai/block-sudo",
|
|
40
|
+
"matchedPolicies": ["failproofai/block-env-files", "failproofai/block-sudo"],
|
|
41
|
+
"decision": "deny",
|
|
42
|
+
"reason": "sudo commands are blocked",
|
|
43
|
+
"durationMs": 1,
|
|
44
|
+
"sessionId": "3ee9c788-8772-4f92-be0b-a80ede7ac48e",
|
|
45
|
+
"cwd": "/home/sidd/Desktop/work-failproofai/failproofai",
|
|
46
|
+
"permissionMode": "default"
|
|
47
|
+
})
|
|
48
|
+
.to_string()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fn allow_row(ts: i64, tool: &str) -> String {
|
|
52
|
+
serde_json::json!({
|
|
53
|
+
"timestamp": ts,
|
|
54
|
+
"eventType": "PreToolUse",
|
|
55
|
+
"integration": "claude",
|
|
56
|
+
"toolName": tool,
|
|
57
|
+
"matchedPolicies": ["failproofai/block-sudo"],
|
|
58
|
+
"decision": "allow",
|
|
59
|
+
"durationMs": 42,
|
|
60
|
+
"sessionId": "sess-a",
|
|
61
|
+
"cwd": "/home/u/repo",
|
|
62
|
+
"permissionMode": "default"
|
|
63
|
+
})
|
|
64
|
+
.to_string()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fn parse(s: &str) -> HookRow {
|
|
68
|
+
serde_json::from_str(s).unwrap()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Read every event out of the spool directory.
|
|
72
|
+
fn spooled(dir: &Path) -> Vec<Value> {
|
|
73
|
+
let mut out = Vec::new();
|
|
74
|
+
for e in fs::read_dir(dir).unwrap().filter_map(|e| e.ok()) {
|
|
75
|
+
let p = e.path();
|
|
76
|
+
if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for line in fs::read_to_string(&p).unwrap().lines() {
|
|
80
|
+
if !line.trim().is_empty() {
|
|
81
|
+
out.push(serde_json::from_str(line).unwrap());
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async fn run_once(store: &Path, state: &Path, spool: &Path, v: HooksVerbosity) {
|
|
89
|
+
let sd = Shutdown::for_test(Arc::new(AtomicBool::new(false)));
|
|
90
|
+
// The source polls every 5s; run it briefly, which is long enough for the
|
|
91
|
+
// first pass (which happens immediately) to complete.
|
|
92
|
+
let fut = hooks::run(
|
|
93
|
+
store.to_path_buf(),
|
|
94
|
+
state.to_path_buf(),
|
|
95
|
+
spool.to_path_buf(),
|
|
96
|
+
v,
|
|
97
|
+
"local".into(),
|
|
98
|
+
sd,
|
|
99
|
+
);
|
|
100
|
+
let _ = tokio::time::timeout(Duration::from_millis(1500), fut).await;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#[test]
|
|
104
|
+
fn epoch_millis_become_rfc3339_with_microseconds() {
|
|
105
|
+
// Verified against `date -u -d @1785740912.184`.
|
|
106
|
+
let s = transform::to_rfc3339_micros(1785740912184, 0).unwrap();
|
|
107
|
+
assert_eq!(s, "2026-08-03T07:08:32.184000Z");
|
|
108
|
+
// The index offset separates the two legs of one pair by a microsecond, so
|
|
109
|
+
// hook_completed cannot sort before its own hook_triggered.
|
|
110
|
+
let s1 = transform::to_rfc3339_micros(1785740912184, 1).unwrap();
|
|
111
|
+
assert_eq!(s1, "2026-08-03T07:08:32.184001Z");
|
|
112
|
+
assert!(s < s1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#[test]
|
|
116
|
+
fn a_leap_day_is_handled() {
|
|
117
|
+
// The reason this uses a date library rather than hand-rolled maths.
|
|
118
|
+
// 2024-02-29T00:00:00Z
|
|
119
|
+
assert_eq!(
|
|
120
|
+
transform::to_rfc3339_micros(1709164800000, 0).unwrap(),
|
|
121
|
+
"2024-02-29T00:00:00.000000Z"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#[test]
|
|
126
|
+
fn a_deny_row_becomes_a_paired_trigger_and_completion() {
|
|
127
|
+
let events = transform::to_events(&parse(&deny_row()), 4096, "local");
|
|
128
|
+
assert_eq!(events.len(), 2);
|
|
129
|
+
|
|
130
|
+
let start = &events[0];
|
|
131
|
+
let end = &events[1];
|
|
132
|
+
assert_eq!(start["type"], "hook_triggered");
|
|
133
|
+
assert_eq!(end["type"], "hook_completed");
|
|
134
|
+
|
|
135
|
+
// Paired on hook_id — this is what the server's latency endpoint joins on.
|
|
136
|
+
assert_eq!(start["hook_id"], end["hook_id"]);
|
|
137
|
+
assert!(start["hook_id"].as_str().unwrap().contains("4096"));
|
|
138
|
+
|
|
139
|
+
assert_eq!(start["hook_name"], "PreToolUse");
|
|
140
|
+
assert_eq!(start["trigger_event"], "PreToolUse");
|
|
141
|
+
// trigger_event on the START leg only: the server filters via a semijoin
|
|
142
|
+
// over hook_id, so duplicating it would double-count every hook.
|
|
143
|
+
assert!(end.get("trigger_event").is_none());
|
|
144
|
+
|
|
145
|
+
assert_eq!(end["outcome"], "deny");
|
|
146
|
+
assert_eq!(end["error"], "sudo commands are blocked");
|
|
147
|
+
assert_eq!(end["error_type"], "failproofai_deny");
|
|
148
|
+
assert_eq!(end["failproofai_policy"], "failproofai/block-sudo");
|
|
149
|
+
assert_eq!(start["duration_ms"], 1.0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[test]
|
|
153
|
+
fn an_allow_row_carries_no_error_field() {
|
|
154
|
+
// The server's is_error is a truthiness check, so an `error` present on an
|
|
155
|
+
// allow would render every successful hook as a failure.
|
|
156
|
+
let row = parse(&allow_row(1785740912184, "Read"));
|
|
157
|
+
let events = transform::to_events(&row, 0, "local");
|
|
158
|
+
assert_eq!(events[1]["outcome"], "allow");
|
|
159
|
+
assert!(events[1].get("error").is_none());
|
|
160
|
+
assert!(events[1].get("error_type").is_none());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#[test]
|
|
164
|
+
fn a_deny_with_no_reason_still_gets_a_non_empty_error() {
|
|
165
|
+
let mut v: serde_json::Value = serde_json::from_str(&deny_row()).unwrap();
|
|
166
|
+
v["reason"] = Value::Null;
|
|
167
|
+
let events = transform::to_events(&serde_json::from_value(v).unwrap(), 0, "local");
|
|
168
|
+
let err = events[1]["error"].as_str().unwrap();
|
|
169
|
+
assert!(!err.is_empty(), "an empty error would read as a success");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#[test]
|
|
173
|
+
fn a_row_with_no_session_id_is_dropped_rather_than_shipped() {
|
|
174
|
+
// Ingest requires session_id and silently skips lines without one, so
|
|
175
|
+
// emitting would just inflate the server's skipped counter.
|
|
176
|
+
let mut v: serde_json::Value = serde_json::from_str(&deny_row()).unwrap();
|
|
177
|
+
v["sessionId"] = Value::Null;
|
|
178
|
+
let events = transform::to_events(&serde_json::from_value(v).unwrap(), 0, "local");
|
|
179
|
+
assert!(events.is_empty());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[test]
|
|
183
|
+
fn the_agent_id_matches_what_the_session_sources_produce() {
|
|
184
|
+
// The collector's claude source files this machine's sessions under
|
|
185
|
+
// `claude-failproofai`. Hook events must land under the same agent or the
|
|
186
|
+
// dashboard shows two that look unrelated.
|
|
187
|
+
let row = parse(&deny_row());
|
|
188
|
+
assert_eq!(transform::agent_id(&row), "claude-failproofai");
|
|
189
|
+
|
|
190
|
+
// No cwd (a gateway session) falls back to the bare integration.
|
|
191
|
+
let mut v: serde_json::Value = serde_json::from_str(&deny_row()).unwrap();
|
|
192
|
+
v["cwd"] = Value::Null;
|
|
193
|
+
assert_eq!(
|
|
194
|
+
transform::agent_id(&serde_json::from_value(v).unwrap()),
|
|
195
|
+
"claude"
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#[test]
|
|
200
|
+
fn two_rows_in_one_session_never_collide_on_hook_id() {
|
|
201
|
+
// The real corpus has 8,613 PreToolUse rows in one session. A per-session
|
|
202
|
+
// id would have collapsed all of them into a single row server-side.
|
|
203
|
+
let row = parse(&deny_row());
|
|
204
|
+
let a = transform::to_events(&row, 100, "local");
|
|
205
|
+
let b = transform::to_events(&row, 200, "local");
|
|
206
|
+
assert_ne!(a[0]["hook_id"], b[0]["hook_id"]);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
210
|
+
async fn decisions_verbosity_keeps_denies_exact_and_rolls_up_allows() {
|
|
211
|
+
let store = tmpdir("verb-store");
|
|
212
|
+
let state = tmpdir("verb-state");
|
|
213
|
+
let spool = tmpdir("verb-spool");
|
|
214
|
+
|
|
215
|
+
// 10 allows in one minute + 1 deny.
|
|
216
|
+
let mut lines: Vec<String> = (0..10)
|
|
217
|
+
.map(|i| allow_row(1785740912000 + i * 100, "Bash"))
|
|
218
|
+
.collect();
|
|
219
|
+
lines.push(deny_row());
|
|
220
|
+
fs::write(store.join("current.jsonl"), lines.join("\n") + "\n").unwrap();
|
|
221
|
+
|
|
222
|
+
run_once(&store, &state, &spool, HooksVerbosity::Decisions).await;
|
|
223
|
+
|
|
224
|
+
let events = spooled(&spool);
|
|
225
|
+
let triggered: Vec<_> = events
|
|
226
|
+
.iter()
|
|
227
|
+
.filter(|e| e["type"] == "hook_triggered")
|
|
228
|
+
.collect();
|
|
229
|
+
let completed: Vec<_> = events
|
|
230
|
+
.iter()
|
|
231
|
+
.filter(|e| e["type"] == "hook_completed")
|
|
232
|
+
.collect();
|
|
233
|
+
|
|
234
|
+
// The deny is a full pair; the 10 allows collapse to one aggregate leg.
|
|
235
|
+
assert_eq!(
|
|
236
|
+
triggered.len(),
|
|
237
|
+
1,
|
|
238
|
+
"only the deny should produce a trigger leg"
|
|
239
|
+
);
|
|
240
|
+
assert_eq!(triggered[0]["outcome"], Value::Null);
|
|
241
|
+
assert_eq!(
|
|
242
|
+
completed.len(),
|
|
243
|
+
2,
|
|
244
|
+
"one deny completion + one allow aggregate"
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
let agg = completed.iter().find(|e| e["outcome"] == "allow").unwrap();
|
|
248
|
+
assert_eq!(
|
|
249
|
+
agg["failproofai_allow_count"], 10,
|
|
250
|
+
"the count is what preserves the denominator"
|
|
251
|
+
);
|
|
252
|
+
assert!(agg["hook_id"].as_str().unwrap().ends_with(":agg"));
|
|
253
|
+
|
|
254
|
+
fs::remove_dir_all(&store).ok();
|
|
255
|
+
fs::remove_dir_all(&state).ok();
|
|
256
|
+
fs::remove_dir_all(&spool).ok();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
260
|
+
async fn all_verbosity_emits_a_pair_for_every_row() {
|
|
261
|
+
let store = tmpdir("all-store");
|
|
262
|
+
let state = tmpdir("all-state");
|
|
263
|
+
let spool = tmpdir("all-spool");
|
|
264
|
+
|
|
265
|
+
let lines: Vec<String> = (0..5)
|
|
266
|
+
.map(|i| allow_row(1785740912000 + i * 100, "Bash"))
|
|
267
|
+
.collect();
|
|
268
|
+
fs::write(store.join("current.jsonl"), lines.join("\n") + "\n").unwrap();
|
|
269
|
+
|
|
270
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
271
|
+
|
|
272
|
+
let events = spooled(&spool);
|
|
273
|
+
assert_eq!(
|
|
274
|
+
events.len(),
|
|
275
|
+
10,
|
|
276
|
+
"5 rows should yield 5 pairs, got {}",
|
|
277
|
+
events.len()
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
fs::remove_dir_all(&store).ok();
|
|
281
|
+
fs::remove_dir_all(&state).ok();
|
|
282
|
+
fs::remove_dir_all(&spool).ok();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
286
|
+
async fn a_second_pass_ships_nothing_new() {
|
|
287
|
+
// The cursor is the whole point: without it every poll re-ships the entire
|
|
288
|
+
// 800-file store every five seconds.
|
|
289
|
+
let store = tmpdir("resume-store");
|
|
290
|
+
let state = tmpdir("resume-state");
|
|
291
|
+
let spool = tmpdir("resume-spool");
|
|
292
|
+
fs::write(store.join("current.jsonl"), deny_row() + "\n").unwrap();
|
|
293
|
+
|
|
294
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
295
|
+
let first = spooled(&spool).len();
|
|
296
|
+
assert_eq!(first, 2);
|
|
297
|
+
|
|
298
|
+
for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
|
|
299
|
+
fs::remove_file(e.path()).ok();
|
|
300
|
+
}
|
|
301
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
302
|
+
assert_eq!(spooled(&spool).len(), 0, "a resumed pass must ship nothing");
|
|
303
|
+
|
|
304
|
+
fs::remove_dir_all(&store).ok();
|
|
305
|
+
fs::remove_dir_all(&state).ok();
|
|
306
|
+
fs::remove_dir_all(&spool).ok();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
310
|
+
async fn rotation_does_not_re_ship_the_rotated_page() {
|
|
311
|
+
// The store renames current.jsonl to page-*.jsonl at 25 rows. A path-keyed
|
|
312
|
+
// cursor would treat the rotated page as brand new and duplicate all of it.
|
|
313
|
+
let store = tmpdir("rot-store");
|
|
314
|
+
let state = tmpdir("rot-state");
|
|
315
|
+
let spool = tmpdir("rot-spool");
|
|
316
|
+
|
|
317
|
+
fs::write(store.join("current.jsonl"), deny_row() + "\n").unwrap();
|
|
318
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
319
|
+
assert_eq!(spooled(&spool).len(), 2);
|
|
320
|
+
|
|
321
|
+
for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
|
|
322
|
+
fs::remove_file(e.path()).ok();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Rotate exactly as hook-activity-store.ts does.
|
|
326
|
+
fs::rename(
|
|
327
|
+
store.join("current.jsonl"),
|
|
328
|
+
store.join("page-1784294318708-0.jsonl"),
|
|
329
|
+
)
|
|
330
|
+
.unwrap();
|
|
331
|
+
fs::write(store.join("current.jsonl"), "").unwrap();
|
|
332
|
+
|
|
333
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
334
|
+
assert_eq!(
|
|
335
|
+
spooled(&spool).len(),
|
|
336
|
+
0,
|
|
337
|
+
"the rotated page must not be re-shipped under its new name"
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
fs::remove_dir_all(&store).ok();
|
|
341
|
+
fs::remove_dir_all(&state).ok();
|
|
342
|
+
fs::remove_dir_all(&spool).ok();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
346
|
+
async fn a_partially_written_final_line_is_held_back() {
|
|
347
|
+
// Hook rows are appended by short-lived processes; a poll can land
|
|
348
|
+
// mid-write. Half a JSON object must not be consumed, and the cursor must
|
|
349
|
+
// not advance past it.
|
|
350
|
+
let store = tmpdir("partial-store");
|
|
351
|
+
let state = tmpdir("partial-state");
|
|
352
|
+
let spool = tmpdir("partial-spool");
|
|
353
|
+
|
|
354
|
+
let complete = deny_row();
|
|
355
|
+
fs::write(
|
|
356
|
+
store.join("current.jsonl"),
|
|
357
|
+
format!("{complete}\n{{\"timestamp\":178574091"),
|
|
358
|
+
)
|
|
359
|
+
.unwrap();
|
|
360
|
+
|
|
361
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
362
|
+
assert_eq!(
|
|
363
|
+
spooled(&spool).len(),
|
|
364
|
+
2,
|
|
365
|
+
"only the complete row should ship"
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// Complete the truncated line; the next pass must pick it up.
|
|
369
|
+
let mut full = fs::read_to_string(store.join("current.jsonl")).unwrap();
|
|
370
|
+
full.truncate(complete.len() + 1);
|
|
371
|
+
full.push_str(&(allow_row(1785740999999, "Read") + "\n"));
|
|
372
|
+
fs::write(store.join("current.jsonl"), full).unwrap();
|
|
373
|
+
|
|
374
|
+
for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
|
|
375
|
+
fs::remove_file(e.path()).ok();
|
|
376
|
+
}
|
|
377
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
378
|
+
assert_eq!(
|
|
379
|
+
spooled(&spool).len(),
|
|
380
|
+
2,
|
|
381
|
+
"the completed line must ship on the next pass"
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
fs::remove_dir_all(&store).ok();
|
|
385
|
+
fs::remove_dir_all(&state).ok();
|
|
386
|
+
fs::remove_dir_all(&spool).ok();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
390
|
+
async fn non_jsonl_store_files_are_ignored() {
|
|
391
|
+
// current.count, stats.json and current.lock live in the same directory.
|
|
392
|
+
let store = tmpdir("filter-store");
|
|
393
|
+
let state = tmpdir("filter-state");
|
|
394
|
+
let spool = tmpdir("filter-spool");
|
|
395
|
+
|
|
396
|
+
fs::write(store.join("current.jsonl"), deny_row() + "\n").unwrap();
|
|
397
|
+
fs::write(store.join("current.count"), "17").unwrap();
|
|
398
|
+
fs::write(store.join("stats.json"), r#"{"totalEvents":19339}"#).unwrap();
|
|
399
|
+
fs::write(store.join("current.lock"), "12345").unwrap();
|
|
400
|
+
|
|
401
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
402
|
+
assert_eq!(spooled(&spool).len(), 2, "only the .jsonl should be read");
|
|
403
|
+
|
|
404
|
+
fs::remove_dir_all(&store).ok();
|
|
405
|
+
fs::remove_dir_all(&state).ok();
|
|
406
|
+
fs::remove_dir_all(&spool).ok();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
410
|
+
async fn a_malformed_row_does_not_stop_the_rest_of_the_file() {
|
|
411
|
+
let store = tmpdir("bad-store");
|
|
412
|
+
let state = tmpdir("bad-state");
|
|
413
|
+
let spool = tmpdir("bad-spool");
|
|
414
|
+
|
|
415
|
+
fs::write(
|
|
416
|
+
store.join("current.jsonl"),
|
|
417
|
+
format!("{{not json\n{}\n", deny_row()),
|
|
418
|
+
)
|
|
419
|
+
.unwrap();
|
|
420
|
+
|
|
421
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
422
|
+
assert_eq!(spooled(&spool).len(), 2, "the good row must still ship");
|
|
423
|
+
|
|
424
|
+
fs::remove_dir_all(&store).ok();
|
|
425
|
+
fs::remove_dir_all(&state).ok();
|
|
426
|
+
fs::remove_dir_all(&spool).ok();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Decision attribution
|
|
431
|
+
//
|
|
432
|
+
// These fields were added to the activity store (#632) after this source was
|
|
433
|
+
// written (#640), so nothing carried them across. The server reads
|
|
434
|
+
// `payload.policy_source` and `payload.paused` directly; without them every
|
|
435
|
+
// real row arrives unattributed and the guardrails view can only report that
|
|
436
|
+
// no policy decided anything.
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
/// A cloud-decided deny on a managed, currently-paused machine.
|
|
440
|
+
fn attributed_deny_row() -> String {
|
|
441
|
+
serde_json::json!({
|
|
442
|
+
"timestamp": 1785740912184i64,
|
|
443
|
+
"eventType": "PreToolUse",
|
|
444
|
+
"integration": "claude",
|
|
445
|
+
"toolName": "Bash",
|
|
446
|
+
"policyName": "cloud/org-blocks-curl@3/no-curl",
|
|
447
|
+
"decision": "deny",
|
|
448
|
+
"reason": "curl is blocked by your organisation",
|
|
449
|
+
"durationMs": 4,
|
|
450
|
+
"sessionId": "3ee9c788-8772-4f92-be0b-a80ede7ac48e",
|
|
451
|
+
"cwd": "/home/sidd/work/failproofai",
|
|
452
|
+
"policySource": "cloud",
|
|
453
|
+
"cloudPolicyId": "org-blocks-curl",
|
|
454
|
+
"cloudRevision": 3,
|
|
455
|
+
"cloudGeneration": 8,
|
|
456
|
+
"pausedBy": "session",
|
|
457
|
+
"pauseExpiresAt": 1785742712184i64
|
|
458
|
+
})
|
|
459
|
+
.to_string()
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
463
|
+
async fn a_decision_carries_its_attribution_to_the_server() {
|
|
464
|
+
let store = tmpdir("attr-store");
|
|
465
|
+
let state = tmpdir("attr-state");
|
|
466
|
+
let spool = tmpdir("attr-spool");
|
|
467
|
+
|
|
468
|
+
fs::write(store.join("current.jsonl"), attributed_deny_row() + "\n").unwrap();
|
|
469
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
470
|
+
|
|
471
|
+
let events = spooled(&spool);
|
|
472
|
+
let end = events
|
|
473
|
+
.iter()
|
|
474
|
+
.find(|e| e["type"] == "hook_completed")
|
|
475
|
+
.expect("a completed leg");
|
|
476
|
+
|
|
477
|
+
// Exactly the keys the server's queries extract.
|
|
478
|
+
assert_eq!(end["policy_source"], "cloud");
|
|
479
|
+
assert_eq!(end["cloud_policy_id"], "org-blocks-curl");
|
|
480
|
+
assert_eq!(end["cloud_revision"], 3);
|
|
481
|
+
assert_eq!(end["cloud_generation"], 8);
|
|
482
|
+
assert_eq!(end["paused"], true, "must be a bool, not a string");
|
|
483
|
+
assert_eq!(end["paused_by"], "session");
|
|
484
|
+
|
|
485
|
+
fs::remove_dir_all(&store).ok();
|
|
486
|
+
fs::remove_dir_all(&state).ok();
|
|
487
|
+
fs::remove_dir_all(&spool).ok();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
491
|
+
async fn an_unattributed_row_says_so_rather_than_claiming_a_source() {
|
|
492
|
+
let store = tmpdir("noattr-store");
|
|
493
|
+
let state = tmpdir("noattr-state");
|
|
494
|
+
let spool = tmpdir("noattr-spool");
|
|
495
|
+
|
|
496
|
+
// The pre-attribution shape, still on disk on any machine that has not
|
|
497
|
+
// rotated its store. Guessing a source for it would put a count behind a
|
|
498
|
+
// rollout that did not produce it.
|
|
499
|
+
fs::write(store.join("current.jsonl"), deny_row() + "\n").unwrap();
|
|
500
|
+
run_once(&store, &state, &spool, HooksVerbosity::All).await;
|
|
501
|
+
|
|
502
|
+
let events = spooled(&spool);
|
|
503
|
+
let end = events
|
|
504
|
+
.iter()
|
|
505
|
+
.find(|e| e["type"] == "hook_completed")
|
|
506
|
+
.unwrap();
|
|
507
|
+
|
|
508
|
+
assert!(end.get("policy_source").is_none());
|
|
509
|
+
assert!(end.get("cloud_policy_id").is_none());
|
|
510
|
+
// `paused` is the exception: absent and false must not be distinguishable
|
|
511
|
+
// to a reader counting unenforced calls.
|
|
512
|
+
assert_eq!(end["paused"], false);
|
|
513
|
+
|
|
514
|
+
fs::remove_dir_all(&store).ok();
|
|
515
|
+
fs::remove_dir_all(&state).ok();
|
|
516
|
+
fs::remove_dir_all(&spool).ok();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
520
|
+
async fn an_allow_rollup_never_mixes_two_policy_sources() {
|
|
521
|
+
let store = tmpdir("mix-store");
|
|
522
|
+
let state = tmpdir("mix-state");
|
|
523
|
+
let spool = tmpdir("mix-spool");
|
|
524
|
+
|
|
525
|
+
// Same session, event, tool and minute — everything the old key grouped
|
|
526
|
+
// on. Only the attribution differs, so with attribution outside the key
|
|
527
|
+
// these collapse into one event whose source is wrong for half of them.
|
|
528
|
+
let cloud_allow = serde_json::json!({
|
|
529
|
+
"timestamp": 1785740912000i64, "eventType": "PreToolUse", "integration": "claude",
|
|
530
|
+
"toolName": "Bash", "decision": "allow", "durationMs": 2,
|
|
531
|
+
"sessionId": "s1", "cwd": "/w", "policySource": "cloud",
|
|
532
|
+
"cloudPolicyId": "org-guard", "cloudRevision": 2, "cloudGeneration": 8
|
|
533
|
+
})
|
|
534
|
+
.to_string();
|
|
535
|
+
let plain_allow = serde_json::json!({
|
|
536
|
+
"timestamp": 1785740912100i64, "eventType": "PreToolUse", "integration": "claude",
|
|
537
|
+
"toolName": "Bash", "decision": "allow", "durationMs": 2,
|
|
538
|
+
"sessionId": "s1", "cwd": "/w"
|
|
539
|
+
})
|
|
540
|
+
.to_string();
|
|
541
|
+
|
|
542
|
+
fs::write(
|
|
543
|
+
store.join("current.jsonl"),
|
|
544
|
+
format!("{cloud_allow}\n{plain_allow}\n"),
|
|
545
|
+
)
|
|
546
|
+
.unwrap();
|
|
547
|
+
run_once(&store, &state, &spool, HooksVerbosity::Decisions).await;
|
|
548
|
+
|
|
549
|
+
let events = spooled(&spool);
|
|
550
|
+
assert_eq!(events.len(), 2, "one bucket per source, not one merged");
|
|
551
|
+
|
|
552
|
+
let cloud = events
|
|
553
|
+
.iter()
|
|
554
|
+
.find(|e| e["policy_source"] == "cloud")
|
|
555
|
+
.expect("the cloud-decided allow keeps its source");
|
|
556
|
+
assert_eq!(cloud["failproofai_allow_count"], 1);
|
|
557
|
+
|
|
558
|
+
let plain = events
|
|
559
|
+
.iter()
|
|
560
|
+
.find(|e| e.get("policy_source").is_none())
|
|
561
|
+
.expect("the unattributed allow stays unattributed");
|
|
562
|
+
assert_eq!(plain["failproofai_allow_count"], 1);
|
|
563
|
+
|
|
564
|
+
fs::remove_dir_all(&store).ok();
|
|
565
|
+
fs::remove_dir_all(&state).ok();
|
|
566
|
+
fs::remove_dir_all(&spool).ok();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
570
|
+
async fn an_observed_verdict_survives_the_allow_rollup() {
|
|
571
|
+
let store = tmpdir("obs-store");
|
|
572
|
+
let state = tmpdir("obs-state");
|
|
573
|
+
let spool = tmpdir("obs-spool");
|
|
574
|
+
|
|
575
|
+
// Observe mode evaluates a policy and DISCARDS the verdict, so the row is
|
|
576
|
+
// an allow. Rolled up with the other allows, the would-be verdict — the
|
|
577
|
+
// only thing a trial produces — disappears into a count.
|
|
578
|
+
let observed = serde_json::json!({
|
|
579
|
+
"timestamp": 1785740912000i64, "eventType": "PreToolUse", "integration": "claude",
|
|
580
|
+
"toolName": "Bash", "decision": "allow", "durationMs": 3,
|
|
581
|
+
"sessionId": "s1", "cwd": "/w", "cloudGeneration": 8,
|
|
582
|
+
"observed": [{"policyId": "org-trials-git-push", "revision": 1, "decision": "deny"}]
|
|
583
|
+
})
|
|
584
|
+
.to_string();
|
|
585
|
+
let ordinary = serde_json::json!({
|
|
586
|
+
"timestamp": 1785740912100i64, "eventType": "PreToolUse", "integration": "claude",
|
|
587
|
+
"toolName": "Bash", "decision": "allow", "durationMs": 1,
|
|
588
|
+
"sessionId": "s1", "cwd": "/w", "cloudGeneration": 8
|
|
589
|
+
})
|
|
590
|
+
.to_string();
|
|
591
|
+
|
|
592
|
+
fs::write(
|
|
593
|
+
store.join("current.jsonl"),
|
|
594
|
+
format!("{observed}\n{ordinary}\n"),
|
|
595
|
+
)
|
|
596
|
+
.unwrap();
|
|
597
|
+
run_once(&store, &state, &spool, HooksVerbosity::Decisions).await;
|
|
598
|
+
|
|
599
|
+
let events = spooled(&spool);
|
|
600
|
+
let trial = events
|
|
601
|
+
.iter()
|
|
602
|
+
.find(|e| e.get("failproofai_observed").is_some())
|
|
603
|
+
.expect("the observed verdict must reach the server intact");
|
|
604
|
+
assert_eq!(trial["failproofai_observed"][0]["decision"], "deny");
|
|
605
|
+
assert_eq!(
|
|
606
|
+
trial["failproofai_observed"][0]["policyId"],
|
|
607
|
+
"org-trials-git-push"
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
// The ordinary allow still rolls up — the exemption is narrow.
|
|
611
|
+
assert!(
|
|
612
|
+
events
|
|
613
|
+
.iter()
|
|
614
|
+
.any(|e| e.get("failproofai_allow_count").is_some()),
|
|
615
|
+
"non-observed allows must still aggregate"
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
fs::remove_dir_all(&store).ok();
|
|
619
|
+
fs::remove_dir_all(&state).ok();
|
|
620
|
+
fs::remove_dir_all(&spool).ok();
|
|
621
|
+
}
|