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
package/src/hooks/handler.ts
CHANGED
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
ANTIGRAVITY_EVENT_MAP,
|
|
26
26
|
} from "./types";
|
|
27
27
|
import { canonicalizeToolName, canonicalizeToolInput } from "./tool-name-canonicalize";
|
|
28
|
-
import
|
|
28
|
+
import { normalizeCliPayload } from "./normalize-cli-payload";
|
|
29
|
+
import type { PolicyFunction, PolicyResult, HooksConfig } from "./policy-types";
|
|
29
30
|
import { readMergedHooksConfig } from "./hooks-config";
|
|
30
31
|
import { registerBuiltinPolicies } from "./builtin-policies";
|
|
31
32
|
import { evaluatePolicies } from "./policy-evaluator";
|
|
@@ -39,6 +40,7 @@ import { resolvePermissionMode } from "./resolve-permission-mode";
|
|
|
39
40
|
import { resolveTranscriptPath } from "./resolve-transcript-path";
|
|
40
41
|
import { getInstanceId } from "../../lib/telemetry-id";
|
|
41
42
|
import { hookLogInfo, hookLogWarn } from "./hook-logger";
|
|
43
|
+
import { readStdinPayload } from "./read-stdin";
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Canonicalize an event name to PascalCase. Codex sends snake_case event names
|
|
@@ -49,8 +51,11 @@ import { hookLogInfo, hookLogWarn } from "./hook-logger";
|
|
|
49
51
|
* NAMES arrive PascalCase already (note: tool names are a separate matter and
|
|
50
52
|
* are canonicalized in canonicalizeToolName below). The internal
|
|
51
53
|
* registry, builtin policies, and policy.match.events all key on PascalCase.
|
|
54
|
+
*
|
|
55
|
+
* Exported so fail-closed.ts can produce the same canonical event type
|
|
56
|
+
* without duplicating this per-CLI mapping.
|
|
52
57
|
*/
|
|
53
|
-
function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType {
|
|
58
|
+
export function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType {
|
|
54
59
|
if (cli === "codex") {
|
|
55
60
|
const mapped = CODEX_EVENT_MAP[raw as CodexHookEventType];
|
|
56
61
|
if (mapped) return mapped;
|
|
@@ -87,115 +92,79 @@ function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType
|
|
|
87
92
|
return raw as HookEventType;
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
export
|
|
95
|
+
export interface HookEventOutcome {
|
|
96
|
+
exitCode: number;
|
|
97
|
+
stdout: string;
|
|
98
|
+
stderr: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface EvaluateHookEventOptions {
|
|
102
|
+
/**
|
|
103
|
+
* Await pending telemetry POSTs before returning. Defaults to `true`,
|
|
104
|
+
* matching the one-shot CLI process's need to flush before `process.exit()`
|
|
105
|
+
* drops anything in flight. The warm worker (which never exits between
|
|
106
|
+
* calls) passes `false` — telemetry simply resolves on its own schedule,
|
|
107
|
+
* a real latency win unlocked by the process staying alive.
|
|
108
|
+
*/
|
|
109
|
+
awaitTelemetryFlush?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* When set, skips loading policies-config.json / builtins / custom hooks
|
|
112
|
+
* entirely and registers ONLY a single synthetic policy that always
|
|
113
|
+
* returns this decision — then runs the real, unmodified
|
|
114
|
+
* `evaluatePolicies()` so the response still gets this event/CLI's exact
|
|
115
|
+
* per-CLI shaping (Cursor's flat `continue:false`, Factory's exit-2, …).
|
|
116
|
+
* This is the fail-closed path: reusing the real evaluator for shaping
|
|
117
|
+
* instead of hand-rolling a generic denial avoids a decision that *looks*
|
|
118
|
+
* like protection but is silently inert for at least one CLI.
|
|
119
|
+
*/
|
|
120
|
+
forceDecision?: { decision: "deny"; reason: string };
|
|
121
|
+
/**
|
|
122
|
+
* Used only when the stdin payload itself carries no `cwd` (and, for
|
|
123
|
+
* Cursor, no `workspace_roots`) — see `resolveCwd`. Callers running
|
|
124
|
+
* inside a long-lived process (the warm worker) MUST pass the
|
|
125
|
+
* *originating* CLI process's cwd here rather than leaving this unset:
|
|
126
|
+
* `readMergedHooksConfig`/`loadAllCustomHooks` fall back to
|
|
127
|
+
* `process.cwd()` internally when `session.cwd` is undefined, and inside
|
|
128
|
+
* a warm worker `process.cwd()` is fixed at wherever the worker itself
|
|
129
|
+
* was spawned — not wherever this particular request's session actually
|
|
130
|
+
* is. The one-shot CLI process doesn't need this: its own `process.cwd()`
|
|
131
|
+
* already IS the right value, which is exactly what that fallback was
|
|
132
|
+
* written for.
|
|
133
|
+
*/
|
|
134
|
+
fallbackCwd?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The core hook-evaluation logic, decoupled from process stdin/stdout so it
|
|
139
|
+
* can be called repeatedly inside a long-lived process (the daemon's warm
|
|
140
|
+
* worker) as well as from the one-shot `handleHookEvent` wrapper below.
|
|
141
|
+
* Never reads `process.stdin` or writes `process.stdout`/`process.stderr`
|
|
142
|
+
* itself — every caller gets the full `{exitCode, stdout, stderr}` back and
|
|
143
|
+
* decides what to do with it.
|
|
144
|
+
*/
|
|
145
|
+
export async function evaluateHookEvent(
|
|
91
146
|
eventType: string,
|
|
92
147
|
cli: IntegrationType = "claude",
|
|
93
|
-
|
|
148
|
+
stdinPayload: string,
|
|
149
|
+
opts?: EvaluateHookEventOptions,
|
|
150
|
+
): Promise<HookEventOutcome> {
|
|
94
151
|
const startTime = performance.now();
|
|
95
152
|
try {
|
|
96
|
-
|
|
97
|
-
// Read stdin payload (Claude passes JSON)
|
|
98
|
-
const MAX_STDIN_BYTES = 1_048_576; // 1 MB
|
|
99
|
-
let payload = "";
|
|
100
|
-
let stdinOversized = false;
|
|
101
|
-
try {
|
|
102
|
-
payload = await new Promise<string>((resolve, reject) => {
|
|
103
|
-
const chunks: string[] = [];
|
|
104
|
-
let totalBytes = 0;
|
|
105
|
-
process.stdin.setEncoding("utf8");
|
|
106
|
-
process.stdin.on("data", (chunk: string) => {
|
|
107
|
-
totalBytes += Buffer.byteLength(chunk);
|
|
108
|
-
if (totalBytes > MAX_STDIN_BYTES) {
|
|
109
|
-
hookLogWarn(`stdin payload exceeds 1 MB for ${eventType}, discarding`);
|
|
110
|
-
stdinOversized = true;
|
|
111
|
-
process.stdin.destroy();
|
|
112
|
-
resolve("");
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
chunks.push(chunk);
|
|
116
|
-
});
|
|
117
|
-
process.stdin.on("end", () => resolve(chunks.join("")));
|
|
118
|
-
process.stdin.on("error", reject);
|
|
119
|
-
// If stdin is already closed or not piped, resolve immediately
|
|
120
|
-
if (process.stdin.readableEnded) resolve("");
|
|
121
|
-
});
|
|
122
|
-
} catch (err) {
|
|
123
|
-
hookLogWarn(`stdin read failed for ${eventType}`);
|
|
124
|
-
void trackHookEvent(getInstanceId(), "hook_stdin_error", {
|
|
125
|
-
event_type: eventType,
|
|
126
|
-
cli,
|
|
127
|
-
error_type: err instanceof Error ? err.name : "unknown",
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
if (stdinOversized) {
|
|
131
|
-
void trackHookEvent(getInstanceId(), "hook_stdin_error", {
|
|
132
|
-
event_type: eventType,
|
|
133
|
-
cli,
|
|
134
|
-
error_type: "oversized",
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
153
|
let parsed: Record<string, unknown> = {};
|
|
139
|
-
if (
|
|
154
|
+
if (stdinPayload) {
|
|
140
155
|
try {
|
|
141
|
-
parsed = JSON.parse(
|
|
156
|
+
parsed = JSON.parse(stdinPayload) as Record<string, unknown>;
|
|
142
157
|
} catch {
|
|
143
|
-
hookLogWarn(`payload parse failed for ${eventType} (${
|
|
158
|
+
hookLogWarn(`payload parse failed for ${eventType} (${stdinPayload.length} bytes)`);
|
|
144
159
|
void trackHookEvent(getInstanceId(), "hook_payload_parse_error", {
|
|
145
160
|
event_type: eventType,
|
|
146
161
|
cli,
|
|
147
|
-
payload_size:
|
|
162
|
+
payload_size: stdinPayload.length,
|
|
148
163
|
});
|
|
149
164
|
}
|
|
150
165
|
}
|
|
151
166
|
|
|
152
|
-
|
|
153
|
-
// the handler downstream reads to canonical snake_case BEFORE any
|
|
154
|
-
// canonicalization runs. `toolCall.{name,args}` → `tool_name`/`tool_input`,
|
|
155
|
-
// `conversationId` → `session_id`, `workspacePaths[0]` → `cwd`,
|
|
156
|
-
// `transcriptPath` → `transcript_path`. Verified against agy v1.1.2.
|
|
157
|
-
if (cli === "antigravity") {
|
|
158
|
-
const tc = parsed.toolCall as { name?: string; args?: unknown } | undefined;
|
|
159
|
-
if (tc && typeof tc === "object") {
|
|
160
|
-
if (tc.name !== undefined) parsed.tool_name = tc.name;
|
|
161
|
-
if (tc.args !== undefined) parsed.tool_input = tc.args;
|
|
162
|
-
}
|
|
163
|
-
if (typeof parsed.conversationId === "string") parsed.session_id = parsed.conversationId;
|
|
164
|
-
if (Array.isArray(parsed.workspacePaths) && typeof parsed.workspacePaths[0] === "string") {
|
|
165
|
-
parsed.cwd = parsed.workspacePaths[0];
|
|
166
|
-
}
|
|
167
|
-
if (typeof parsed.transcriptPath === "string") parsed.transcript_path = parsed.transcriptPath;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Copilot's snake_case events (PreToolUse/PostToolUse/Stop/…) are already
|
|
171
|
-
// Claude-shaped, but `permissionRequest` alone pipes a camelCase payload
|
|
172
|
-
// (`hookName`, `sessionId`, `toolName` in lowercase, `toolInput`) — verified
|
|
173
|
-
// live against Copilot CLI 1.0.71. Normalize the fields the handler reads so
|
|
174
|
-
// PermissionRequest-matched policies (e.g. block-sudo's Codex-escalation
|
|
175
|
-
// guard) fire instead of seeing a null tool name.
|
|
176
|
-
if (cli === "copilot") {
|
|
177
|
-
if (typeof parsed.toolName === "string" && parsed.tool_name === undefined) {
|
|
178
|
-
parsed.tool_name = parsed.toolName;
|
|
179
|
-
}
|
|
180
|
-
if (parsed.toolInput !== undefined && parsed.tool_input === undefined) {
|
|
181
|
-
parsed.tool_input = parsed.toolInput;
|
|
182
|
-
}
|
|
183
|
-
if (typeof parsed.sessionId === "string" && parsed.session_id === undefined) {
|
|
184
|
-
parsed.session_id = parsed.sessionId;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Goose pipes `event` / `working_dir` instead of Claude's `hook_event_name` /
|
|
189
|
-
// `cwd` (its `tool_name` / `tool_input` are already canonical field names).
|
|
190
|
-
// Normalize both so resolveCwd keeps its cwd (block-read-outside-cwd) and the
|
|
191
|
-
// round-tripped event name is available. The --hook arg is already PascalCase,
|
|
192
|
-
// so canonicalizeEventType needs no goose branch. Verified goose v1.43.0.
|
|
193
|
-
if (cli === "goose") {
|
|
194
|
-
if (typeof parsed.working_dir === "string") parsed.cwd = parsed.working_dir;
|
|
195
|
-
if (typeof parsed.event === "string" && parsed.hook_event_name === undefined) {
|
|
196
|
-
parsed.hook_event_name = parsed.event;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
167
|
+
normalizeCliPayload(cli, parsed);
|
|
199
168
|
|
|
200
169
|
// Canonicalize event name (Codex sends snake_case; internals expect PascalCase)
|
|
201
170
|
const canonicalEventType = canonicalizeEventType(eventType, cli);
|
|
@@ -226,7 +195,7 @@ export async function handleHookEvent(
|
|
|
226
195
|
const session: SessionMetadata = {
|
|
227
196
|
sessionId,
|
|
228
197
|
transcriptPath: resolveTranscriptPath(cli, parsed, sessionId),
|
|
229
|
-
cwd: resolveCwd(cli, parsed),
|
|
198
|
+
cwd: resolveCwd(cli, parsed) ?? opts?.fallbackCwd,
|
|
230
199
|
permissionMode: resolvePermissionMode(cli, parsed, sessionId),
|
|
231
200
|
hookEventName: parsed.hook_event_name as string | undefined,
|
|
232
201
|
// Preserve the raw CLI-side event name (eventType arg) before
|
|
@@ -237,108 +206,117 @@ export async function handleHookEvent(
|
|
|
237
206
|
cli,
|
|
238
207
|
};
|
|
239
208
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
registerBuiltinPolicies(config.enabledPolicies);
|
|
244
|
-
|
|
245
|
-
// Load and register custom hooks (layer 2, after builtins)
|
|
246
|
-
const loadResult = await loadAllCustomHooks(
|
|
247
|
-
config.customPoliciesPaths ?? config.customPoliciesPath,
|
|
248
|
-
{
|
|
249
|
-
sessionCwd: session.cwd,
|
|
250
|
-
customPoliciesEnabled: config.customPoliciesEnabled,
|
|
251
|
-
},
|
|
252
|
-
);
|
|
253
|
-
const customHooksList = loadResult.hooks;
|
|
254
|
-
const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []);
|
|
255
|
-
const conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
|
|
209
|
+
let config: HooksConfig;
|
|
210
|
+
let customHooksList: CustomHook[] = [];
|
|
211
|
+
let conventionHookNames = new Set<string>();
|
|
256
212
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
try {
|
|
266
|
-
const result = await Promise.race([
|
|
267
|
-
hook.fn(ctx),
|
|
268
|
-
new Promise<PolicyResult>((_, reject) =>
|
|
269
|
-
setTimeout(() => reject(new Error("timeout")), 10_000),
|
|
270
|
-
),
|
|
271
|
-
]);
|
|
272
|
-
return result;
|
|
273
|
-
} catch (err) {
|
|
274
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
275
|
-
const isTimeout = msg === "timeout";
|
|
276
|
-
hookLogWarn(`${prefix} hook "${hookName}" failed: ${msg}`);
|
|
277
|
-
void trackHookEvent(getInstanceId(), "custom_hook_error", {
|
|
278
|
-
hook_name: hookName,
|
|
279
|
-
error_type: isTimeout ? "timeout" : "exception",
|
|
280
|
-
event_type: eventType,
|
|
281
|
-
cli,
|
|
282
|
-
is_convention_policy: isConvention,
|
|
283
|
-
convention_scope: conventionScope ?? null,
|
|
284
|
-
});
|
|
285
|
-
return { decision: "allow" };
|
|
286
|
-
}
|
|
287
|
-
};
|
|
213
|
+
if (opts?.forceDecision) {
|
|
214
|
+
// Fail-closed: no config/custom-hook loading at all — a daemon that
|
|
215
|
+
// can't be reached can't have run any of a project's custom policies
|
|
216
|
+
// either, so there is nothing to load. One synthetic policy stands in
|
|
217
|
+
// for the entire enabled set.
|
|
218
|
+
config = { enabledPolicies: [] };
|
|
219
|
+
clearPolicies();
|
|
220
|
+
const decision = opts.forceDecision;
|
|
288
221
|
registerPolicy(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
-1, // Custom hooks run after builtins (priority 0)
|
|
222
|
+
"failproofai/daemon-unreachable",
|
|
223
|
+
"Fail-closed: the failproofaid daemon could not be reached for this daemon-configured scope.",
|
|
224
|
+
async (): Promise<PolicyResult> => ({ decision: decision.decision, reason: decision.reason }),
|
|
225
|
+
{},
|
|
294
226
|
);
|
|
295
|
-
}
|
|
227
|
+
} else {
|
|
228
|
+
// Load enabled policies (merge across project/local/global scopes)
|
|
229
|
+
config = readMergedHooksConfig(session.cwd);
|
|
230
|
+
clearPolicies();
|
|
231
|
+
registerBuiltinPolicies(config.enabledPolicies);
|
|
296
232
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
custom_hooks_count: customHooksList.length,
|
|
302
|
-
custom_hook_names: customHooksList.map((h) => h.name),
|
|
303
|
-
event_types_covered: [...new Set(customHooksList.flatMap((h) => h.match?.events ?? []))],
|
|
233
|
+
// Load and register custom hooks (layer 2, after builtins)
|
|
234
|
+
const loadResult = await loadAllCustomHooks(config.customPoliciesPaths ?? config.customPoliciesPath, {
|
|
235
|
+
sessionCwd: session.cwd,
|
|
236
|
+
customPoliciesEnabled: config.customPoliciesEnabled,
|
|
304
237
|
});
|
|
305
|
-
|
|
238
|
+
customHooksList = loadResult.hooks;
|
|
239
|
+
const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []);
|
|
240
|
+
conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
|
|
306
241
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
242
|
+
for (const hook of customHooksList) {
|
|
243
|
+
const policyId = (hook as CustomHook & { __policyId?: string }).__policyId;
|
|
244
|
+
if (policyId && disabledCustomPolicies.has(policyId)) continue;
|
|
245
|
+
const hookName = hook.name;
|
|
246
|
+
const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope;
|
|
247
|
+
const isConvention = !!conventionScope;
|
|
248
|
+
const prefix = isConvention ? `.failproofai-${conventionScope}` : "custom";
|
|
249
|
+
const fn: PolicyFunction = async (ctx): Promise<PolicyResult> => {
|
|
250
|
+
try {
|
|
251
|
+
const result = await Promise.race([
|
|
252
|
+
hook.fn(ctx),
|
|
253
|
+
new Promise<PolicyResult>((_, reject) => setTimeout(() => reject(new Error("timeout")), 10_000)),
|
|
254
|
+
]);
|
|
255
|
+
return result;
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
258
|
+
const isTimeout = msg === "timeout";
|
|
259
|
+
hookLogWarn(`${prefix} hook "${hookName}" failed: ${msg}`);
|
|
260
|
+
void trackHookEvent(getInstanceId(), "custom_hook_error", {
|
|
261
|
+
hook_name: hookName,
|
|
262
|
+
error_type: isTimeout ? "timeout" : "exception",
|
|
263
|
+
event_type: eventType,
|
|
264
|
+
cli,
|
|
265
|
+
is_convention_policy: isConvention,
|
|
266
|
+
convention_scope: conventionScope ?? null,
|
|
267
|
+
});
|
|
268
|
+
return { decision: "allow" };
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
registerPolicy(
|
|
272
|
+
`${prefix}/${hookName}`,
|
|
273
|
+
hook.description ?? "",
|
|
274
|
+
fn,
|
|
275
|
+
hook.match ?? {},
|
|
276
|
+
-1, // Custom hooks run after builtins (priority 0)
|
|
277
|
+
);
|
|
278
|
+
}
|
|
318
279
|
|
|
319
|
-
|
|
280
|
+
// Fire telemetry once per invocation for custom hook loads
|
|
281
|
+
if (customHooksList.length > 0) {
|
|
282
|
+
void trackHookEvent(getInstanceId(), "custom_hooks_loaded", {
|
|
283
|
+
cli,
|
|
284
|
+
custom_hooks_count: customHooksList.length,
|
|
285
|
+
custom_hook_names: customHooksList.map((h) => h.name),
|
|
286
|
+
event_types_covered: [...new Set(customHooksList.flatMap((h) => h.match?.events ?? []))],
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Fire telemetry for convention-based policy discovery
|
|
291
|
+
if (loadResult.conventionSources.length > 0) {
|
|
292
|
+
void trackHookEvent(getInstanceId(), "convention_policies_loaded", {
|
|
293
|
+
event_type: canonicalEventType,
|
|
294
|
+
cli,
|
|
295
|
+
project_file_count: loadResult.conventionSources.filter((s) => s.scope === "project").length,
|
|
296
|
+
user_file_count: loadResult.conventionSources.filter((s) => s.scope === "user").length,
|
|
297
|
+
convention_hook_count: conventionHookNames.size,
|
|
298
|
+
convention_hook_names: [...conventionHookNames],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
hookLogInfo(
|
|
303
|
+
`event=${canonicalEventType} cli=${cli} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
320
306
|
|
|
321
307
|
// Evaluate policies (use canonical PascalCase event type)
|
|
322
308
|
const result = await evaluatePolicies(canonicalEventType, parsed, session, config);
|
|
323
309
|
const durationMs = Math.round(performance.now() - startTime);
|
|
324
310
|
hookLogInfo(`result=${result.decision} policy=${result.policyName ?? "none"} duration=${durationMs}ms`);
|
|
325
311
|
|
|
326
|
-
if (result.stdout) {
|
|
327
|
-
process.stdout.write(result.stdout);
|
|
328
|
-
}
|
|
329
|
-
if (result.stderr) {
|
|
330
|
-
process.stderr.write(result.stderr);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
312
|
// Which policies actually ran for this event, regardless of how they
|
|
334
313
|
// decided. `result.policyName` names only the decider — null on a plain
|
|
335
314
|
// allow — so without this a row cannot tell "your policy ran and allowed"
|
|
336
315
|
// from "no policy covers this event". The lookup is the same cached call
|
|
337
316
|
// the evaluator already made, so it costs nothing.
|
|
338
|
-
const matchedPolicies = getPoliciesForEvent(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
).map((p) => p.name);
|
|
317
|
+
const matchedPolicies = getPoliciesForEvent(canonicalEventType, parsed.tool_name as string | undefined).map(
|
|
318
|
+
(p) => p.name,
|
|
319
|
+
);
|
|
342
320
|
|
|
343
321
|
// Persist activity to disk (visible in /policies activity tab)
|
|
344
322
|
const activityEntry = {
|
|
@@ -370,15 +348,15 @@ export async function handleHookEvent(
|
|
|
370
348
|
const isCustomHook = result.policyName?.startsWith("custom/") ?? false;
|
|
371
349
|
const isConventionPolicy = result.policyName?.startsWith(".failproofai-") ?? false;
|
|
372
350
|
const conventionScope = isConventionPolicy
|
|
373
|
-
? result.policyName!.match(/^\.failproofai-(project|user)\//)?.[1] ?? null
|
|
351
|
+
? (result.policyName!.match(/^\.failproofai-(project|user)\//)?.[1] ?? null)
|
|
374
352
|
: null;
|
|
375
353
|
const hasCustomParams =
|
|
376
|
-
!isCustomHook &&
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
354
|
+
!isCustomHook &&
|
|
355
|
+
!isConventionPolicy &&
|
|
356
|
+
!!(result.policyName && config.policyParams?.[result.policyName]);
|
|
357
|
+
const paramKeysOverridden = hasCustomParams ? Object.keys(config.policyParams![result.policyName!]) : [];
|
|
380
358
|
const distinctId = getInstanceId();
|
|
381
|
-
|
|
359
|
+
const trackPromise = trackHookEvent(distinctId, "hook_policy_triggered", {
|
|
382
360
|
event_type: canonicalEventType,
|
|
383
361
|
cli,
|
|
384
362
|
tool_name: (parsed.tool_name as string) ?? null,
|
|
@@ -390,17 +368,75 @@ export async function handleHookEvent(
|
|
|
390
368
|
has_custom_params: hasCustomParams,
|
|
391
369
|
param_keys_overridden: paramKeysOverridden,
|
|
392
370
|
});
|
|
371
|
+
// Deny/instruct is exactly the response the daemon's warm worker
|
|
372
|
+
// needs to return fast — a live network POST to PostHog on this path
|
|
373
|
+
// (previously always awaited here, regardless of opts.awaitTelemetryFlush)
|
|
374
|
+
// made every deny/instruct decision pay a real network round-trip
|
|
375
|
+
// (hundreds of ms, up to sendEvent's 5s abort timeout when PostHog is
|
|
376
|
+
// unreachable) before the result could return. Caught by a real
|
|
377
|
+
// Docker daemon test: a `sudo` deny took ~700ms even against an
|
|
378
|
+
// already-warm worker and blew through the client's 150ms
|
|
379
|
+
// fail-closed budget every time. Same opt-out this file already
|
|
380
|
+
// grants `flushHookTelemetry()` below — the worker passes
|
|
381
|
+
// awaitTelemetryFlush:false and never calls that flush either, so
|
|
382
|
+
// this becomes true fire-and-forget for it.
|
|
383
|
+
if (opts?.awaitTelemetryFlush ?? true) {
|
|
384
|
+
await trackPromise;
|
|
385
|
+
}
|
|
393
386
|
} catch {
|
|
394
387
|
// Telemetry is best-effort — never block the hook
|
|
395
388
|
}
|
|
396
389
|
}
|
|
397
|
-
|
|
390
|
+
|
|
391
|
+
return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
|
|
398
392
|
} finally {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
393
|
+
if (opts?.awaitTelemetryFlush ?? true) {
|
|
394
|
+
// Await any un-awaited (`void trackHookEvent(...)`) events fired during
|
|
395
|
+
// this invocation. The one-shot CLI process calls process.exit() the
|
|
396
|
+
// moment it gets its result back, which would otherwise drop in-flight
|
|
397
|
+
// POSTs — notably on the allow path (no trailing awaited event) and on
|
|
398
|
+
// any early throw (custom-hook load / policy eval) before the happy
|
|
399
|
+
// path is reached. The warm worker passes awaitTelemetryFlush:false —
|
|
400
|
+
// it never exits between calls, so there's nothing to drop.
|
|
401
|
+
await flushHookTelemetry();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* One-shot CLI entrypoint — reads `process.stdin`, evaluates the hook, writes
|
|
408
|
+
* `process.stdout`/`process.stderr`, and returns just the exit code. This is
|
|
409
|
+
* the exact contract `bin/failproofai.mjs`'s `--hook` path has always had;
|
|
410
|
+
* kept unchanged so nothing about the one-shot (non-daemon) path regresses.
|
|
411
|
+
* Internally now just a thin wrapper around `evaluateHookEvent`.
|
|
412
|
+
*/
|
|
413
|
+
export async function handleHookEvent(eventType: string, cli: IntegrationType = "claude"): Promise<number> {
|
|
414
|
+
const MAX_STDIN_BYTES = 1_048_576; // 1 MB
|
|
415
|
+
const stdinRead = await readStdinPayload(MAX_STDIN_BYTES);
|
|
416
|
+
if (stdinRead.readError) {
|
|
417
|
+
hookLogWarn(`stdin read failed for ${eventType}`);
|
|
418
|
+
void trackHookEvent(getInstanceId(), "hook_stdin_error", {
|
|
419
|
+
event_type: eventType,
|
|
420
|
+
cli,
|
|
421
|
+
error_type: "unknown",
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (stdinRead.oversized) {
|
|
425
|
+
hookLogWarn(`stdin payload exceeds 1 MB for ${eventType}, discarding`);
|
|
426
|
+
void trackHookEvent(getInstanceId(), "hook_stdin_error", {
|
|
427
|
+
event_type: eventType,
|
|
428
|
+
cli,
|
|
429
|
+
error_type: "oversized",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const result = await evaluateHookEvent(eventType, cli, stdinRead.payload);
|
|
434
|
+
|
|
435
|
+
if (result.stdout) {
|
|
436
|
+
process.stdout.write(result.stdout);
|
|
437
|
+
}
|
|
438
|
+
if (result.stderr) {
|
|
439
|
+
process.stderr.write(result.stderr);
|
|
405
440
|
}
|
|
441
|
+
return result.exitCode;
|
|
406
442
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-CLI payload field normalization, applied in place BEFORE any
|
|
3
|
+
* canonicalization or session-metadata resolution runs.
|
|
4
|
+
*
|
|
5
|
+
* Extracted out of handler.ts so the fail-closed evaluator (fail-closed.ts)
|
|
6
|
+
* can produce a correctly-shaped decision without duplicating a second copy
|
|
7
|
+
* of these CLI-specific field mappings — the exact quirks below took real
|
|
8
|
+
* verification effort against each CLI's live payload shape (see CLAUDE.md),
|
|
9
|
+
* so they must stay described in exactly one place.
|
|
10
|
+
*/
|
|
11
|
+
import type { IntegrationType } from "./types";
|
|
12
|
+
|
|
13
|
+
export function normalizeCliPayload(cli: IntegrationType, parsed: Record<string, unknown>): void {
|
|
14
|
+
// Antigravity (agy) pipes a camelCase protojson payload; normalize the fields
|
|
15
|
+
// the handler downstream reads to canonical snake_case BEFORE any
|
|
16
|
+
// canonicalization runs. `toolCall.{name,args}` → `tool_name`/`tool_input`,
|
|
17
|
+
// `conversationId` → `session_id`, `workspacePaths[0]` → `cwd`,
|
|
18
|
+
// `transcriptPath` → `transcript_path`. Verified against agy v1.1.2.
|
|
19
|
+
if (cli === "antigravity") {
|
|
20
|
+
const tc = parsed.toolCall as { name?: string; args?: unknown } | undefined;
|
|
21
|
+
if (tc && typeof tc === "object") {
|
|
22
|
+
if (tc.name !== undefined) parsed.tool_name = tc.name;
|
|
23
|
+
if (tc.args !== undefined) parsed.tool_input = tc.args;
|
|
24
|
+
}
|
|
25
|
+
if (typeof parsed.conversationId === "string") parsed.session_id = parsed.conversationId;
|
|
26
|
+
if (Array.isArray(parsed.workspacePaths) && typeof parsed.workspacePaths[0] === "string") {
|
|
27
|
+
parsed.cwd = parsed.workspacePaths[0];
|
|
28
|
+
}
|
|
29
|
+
if (typeof parsed.transcriptPath === "string") parsed.transcript_path = parsed.transcriptPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Copilot's snake_case events (PreToolUse/PostToolUse/Stop/…) are already
|
|
33
|
+
// Claude-shaped, but `permissionRequest` alone pipes a camelCase payload
|
|
34
|
+
// (`hookName`, `sessionId`, `toolName` in lowercase, `toolInput`) — verified
|
|
35
|
+
// live against Copilot CLI 1.0.71. Normalize the fields the handler reads so
|
|
36
|
+
// PermissionRequest-matched policies (e.g. block-sudo's Codex-escalation
|
|
37
|
+
// guard) fire instead of seeing a null tool name.
|
|
38
|
+
if (cli === "copilot") {
|
|
39
|
+
if (typeof parsed.toolName === "string" && parsed.tool_name === undefined) {
|
|
40
|
+
parsed.tool_name = parsed.toolName;
|
|
41
|
+
}
|
|
42
|
+
if (parsed.toolInput !== undefined && parsed.tool_input === undefined) {
|
|
43
|
+
parsed.tool_input = parsed.toolInput;
|
|
44
|
+
}
|
|
45
|
+
if (typeof parsed.sessionId === "string" && parsed.session_id === undefined) {
|
|
46
|
+
parsed.session_id = parsed.sessionId;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Goose pipes `event` / `working_dir` instead of Claude's `hook_event_name` /
|
|
51
|
+
// `cwd` (its `tool_name` / `tool_input` are already canonical field names).
|
|
52
|
+
// Normalize both so resolveCwd keeps its cwd (block-read-outside-cwd) and the
|
|
53
|
+
// round-tripped event name is available. The --hook arg is already PascalCase,
|
|
54
|
+
// so canonicalizeEventType needs no goose branch. Verified goose v1.43.0.
|
|
55
|
+
if (cli === "goose") {
|
|
56
|
+
if (typeof parsed.working_dir === "string") parsed.cwd = parsed.working_dir;
|
|
57
|
+
if (typeof parsed.event === "string" && parsed.hook_event_name === undefined) {
|
|
58
|
+
parsed.hook_event_name = parsed.event;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -116,6 +116,21 @@ export interface HooksConfig {
|
|
|
116
116
|
* locking would corrupt the file that governs enforcement.
|
|
117
117
|
*/
|
|
118
118
|
conventionPolicies?: ConventionPolicyRecord[];
|
|
119
|
+
/**
|
|
120
|
+
* `true` once `failproofai config` has installed and started the
|
|
121
|
+
* failproofaid background daemon for this machine. Global scope ONLY —
|
|
122
|
+
* whether *this specific machine* has a running daemon is not something a
|
|
123
|
+
* committed project config should be able to assert on a teammate's
|
|
124
|
+
* behalf, so `readMergedHooksConfig` does not merge this key across
|
|
125
|
+
* scopes the way `enabledPolicies` merges. See `daemon-client.ts`.
|
|
126
|
+
*
|
|
127
|
+
* Governs the fail-closed/in-process split entirely: unset (or `false`)
|
|
128
|
+
* means this machine has never been daemon-configured, so hooks run the
|
|
129
|
+
* same full in-process evaluation they always have, with no socket
|
|
130
|
+
* attempt at all. Once `true`, an unreachable daemon fails closed instead
|
|
131
|
+
* of silently falling back — see the plan's "Confirmed scope decisions".
|
|
132
|
+
*/
|
|
133
|
+
daemonConfigured?: boolean;
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
/** One convention policy file and the hooks it registered, as last observed. */
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the hook payload off `process.stdin`, capped at `maxBytes`.
|
|
3
|
+
*
|
|
4
|
+
* Pure I/O only — no logging, no telemetry. Extracted out of handler.ts so
|
|
5
|
+
* it's reusable and independently testable; callers that need the existing
|
|
6
|
+
* `hook_stdin_error` telemetry-on-failure behavior (currently only the
|
|
7
|
+
* one-shot `handleHookEvent` wrapper) add that themselves from the returned
|
|
8
|
+
* flags. The daemon's warm worker never calls this at all — it receives the
|
|
9
|
+
* payload directly over the socket, already read by the client.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface StdinRead {
|
|
13
|
+
payload: string;
|
|
14
|
+
/** The payload exceeded `maxBytes` and was discarded (payload is `""`). */
|
|
15
|
+
oversized: boolean;
|
|
16
|
+
/** `process.stdin` emitted an `error` event before `end`. */
|
|
17
|
+
readError: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readStdinPayload(maxBytes = 1_048_576): Promise<StdinRead> {
|
|
21
|
+
let payload = "";
|
|
22
|
+
let oversized = false;
|
|
23
|
+
let readError = false;
|
|
24
|
+
try {
|
|
25
|
+
payload = await new Promise<string>((resolve, reject) => {
|
|
26
|
+
const chunks: string[] = [];
|
|
27
|
+
let totalBytes = 0;
|
|
28
|
+
process.stdin.setEncoding("utf8");
|
|
29
|
+
process.stdin.on("data", (chunk: string) => {
|
|
30
|
+
totalBytes += Buffer.byteLength(chunk);
|
|
31
|
+
if (totalBytes > maxBytes) {
|
|
32
|
+
oversized = true;
|
|
33
|
+
process.stdin.destroy();
|
|
34
|
+
resolve("");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
chunks.push(chunk);
|
|
38
|
+
});
|
|
39
|
+
process.stdin.on("end", () => resolve(chunks.join("")));
|
|
40
|
+
process.stdin.on("error", reject);
|
|
41
|
+
// If stdin is already closed or not piped, resolve immediately
|
|
42
|
+
if (process.stdin.readableEnded) resolve("");
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
readError = true;
|
|
46
|
+
}
|
|
47
|
+
return { payload, oversized, readError };
|
|
48
|
+
}
|