failproofai 0.0.15 → 1.0.0-beta.0
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 +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/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 +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- 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 +9 -9
- 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 +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 +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/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
- 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 +9 -9
- 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/chunks/[root-of-the-server]__0id_xf7._.js +3 -0
- 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]__0114ewg._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06b4trb._.js → [root-of-the-server]__0259h38._.js} +2 -2
- 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]__0qucxyj._.js +2 -2
- 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]__170799-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zj-87g._.js +3 -0
- 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 +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0hs_5mh.js → node_modules_html-to-image_es_index_1-upb14.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 +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/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +11 -11
- package/.next/standalone/.next/static/chunks/{1_v8b8mxhk25s.js → 0ip1n8xmgxarv.js} +1 -1
- package/.next/standalone/.next/static/chunks/{33rm-y-i4uzvv.js → 0u9hc6oxx-gpj.js} +1 -1
- package/.next/standalone/.next/static/chunks/{11qhtpk2cvoxv.js → 1d6ncspjagm53.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2f4mxqkkoa_7d.js → 1grd7y94-igz9.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3m8wmvlhsy1mx.js → 1s-_swlc80y43.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0kf9j0pf_j9-0.js → 29k6ef49zppxj.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2h7vw-ojgr9fz.js → 29ydr-8q0zis6.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1-ng45379-ztb.js → 2vr7aazw6k2-n.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2geh8s7d3ead-.js → 3wrgbih-516uj.js} +1 -1
- package/.next/standalone/Cargo.lock +428 -0
- package/.next/standalone/Cargo.toml +9 -0
- package/.next/standalone/crates/PROTOCOL.md +122 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +18 -0
- package/.next/standalone/crates/failproofaid/src/lock.rs +81 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +83 -0
- package/.next/standalone/crates/failproofaid/src/paths.rs +172 -0
- package/.next/standalone/crates/failproofaid/src/server.rs +502 -0
- package/.next/standalone/crates/failproofaid/src/worker.rs +400 -0
- package/.next/standalone/crates/failproofaid/tests/daemon_e2e.rs +241 -0
- package/.next/standalone/crates/fpai-ipc/Cargo.toml +16 -0
- package/.next/standalone/crates/fpai-ipc/src/envelope.rs +175 -0
- package/.next/standalone/crates/fpai-ipc/src/framing.rs +177 -0
- package/.next/standalone/crates/fpai-ipc/src/lib.rs +10 -0
- package/.next/standalone/crates/fpai-ipc/src/peer.rs +80 -0
- package/.next/standalone/package.json +5 -3
- package/.next/standalone/rust-toolchain.toml +4 -0
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai-worker.mjs +51 -0
- package/bin/failproofai.mjs +68 -1
- package/bin/failproofaid-shim.mjs +67 -0
- package/dist/cli.mjs +1202 -624
- package/dist/worker.mjs +8441 -0
- package/package.json +5 -3
- package/src/hooks/builtin-policies.ts +37 -13
- package/src/hooks/configure-wizard.ts +73 -1
- package/src/hooks/daemon-client.ts +199 -0
- package/src/hooks/daemon-download.ts +152 -0
- package/src/hooks/daemon-service.ts +449 -0
- package/src/hooks/handler.ts +231 -195
- package/src/hooks/normalize-cli-payload.ts +61 -0
- package/src/hooks/policy-types.ts +15 -0
- package/src/hooks/read-stdin.ts +48 -0
- package/src/hooks/worker-server.ts +154 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1is3glr._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0fopait._.js +0 -3
- /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
//! The JSON envelope carried inside each [`crate::framing`] frame.
|
|
2
|
+
//!
|
|
3
|
+
//! This mirrors the `(argv --hook <Event> --cli <cli>, stdin JSON payload)
|
|
4
|
+
//! -> (stdout, stderr, exitCode)` contract every hook invocation already
|
|
5
|
+
//! has today — see `src/hooks/handler.ts`'s `handleHookEvent`. The daemon
|
|
6
|
+
//! adds nothing to that contract; it only relays it over a socket instead
|
|
7
|
+
//! of a fresh process's argv/stdin/stdout.
|
|
8
|
+
|
|
9
|
+
use serde::{Deserialize, Serialize};
|
|
10
|
+
|
|
11
|
+
/// Bumped whenever a wire-incompatible change is made to either message
|
|
12
|
+
/// enum below. The client treats any mismatch identically to "daemon
|
|
13
|
+
/// unreachable" (see `docs/configuration.mdx` and the failproofaid plan) —
|
|
14
|
+
/// there is no negotiation, only agree-or-fall-back.
|
|
15
|
+
pub const PROTOCOL_VERSION: u32 = 1;
|
|
16
|
+
|
|
17
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
18
|
+
#[serde(tag = "type", rename_all = "camelCase")]
|
|
19
|
+
pub enum ClientMessage {
|
|
20
|
+
/// Cheap liveness/handshake check — used by tests and by a client that
|
|
21
|
+
/// only wants to confirm a compatible daemon is listening.
|
|
22
|
+
#[serde(rename_all = "camelCase")]
|
|
23
|
+
Ping { protocol_version: u32 },
|
|
24
|
+
/// One hook evaluation request, corresponding 1:1 to one
|
|
25
|
+
/// `failproofai --hook <Event> --cli <cli>` invocation.
|
|
26
|
+
#[serde(rename_all = "camelCase")]
|
|
27
|
+
Hook {
|
|
28
|
+
protocol_version: u32,
|
|
29
|
+
hook_event: String,
|
|
30
|
+
cli: String,
|
|
31
|
+
/// The raw stdin payload the calling agent CLI wrote to the
|
|
32
|
+
/// one-shot `failproofai` process — forwarded verbatim.
|
|
33
|
+
stdin: String,
|
|
34
|
+
/// Best-effort cwd of the *originating* CLI process, not the
|
|
35
|
+
/// daemon's own cwd (the daemon is a single long-lived process; its
|
|
36
|
+
/// own `cwd` does not vary per request and must never be used to
|
|
37
|
+
/// resolve project config or custom policies).
|
|
38
|
+
cwd: Option<String>,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
43
|
+
#[serde(tag = "type", rename_all = "camelCase")]
|
|
44
|
+
pub enum ServerMessage {
|
|
45
|
+
#[serde(rename_all = "camelCase")]
|
|
46
|
+
Pong { protocol_version: u32 },
|
|
47
|
+
#[serde(rename_all = "camelCase")]
|
|
48
|
+
HookResult {
|
|
49
|
+
protocol_version: u32,
|
|
50
|
+
exit_code: i32,
|
|
51
|
+
stdout: String,
|
|
52
|
+
stderr: String,
|
|
53
|
+
},
|
|
54
|
+
/// The daemon accepted the connection and parsed the request, but could
|
|
55
|
+
/// not produce a verdict (e.g. the worker is down/hung). Distinct from
|
|
56
|
+
/// `HookResult` so a client can tell "ran and decided" apart from
|
|
57
|
+
/// "daemon couldn't evaluate at all" — the latter is what drives the
|
|
58
|
+
/// client's fail-closed path.
|
|
59
|
+
#[serde(rename_all = "camelCase")]
|
|
60
|
+
Error {
|
|
61
|
+
protocol_version: u32,
|
|
62
|
+
message: String,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
impl ClientMessage {
|
|
67
|
+
pub fn protocol_version(&self) -> u32 {
|
|
68
|
+
match self {
|
|
69
|
+
ClientMessage::Ping { protocol_version } => *protocol_version,
|
|
70
|
+
ClientMessage::Hook {
|
|
71
|
+
protocol_version, ..
|
|
72
|
+
} => *protocol_version,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
impl ServerMessage {
|
|
78
|
+
pub fn protocol_version(&self) -> u32 {
|
|
79
|
+
match self {
|
|
80
|
+
ServerMessage::Pong { protocol_version } => *protocol_version,
|
|
81
|
+
ServerMessage::HookResult {
|
|
82
|
+
protocol_version, ..
|
|
83
|
+
} => *protocol_version,
|
|
84
|
+
ServerMessage::Error {
|
|
85
|
+
protocol_version, ..
|
|
86
|
+
} => *protocol_version,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[cfg(test)]
|
|
92
|
+
mod tests {
|
|
93
|
+
use super::*;
|
|
94
|
+
|
|
95
|
+
#[test]
|
|
96
|
+
fn ping_serializes_with_a_type_tag() {
|
|
97
|
+
let msg = ClientMessage::Ping {
|
|
98
|
+
protocol_version: PROTOCOL_VERSION,
|
|
99
|
+
};
|
|
100
|
+
let json = serde_json::to_value(&msg).unwrap();
|
|
101
|
+
assert_eq!(json["type"], "ping");
|
|
102
|
+
assert_eq!(json["protocolVersion"], PROTOCOL_VERSION);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#[test]
|
|
106
|
+
fn hook_request_uses_camel_case_field_names_on_the_wire() {
|
|
107
|
+
let msg = ClientMessage::Hook {
|
|
108
|
+
protocol_version: PROTOCOL_VERSION,
|
|
109
|
+
hook_event: "PreToolUse".to_string(),
|
|
110
|
+
cli: "claude".to_string(),
|
|
111
|
+
stdin: "{}".to_string(),
|
|
112
|
+
cwd: Some("/repo".to_string()),
|
|
113
|
+
};
|
|
114
|
+
let json = serde_json::to_value(&msg).unwrap();
|
|
115
|
+
assert_eq!(json["type"], "hook");
|
|
116
|
+
assert_eq!(json["hookEvent"], "PreToolUse");
|
|
117
|
+
assert_eq!(json["cli"], "claude");
|
|
118
|
+
assert_eq!(json["cwd"], "/repo");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#[test]
|
|
122
|
+
fn hook_request_cwd_is_optional() {
|
|
123
|
+
let json = serde_json::json!({
|
|
124
|
+
"type": "hook",
|
|
125
|
+
"protocolVersion": 1,
|
|
126
|
+
"hookEvent": "Stop",
|
|
127
|
+
"cli": "codex",
|
|
128
|
+
"stdin": "{}"
|
|
129
|
+
});
|
|
130
|
+
let msg: ClientMessage = serde_json::from_value(json).unwrap();
|
|
131
|
+
match msg {
|
|
132
|
+
ClientMessage::Hook { cwd, .. } => assert_eq!(cwd, None),
|
|
133
|
+
_ => panic!("expected Hook variant"),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#[test]
|
|
138
|
+
fn unknown_message_type_fails_to_deserialize() {
|
|
139
|
+
let json = serde_json::json!({ "type": "bogus", "protocolVersion": 1 });
|
|
140
|
+
let result: Result<ClientMessage, _> = serde_json::from_value(json);
|
|
141
|
+
assert!(result.is_err());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[test]
|
|
145
|
+
fn server_hook_result_round_trips() {
|
|
146
|
+
let msg = ServerMessage::HookResult {
|
|
147
|
+
protocol_version: PROTOCOL_VERSION,
|
|
148
|
+
exit_code: 2,
|
|
149
|
+
stdout: String::new(),
|
|
150
|
+
stderr: "blocked".to_string(),
|
|
151
|
+
};
|
|
152
|
+
let json = serde_json::to_string(&msg).unwrap();
|
|
153
|
+
let decoded: ServerMessage = serde_json::from_str(&json).unwrap();
|
|
154
|
+
assert_eq!(decoded, msg);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[test]
|
|
158
|
+
fn protocol_version_accessor_matches_every_variant() {
|
|
159
|
+
assert_eq!(
|
|
160
|
+
ClientMessage::Ping {
|
|
161
|
+
protocol_version: 7
|
|
162
|
+
}
|
|
163
|
+
.protocol_version(),
|
|
164
|
+
7
|
|
165
|
+
);
|
|
166
|
+
assert_eq!(
|
|
167
|
+
ServerMessage::Error {
|
|
168
|
+
protocol_version: 9,
|
|
169
|
+
message: "x".to_string()
|
|
170
|
+
}
|
|
171
|
+
.protocol_version(),
|
|
172
|
+
9
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
//! Length-prefixed JSON framing shared by the daemon and every client of it.
|
|
2
|
+
//!
|
|
3
|
+
//! Frame = 4-byte big-endian `u32` byte length, followed by that many bytes
|
|
4
|
+
//! of UTF-8 JSON. One frame in, one frame out, per connection — the daemon
|
|
5
|
+
//! never keeps a connection open across multiple logical requests, so there
|
|
6
|
+
//! is no need for a request-id-multiplexed protocol.
|
|
7
|
+
|
|
8
|
+
use serde::Serialize;
|
|
9
|
+
use serde::de::DeserializeOwned;
|
|
10
|
+
use std::io::{self, Read, Write};
|
|
11
|
+
|
|
12
|
+
/// Hard cap on a single frame's declared length. A hook payload is at most
|
|
13
|
+
/// 1 MiB (see `handler.ts`'s own stdin cap); 16 MiB leaves headroom without
|
|
14
|
+
/// letting a corrupt or hostile length prefix trigger an unbounded
|
|
15
|
+
/// allocation before a single byte of the body has even been read.
|
|
16
|
+
pub const MAX_FRAME_LEN: u32 = 16 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
#[derive(Debug)]
|
|
19
|
+
pub enum FramingError {
|
|
20
|
+
Io(io::Error),
|
|
21
|
+
FrameTooLarge { declared: u32, max: u32 },
|
|
22
|
+
Json(serde_json::Error),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
impl std::fmt::Display for FramingError {
|
|
26
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
27
|
+
match self {
|
|
28
|
+
FramingError::Io(e) => write!(f, "io error: {e}"),
|
|
29
|
+
FramingError::FrameTooLarge { declared, max } => {
|
|
30
|
+
write!(f, "frame length {declared} exceeds max {max}")
|
|
31
|
+
}
|
|
32
|
+
FramingError::Json(e) => write!(f, "invalid JSON frame: {e}"),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
impl std::error::Error for FramingError {}
|
|
38
|
+
|
|
39
|
+
impl From<io::Error> for FramingError {
|
|
40
|
+
fn from(e: io::Error) -> Self {
|
|
41
|
+
FramingError::Io(e)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
impl From<serde_json::Error> for FramingError {
|
|
46
|
+
fn from(e: serde_json::Error) -> Self {
|
|
47
|
+
FramingError::Json(e)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Serializes `value` to JSON and writes it as one length-prefixed frame.
|
|
52
|
+
pub fn write_message<W: Write, T: Serialize>(
|
|
53
|
+
writer: &mut W,
|
|
54
|
+
value: &T,
|
|
55
|
+
) -> Result<(), FramingError> {
|
|
56
|
+
let body = serde_json::to_vec(value)?;
|
|
57
|
+
let len = u32::try_from(body.len()).map_err(|_| FramingError::FrameTooLarge {
|
|
58
|
+
declared: u32::MAX,
|
|
59
|
+
max: MAX_FRAME_LEN,
|
|
60
|
+
})?;
|
|
61
|
+
if len > MAX_FRAME_LEN {
|
|
62
|
+
return Err(FramingError::FrameTooLarge {
|
|
63
|
+
declared: len,
|
|
64
|
+
max: MAX_FRAME_LEN,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
writer.write_all(&len.to_be_bytes())?;
|
|
68
|
+
writer.write_all(&body)?;
|
|
69
|
+
writer.flush()?;
|
|
70
|
+
Ok(())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Reads one length-prefixed frame and deserializes it as `T`. Rejects the
|
|
74
|
+
/// frame (without allocating a body buffer) if the declared length exceeds
|
|
75
|
+
/// [`MAX_FRAME_LEN`], so a corrupt or adversarial length prefix can't be
|
|
76
|
+
/// used to force a large allocation.
|
|
77
|
+
pub fn read_message<R: Read, T: DeserializeOwned>(reader: &mut R) -> Result<T, FramingError> {
|
|
78
|
+
let mut len_bytes = [0u8; 4];
|
|
79
|
+
reader.read_exact(&mut len_bytes)?;
|
|
80
|
+
let len = u32::from_be_bytes(len_bytes);
|
|
81
|
+
if len > MAX_FRAME_LEN {
|
|
82
|
+
return Err(FramingError::FrameTooLarge {
|
|
83
|
+
declared: len,
|
|
84
|
+
max: MAX_FRAME_LEN,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
let mut body = vec![0u8; len as usize];
|
|
88
|
+
reader.read_exact(&mut body)?;
|
|
89
|
+
let value = serde_json::from_slice(&body)?;
|
|
90
|
+
Ok(value)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[cfg(test)]
|
|
94
|
+
mod tests {
|
|
95
|
+
use super::*;
|
|
96
|
+
use serde::Deserialize;
|
|
97
|
+
use std::io::Cursor;
|
|
98
|
+
|
|
99
|
+
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
100
|
+
struct Sample {
|
|
101
|
+
a: u32,
|
|
102
|
+
b: String,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#[test]
|
|
106
|
+
fn round_trips_a_simple_value() {
|
|
107
|
+
let mut buf = Vec::new();
|
|
108
|
+
let msg = Sample {
|
|
109
|
+
a: 42,
|
|
110
|
+
b: "hello".to_string(),
|
|
111
|
+
};
|
|
112
|
+
write_message(&mut buf, &msg).unwrap();
|
|
113
|
+
let mut cursor = Cursor::new(buf);
|
|
114
|
+
let decoded: Sample = read_message(&mut cursor).unwrap();
|
|
115
|
+
assert_eq!(decoded, msg);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#[test]
|
|
119
|
+
fn round_trips_empty_string_fields() {
|
|
120
|
+
let mut buf = Vec::new();
|
|
121
|
+
let msg = Sample {
|
|
122
|
+
a: 0,
|
|
123
|
+
b: String::new(),
|
|
124
|
+
};
|
|
125
|
+
write_message(&mut buf, &msg).unwrap();
|
|
126
|
+
let mut cursor = Cursor::new(buf);
|
|
127
|
+
let decoded: Sample = read_message(&mut cursor).unwrap();
|
|
128
|
+
assert_eq!(decoded, msg);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#[test]
|
|
132
|
+
fn rejects_a_declared_length_over_the_cap_without_reading_a_body() {
|
|
133
|
+
let mut buf = Vec::new();
|
|
134
|
+
buf.extend_from_slice(&(MAX_FRAME_LEN + 1).to_be_bytes());
|
|
135
|
+
// Deliberately no body bytes follow — if `read_message` tried to
|
|
136
|
+
// allocate/read the declared length before checking the cap, this
|
|
137
|
+
// would hang or panic on the short read instead of erroring cleanly.
|
|
138
|
+
let mut cursor = Cursor::new(buf);
|
|
139
|
+
let result: Result<Sample, _> = read_message(&mut cursor);
|
|
140
|
+
assert!(matches!(result, Err(FramingError::FrameTooLarge { .. })));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[test]
|
|
144
|
+
fn refuses_to_write_a_frame_over_the_cap() {
|
|
145
|
+
#[derive(Serialize)]
|
|
146
|
+
struct Big {
|
|
147
|
+
data: String,
|
|
148
|
+
}
|
|
149
|
+
let big = Big {
|
|
150
|
+
data: "x".repeat((MAX_FRAME_LEN as usize) + 1),
|
|
151
|
+
};
|
|
152
|
+
let mut buf = Vec::new();
|
|
153
|
+
let result = write_message(&mut buf, &big);
|
|
154
|
+
assert!(matches!(result, Err(FramingError::FrameTooLarge { .. })));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[test]
|
|
158
|
+
fn rejects_truncated_frames() {
|
|
159
|
+
let mut buf = Vec::new();
|
|
160
|
+
buf.extend_from_slice(&10u32.to_be_bytes());
|
|
161
|
+
buf.extend_from_slice(b"short"); // fewer than the declared 10 bytes
|
|
162
|
+
let mut cursor = Cursor::new(buf);
|
|
163
|
+
let result: Result<Sample, _> = read_message(&mut cursor);
|
|
164
|
+
assert!(matches!(result, Err(FramingError::Io(_))));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn rejects_malformed_json_inside_a_valid_frame() {
|
|
169
|
+
let mut buf = Vec::new();
|
|
170
|
+
let body = b"not json";
|
|
171
|
+
buf.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
|
172
|
+
buf.extend_from_slice(body);
|
|
173
|
+
let mut cursor = Cursor::new(buf);
|
|
174
|
+
let result: Result<Sample, _> = read_message(&mut cursor);
|
|
175
|
+
assert!(matches!(result, Err(FramingError::Json(_))));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//! Wire protocol shared by `failproofaid` and every client/test that talks
|
|
2
|
+
//! to it: message framing, the JSON envelope, and Unix-socket peer
|
|
3
|
+
//! verification.
|
|
4
|
+
|
|
5
|
+
pub mod envelope;
|
|
6
|
+
pub mod framing;
|
|
7
|
+
pub mod peer;
|
|
8
|
+
|
|
9
|
+
pub use envelope::{ClientMessage, PROTOCOL_VERSION, ServerMessage};
|
|
10
|
+
pub use framing::{FramingError, MAX_FRAME_LEN, read_message, write_message};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//! Verifies that a Unix socket peer is running as the same OS user as this
|
|
2
|
+
//! process.
|
|
3
|
+
//!
|
|
4
|
+
//! failproofaid is user-scope only (see the plan: "no elevation, the daemon
|
|
5
|
+
//! spawns the worker because the daemon already runs as the user"): the
|
|
6
|
+
//! socket directory is `0700` and the socket file `0600`, which is the
|
|
7
|
+
//! actual access-control boundary. This check is a second, defense-in-depth
|
|
8
|
+
//! layer against the narrow window between a socket file existing and its
|
|
9
|
+
//! permissions having been fully applied, and against misconfigured
|
|
10
|
+
//! filesystems (e.g. a shared mount with unexpectedly loose permissions).
|
|
11
|
+
//! It is not a stronger security boundary than the filesystem permissions
|
|
12
|
+
//! already provide — same OS user can always reach this daemon regardless.
|
|
13
|
+
|
|
14
|
+
use std::io;
|
|
15
|
+
use std::os::unix::io::AsRawFd;
|
|
16
|
+
use std::os::unix::net::UnixStream;
|
|
17
|
+
|
|
18
|
+
/// Returns the effective UID of the process on the other end of `stream`.
|
|
19
|
+
#[cfg(target_os = "linux")]
|
|
20
|
+
pub fn peer_uid(stream: &UnixStream) -> io::Result<u32> {
|
|
21
|
+
use std::mem;
|
|
22
|
+
|
|
23
|
+
let fd = stream.as_raw_fd();
|
|
24
|
+
let mut cred: libc::ucred = unsafe { mem::zeroed() };
|
|
25
|
+
let mut len = mem::size_of::<libc::ucred>() as libc::socklen_t;
|
|
26
|
+
|
|
27
|
+
let ret = unsafe {
|
|
28
|
+
libc::getsockopt(
|
|
29
|
+
fd,
|
|
30
|
+
libc::SOL_SOCKET,
|
|
31
|
+
libc::SO_PEERCRED,
|
|
32
|
+
&mut cred as *mut libc::ucred as *mut libc::c_void,
|
|
33
|
+
&mut len,
|
|
34
|
+
)
|
|
35
|
+
};
|
|
36
|
+
if ret != 0 {
|
|
37
|
+
return Err(io::Error::last_os_error());
|
|
38
|
+
}
|
|
39
|
+
Ok(cred.uid)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Returns the effective UID of the process on the other end of `stream`.
|
|
43
|
+
#[cfg(target_os = "macos")]
|
|
44
|
+
pub fn peer_uid(stream: &UnixStream) -> io::Result<u32> {
|
|
45
|
+
let fd = stream.as_raw_fd();
|
|
46
|
+
let mut uid: libc::uid_t = 0;
|
|
47
|
+
let mut gid: libc::gid_t = 0;
|
|
48
|
+
|
|
49
|
+
let ret = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
|
|
50
|
+
if ret != 0 {
|
|
51
|
+
return Err(io::Error::last_os_error());
|
|
52
|
+
}
|
|
53
|
+
Ok(uid)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Returns this process's own effective UID, for comparison against
|
|
57
|
+
/// [`peer_uid`].
|
|
58
|
+
pub fn own_uid() -> u32 {
|
|
59
|
+
unsafe { libc::geteuid() }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// `true` if `stream`'s peer is running as this process's own OS user.
|
|
63
|
+
pub fn is_same_user(stream: &UnixStream) -> io::Result<bool> {
|
|
64
|
+
Ok(peer_uid(stream)? == own_uid())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[cfg(test)]
|
|
68
|
+
mod tests {
|
|
69
|
+
use super::*;
|
|
70
|
+
|
|
71
|
+
#[test]
|
|
72
|
+
fn a_local_socketpair_peer_is_always_the_same_user() {
|
|
73
|
+
// socketpair() connects two ends of the same process — the peer
|
|
74
|
+
// UID must equal our own, on every CI runner regardless of who's
|
|
75
|
+
// executing the test.
|
|
76
|
+
let (a, _b) = UnixStream::pair().unwrap();
|
|
77
|
+
assert!(is_same_user(&a).unwrap());
|
|
78
|
+
assert_eq!(peer_uid(&a).unwrap(), own_uid());
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "failproofai",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
4
4
|
"description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
|
|
5
5
|
"bin": {
|
|
6
|
-
"failproofai": "./dist/cli.mjs"
|
|
6
|
+
"failproofai": "./dist/cli.mjs",
|
|
7
|
+
"failproofaid": "./bin/failproofaid-shim.mjs"
|
|
7
8
|
},
|
|
8
9
|
"files": [
|
|
9
10
|
"bin/",
|
|
@@ -24,7 +25,8 @@
|
|
|
24
25
|
"predev": "bun run build:cli && bun link",
|
|
25
26
|
"dev": "FAILPROOFAI_TELEMETRY_DISABLED=1 bun scripts/dev.ts --port 8020",
|
|
26
27
|
"build:cli": "bun build --target=node --format=esm --outfile=dist/cli.mjs bin/failproofai.mjs --external posthog-node --external sql.js && node -e \"const fs=require('fs');const c=fs.readFileSync('dist/cli.mjs','utf8');fs.writeFileSync('dist/cli.mjs',c.replace('#!/usr/bin/env bun','#!/usr/bin/env node').replace('// @bun\\n',''))\"",
|
|
27
|
-
"build": "bun build --target=node --format=
|
|
28
|
+
"build:worker": "bun build --target=node --format=esm --outfile=dist/worker.mjs bin/failproofai-worker.mjs --external posthog-node --external sql.js",
|
|
29
|
+
"build": "bun build --target=node --format=cjs --outfile=dist/index.js src/index.ts && bun run build:cli && bun run build:worker && bun --bun next build && node -e \"const {cpSync}=require('fs');cpSync('.next/static','.next/standalone/.next/static',{recursive:true});\" && node scripts/prune-standalone.mjs",
|
|
28
30
|
"prestart": "bun run build:cli && bun link",
|
|
29
31
|
"start": "FAILPROOFAI_TELEMETRY_DISABLED=1 bun scripts/start.ts",
|
|
30
32
|
"test": "vitest",
|
|
@@ -9,7 +9,7 @@ const currentPort = parseInt(process.env.PORT, 10) || 3000
|
|
|
9
9
|
const hostname = process.env.HOSTNAME || '0.0.0.0'
|
|
10
10
|
|
|
11
11
|
let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10)
|
|
12
|
-
const nextConfig = {"env":{"NEXT_PUBLIC_APP_VERSION":"0.0.
|
|
12
|
+
const nextConfig = {"env":{"NEXT_PUBLIC_APP_VERSION":"1.0.0-beta.0"},"typescript":{"ignoreBuildErrors":false},"typedRoutes":false,"distDir":"./.next","cleanDistDir":true,"assetPrefix":"","cacheMaxMemorySize":52428800,"configOrigin":"next.config.ts","useFileSystemPublicRoutes":true,"generateEtags":true,"pageExtensions":["tsx","ts","jsx","js"],"poweredByHeader":true,"compress":true,"images":{"deviceSizes":[640,750,828,1080,1200,1920,2048,3840],"imageSizes":[32,48,64,96,128,256,384],"path":"/_next/image","loader":"default","loaderFile":"","domains":[],"disableStaticImages":false,"minimumCacheTTL":14400,"formats":["image/webp"],"maximumRedirects":3,"maximumResponseBody":50000000,"dangerouslyAllowLocalIP":false,"dangerouslyAllowSVG":false,"contentSecurityPolicy":"script-src 'none'; frame-src 'none'; sandbox;","contentDispositionType":"attachment","localPatterns":[{"pathname":"**","search":""}],"remotePatterns":[],"qualities":[75],"unoptimized":true,"customCacheHandler":false},"devIndicators":{"position":"bottom-left"},"onDemandEntries":{"maxInactiveAge":60000,"pagesBufferLength":5},"basePath":"","sassOptions":{},"trailingSlash":false,"i18n":null,"productionBrowserSourceMaps":false,"excludeDefaultMomentLocales":true,"reactProductionProfiling":false,"reactStrictMode":null,"reactMaxHeadersLength":6000,"httpAgentOptions":{"keepAlive":true},"logging":{"serverFunctions":true,"browserToTerminal":"warn"},"compiler":{},"expireTime":31536000,"staticPageGenerationTimeout":60,"output":"standalone","modularizeImports":{"@mui/icons-material":{"transform":"@mui/icons-material/{{member}}"},"lodash":{"transform":"lodash/{{member}}"}},"outputFileTracingRoot":"/home/runner/work/failproofai/failproofai","cacheComponents":false,"cacheLife":{"default":{"stale":300,"revalidate":900,"expire":4294967294},"seconds":{"stale":30,"revalidate":1,"expire":60},"minutes":{"stale":300,"revalidate":60,"expire":3600},"hours":{"stale":300,"revalidate":3600,"expire":86400},"days":{"stale":300,"revalidate":86400,"expire":604800},"weeks":{"stale":300,"revalidate":604800,"expire":2592000},"max":{"stale":300,"revalidate":2592000,"expire":31536000}},"cacheHandlers":{},"experimental":{"appNewScrollHandler":false,"useSkewCookie":false,"cssChunking":true,"multiZoneDraftMode":false,"appNavFailHandling":false,"prerenderEarlyExit":true,"serverMinification":true,"linkNoTouchStart":false,"caseSensitiveRoutes":false,"cachedNavigations":false,"partialFallbacks":false,"dynamicOnHover":false,"varyParams":false,"prefetchInlining":false,"preloadEntriesOnStart":true,"clientRouterFilter":true,"clientRouterFilterRedirects":false,"fetchCacheKeyPrefix":"","proxyPrefetch":"flexible","optimisticClientCache":true,"manualClientBasePath":false,"cpus":3,"memoryBasedWorkersCount":false,"imgOptConcurrency":null,"imgOptTimeoutInSeconds":7,"imgOptMaxInputPixels":268402689,"imgOptSequentialRead":null,"isrFlushToDisk":true,"workerThreads":false,"optimizeCss":false,"nextScriptWorkers":false,"scrollRestoration":false,"externalDir":false,"disableOptimizedLoading":false,"gzipSize":true,"craCompat":false,"esmExternals":true,"fullySpecified":false,"swcTraceProfiling":false,"forceSwcTransforms":false,"largePageDataBytes":128000,"typedEnv":false,"parallelServerCompiles":false,"parallelServerBuildTraces":false,"ppr":false,"authInterrupts":false,"webpackMemoryOptimizations":false,"optimizeServerReact":true,"strictRouteTypes":false,"useTypeScriptCli":false,"viewTransition":false,"removeUncaughtErrorAndRejectionListeners":false,"validateRSCRequestHeaders":false,"staleTimes":{"dynamic":0,"static":300},"reactDebugChannel":true,"serverComponentsHmrCache":true,"staticGenerationMaxConcurrency":8,"staticGenerationMinPagesPerWorker":25,"transitionIndicator":false,"gestureTransition":false,"inlineCss":false,"useCache":false,"globalNotFound":false,"browserDebugInfoInTerminal":"warn","lockDistDir":true,"proxyClientMaxBodySize":10485760,"hideLogsAfterAbort":false,"mcpServer":true,"turbopackFileSystemCacheForDev":true,"turbopackFileSystemCacheForBuild":false,"turbopackInferModuleSideEffects":true,"turbopackPluginRuntimeStrategy":"childProcesses","optimizePackageImports":["lucide-react","date-fns","lodash-es","ramda","antd","react-bootstrap","ahooks","@ant-design/icons","@headlessui/react","@headlessui-float/react","@heroicons/react/20/solid","@heroicons/react/24/solid","@heroicons/react/24/outline","@visx/visx","@tremor/react","rxjs","@mui/material","@mui/icons-material","recharts","react-use","effect","@effect/schema","@effect/platform","@effect/platform-node","@effect/platform-browser","@effect/platform-bun","@effect/sql","@effect/sql-mssql","@effect/sql-mysql2","@effect/sql-pg","@effect/sql-sqlite-node","@effect/sql-sqlite-bun","@effect/sql-sqlite-wasm","@effect/sql-sqlite-react-native","@effect/rpc","@effect/rpc-http","@effect/typeclass","@effect/experimental","@effect/opentelemetry","@material-ui/core","@material-ui/icons","@tabler/icons-react","mui-core","react-icons/ai","react-icons/bi","react-icons/bs","react-icons/cg","react-icons/ci","react-icons/di","react-icons/fa","react-icons/fa6","react-icons/fc","react-icons/fi","react-icons/gi","react-icons/go","react-icons/gr","react-icons/hi","react-icons/hi2","react-icons/im","react-icons/io","react-icons/io5","react-icons/lia","react-icons/lib","react-icons/lu","react-icons/md","react-icons/pi","react-icons/ri","react-icons/rx","react-icons/si","react-icons/sl","react-icons/tb","react-icons/tfi","react-icons/ti","react-icons/vsc","react-icons/wi"],"trustHostHeader":false,"isExperimentalCompile":false},"htmlLimitedBots":"[\\w-]+-Google|Google-[\\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight","bundlePagesRouterDependencies":false,"configFileName":"next.config.ts","outputFileTracingExcludes":{"*":["node_modules/@img/**","node_modules/sharp/**"]},"turbopack":{"root":"/home/runner/work/failproofai/failproofai"},"distDirRoot":".next"}
|
|
13
13
|
|
|
14
14
|
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig)
|
|
15
15
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* failproofai-worker — the warm worker process failproofaid (the Rust
|
|
4
|
+
* supervisor) spawns and supervises. NOT a user-facing entry point (no
|
|
5
|
+
* "bin" entry in package.json) — the daemon always invokes this directly by
|
|
6
|
+
* path, either `node bin/failproofai-worker.mjs` (dev, via
|
|
7
|
+
* FAILPROOFAI_WORKER_CMD) or the built `dist/worker.mjs` in production.
|
|
8
|
+
*
|
|
9
|
+
* Reads FAILPROOFAI_WORKER_SOCKET for where to listen — the daemon resolves
|
|
10
|
+
* and passes this; the worker never guesses a path of its own.
|
|
11
|
+
*/
|
|
12
|
+
import { realpathSync } from "node:fs";
|
|
13
|
+
import { dirname, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
if (!process.env.FAILPROOFAI_PACKAGE_ROOT) {
|
|
17
|
+
process.env.FAILPROOFAI_PACKAGE_ROOT = resolve(
|
|
18
|
+
dirname(realpathSync(fileURLToPath(import.meta.url))),
|
|
19
|
+
".."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!process.env.FAILPROOFAI_DIST_PATH) {
|
|
24
|
+
process.env.FAILPROOFAI_DIST_PATH = resolve(
|
|
25
|
+
dirname(realpathSync(fileURLToPath(import.meta.url))),
|
|
26
|
+
"..",
|
|
27
|
+
"dist"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const socketPath = process.env.FAILPROOFAI_WORKER_SOCKET;
|
|
32
|
+
if (!socketPath) {
|
|
33
|
+
console.error("[failproofai-worker] FAILPROOFAI_WORKER_SOCKET is not set");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { startWorkerServer } = await import("../src/hooks/worker-server");
|
|
38
|
+
const server = startWorkerServer(socketPath);
|
|
39
|
+
|
|
40
|
+
server.on("error", (err) => {
|
|
41
|
+
console.error(`[failproofai-worker] server error: ${err.message}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
console.error(`[failproofai-worker] listening on ${socketPath}`);
|
|
46
|
+
|
|
47
|
+
function shutdown() {
|
|
48
|
+
server.close(() => process.exit(0));
|
|
49
|
+
}
|
|
50
|
+
process.on("SIGTERM", shutdown);
|
|
51
|
+
process.on("SIGINT", shutdown);
|
package/bin/failproofai.mjs
CHANGED
|
@@ -62,6 +62,32 @@ async function track(name, props) {
|
|
|
62
62
|
} catch {}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Exits only once stdout/stderr have actually been flushed.
|
|
67
|
+
*
|
|
68
|
+
* Under every agent CLI a hook's stdout is a pipe, and pipe writes in Node
|
|
69
|
+
* are asynchronous — `process.exit()` terminates without draining what's
|
|
70
|
+
* still buffered. That stdout carries the decision payload, so a truncated
|
|
71
|
+
* write silently changes the decision the CLI observes; on the fail-closed
|
|
72
|
+
* path it would drop the deny reason entirely and leave the CLI with a bare
|
|
73
|
+
* exit code and no explanation.
|
|
74
|
+
*/
|
|
75
|
+
async function exitAfterFlush(code) {
|
|
76
|
+
const drain = (stream) =>
|
|
77
|
+
new Promise((resolveDrain) => {
|
|
78
|
+
// A zero-length write's callback still queues behind everything
|
|
79
|
+
// already buffered, so this resolves after the real output lands.
|
|
80
|
+
if (stream.writableLength === 0 || stream.destroyed) resolveDrain();
|
|
81
|
+
else stream.write("", () => resolveDrain());
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
await Promise.all([drain(process.stdout), drain(process.stderr)]);
|
|
85
|
+
} catch {
|
|
86
|
+
// Never let a flush problem swallow the exit code itself.
|
|
87
|
+
}
|
|
88
|
+
process.exit(code);
|
|
89
|
+
}
|
|
90
|
+
|
|
65
91
|
// --hook <event> [--cli <name>] — called by an agent CLI hook; fast path, outside
|
|
66
92
|
// runCli() because it has its own exit code contract with the calling agent.
|
|
67
93
|
const hookIdx = args.indexOf("--hook");
|
|
@@ -94,11 +120,52 @@ if (hookIdx >= 0) {
|
|
|
94
120
|
? cliArg
|
|
95
121
|
: "claude";
|
|
96
122
|
try {
|
|
123
|
+
// Daemon-aware path — inert (and this whole block skipped) on every
|
|
124
|
+
// machine until `failproofai config` has installed failproofaid AND
|
|
125
|
+
// written the daemonConfigured marker (Stage 4). Until then this is
|
|
126
|
+
// byte-for-byte the same handleHookEvent(...) call below.
|
|
127
|
+
const { isDaemonConfigured, tryDaemonHook } = await import("../src/hooks/daemon-client");
|
|
128
|
+
if (isDaemonConfigured()) {
|
|
129
|
+
const { readStdinPayload } = await import("../src/hooks/read-stdin");
|
|
130
|
+
const { evaluateHookEvent } = await import("../src/hooks/handler");
|
|
131
|
+
const stdinRead = await readStdinPayload();
|
|
132
|
+
|
|
133
|
+
const daemonResult = await tryDaemonHook({
|
|
134
|
+
hookEvent: eventType,
|
|
135
|
+
cli,
|
|
136
|
+
stdin: stdinRead.payload,
|
|
137
|
+
// The client's own cwd IS the originating CLI session's cwd — this
|
|
138
|
+
// process is spawned fresh, at that location, by the calling agent
|
|
139
|
+
// CLI's own hook mechanism. See daemon-client.ts / PROTOCOL.md.
|
|
140
|
+
cwd: process.cwd(),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// No silent fallback to in-process evaluation here: this machine was
|
|
144
|
+
// explicitly configured to run through the daemon, so an unreachable
|
|
145
|
+
// daemon fails closed instead — a loud, correctly-shaped deny (real
|
|
146
|
+
// per-CLI response shaping, not a generic denial) rather than a
|
|
147
|
+
// silent downgrade. See the plan's "Confirmed scope decisions".
|
|
148
|
+
const result =
|
|
149
|
+
daemonResult ??
|
|
150
|
+
(await evaluateHookEvent(eventType, cli, stdinRead.payload, {
|
|
151
|
+
forceDecision: {
|
|
152
|
+
decision: "deny",
|
|
153
|
+
reason:
|
|
154
|
+
"failproofaid could not be reached. This machine is configured to run hooks through it " +
|
|
155
|
+
"— check the daemon (see `failproofai config`) rather than retrying blindly.",
|
|
156
|
+
},
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
160
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
161
|
+
await exitAfterFlush(result.exitCode);
|
|
162
|
+
}
|
|
163
|
+
|
|
97
164
|
const { handleHookEvent } = await import("../src/hooks/handler");
|
|
98
165
|
const exitCode = await handleHookEvent(eventType, cli);
|
|
99
166
|
// handleHookEvent already flushes its own telemetry before returning; this
|
|
100
167
|
// is the normal, reliable exit.
|
|
101
|
-
|
|
168
|
+
await exitAfterFlush(exitCode);
|
|
102
169
|
} catch (err) {
|
|
103
170
|
const msg = err instanceof Error ? err.message : String(err);
|
|
104
171
|
await track("hook_dispatch_error", {
|