failproofai 0.0.15 → 1.0.0-beta.1
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]__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]__0fopait._.js → [root-of-the-server]__1ig795d._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06b4trb._.js → [root-of-the-server]__1yo1gyz._.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 +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_14q3-7b.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/{3m8wmvlhsy1mx.js → 07ix3lb4gib0x.js} +1 -1
- package/.next/standalone/.next/static/chunks/{33rm-y-i4uzvv.js → 0fdx7ikab08-g.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0kf9j0pf_j9-0.js → 191njgoik03fm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2f4mxqkkoa_7d.js → 1b3a1scouf40o.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2geh8s7d3ead-.js → 1d71o1_tav721.js} +1 -1
- package/.next/standalone/.next/static/chunks/{11qhtpk2cvoxv.js → 24gs8ip6_spcb.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1-ng45379-ztb.js → 2ovmv4esqalpb.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1_v8b8mxhk25s.js → 30is-6r00sbd7.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2h7vw-ojgr9fz.js → 35584kn409ad1.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 +1385 -707
- 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 +77 -1
- package/src/hooks/daemon-client.ts +199 -0
- package/src/hooks/daemon-download.ts +152 -0
- package/src/hooks/daemon-service.ts +650 -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/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_ssgManifest.js +0 -0
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "failproofai",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
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",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Built-in security policies for Claude Code hooks.
|
|
3
3
|
*/
|
|
4
4
|
import { resolve, join } from "node:path";
|
|
5
|
+
import { statSync } from "node:fs";
|
|
5
6
|
import { readFile, writeFile, stat, open } from "node:fs/promises";
|
|
6
7
|
import { execSync, execFileSync } from "node:child_process";
|
|
7
8
|
import { homedir } from "node:os";
|
|
@@ -240,22 +241,45 @@ const HELM_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*helm(?:\s|$)/;
|
|
|
240
241
|
// failproofai's own workflow policies depend on them.
|
|
241
242
|
const GH_PIPELINE_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*gh\s+(?:workflow\s+(?:run|enable|disable)|run\s+(?:rerun|cancel)|pr\s+merge|release\s+(?:create|delete)|cache\s+delete|secret\s+(?:set|delete))\b/;
|
|
242
243
|
|
|
243
|
-
// Caches the current branch per cwd to avoid repeated execSync calls
|
|
244
|
-
//
|
|
245
|
-
// the
|
|
246
|
-
//
|
|
247
|
-
|
|
244
|
+
// Caches the current branch per cwd to avoid repeated execSync calls, gated
|
|
245
|
+
// on .git/HEAD's mtime rather than reused unconditionally for the life of
|
|
246
|
+
// the process. In the one-shot-process model that unconditional reuse cost
|
|
247
|
+
// nothing (the cache never outlived a single hook call), but the daemon's
|
|
248
|
+
// warm worker keeps this Map alive across many calls and potentially many
|
|
249
|
+
// different projects' cwds — reusing a branch name forever would silently
|
|
250
|
+
// deny/allow a Stop based on a branch the user checked out an hour ago.
|
|
251
|
+
// git updates .git/HEAD's mtime on every checkout/switch, so a cheap local
|
|
252
|
+
// statSync (no subprocess) is a real, precise invalidation signal. Falls
|
|
253
|
+
// back to always re-fetching (no caching) when .git/HEAD can't be stat'd
|
|
254
|
+
// (worktrees/submodules), matching today's behavior for that case. Bounded
|
|
255
|
+
// at 500 entries so a warm worker touching many projects over its lifetime
|
|
256
|
+
// doesn't grow this unboundedly.
|
|
257
|
+
const gitBranchCache = new Map<string, { branch: string; headMtimeMs: number }>();
|
|
258
|
+
const GIT_BRANCH_CACHE_MAX_ENTRIES = 500;
|
|
259
|
+
|
|
260
|
+
function statGitHeadMtimeMs(cwd: string): number | null {
|
|
261
|
+
try {
|
|
262
|
+
return statSync(join(cwd, ".git", "HEAD")).mtimeMs;
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
248
267
|
|
|
249
268
|
function getCurrentBranch(cwd: string): string | null {
|
|
250
269
|
try {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
270
|
+
const headMtimeMs = statGitHeadMtimeMs(cwd);
|
|
271
|
+
const cached = gitBranchCache.get(cwd);
|
|
272
|
+
if (cached && headMtimeMs !== null && cached.headMtimeMs === headMtimeMs) {
|
|
273
|
+
return cached.branch || null;
|
|
274
|
+
}
|
|
275
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
276
|
+
cwd,
|
|
277
|
+
encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
|
|
278
|
+
timeout: 3000,
|
|
279
|
+
}).trim();
|
|
280
|
+
if (headMtimeMs !== null) {
|
|
281
|
+
if (gitBranchCache.size >= GIT_BRANCH_CACHE_MAX_ENTRIES) gitBranchCache.clear();
|
|
282
|
+
gitBranchCache.set(cwd, { branch, headMtimeMs });
|
|
259
283
|
}
|
|
260
284
|
return branch || null;
|
|
261
285
|
} catch {
|
|
@@ -39,6 +39,13 @@ import { POLICY_PRESETS, resolvePreset, resolveEverything } from "./policy-prese
|
|
|
39
39
|
import { discoverPolicyFiles, findSkippedPolicyFiles } from "./custom-hooks-loader";
|
|
40
40
|
import { trackHookEvent } from "./hook-telemetry";
|
|
41
41
|
import { getInstanceId } from "../../lib/telemetry-id";
|
|
42
|
+
import {
|
|
43
|
+
isDaemonSupportedPlatform,
|
|
44
|
+
installDaemonService,
|
|
45
|
+
daemonServiceFilePath,
|
|
46
|
+
setDaemonConfigured,
|
|
47
|
+
} from "./daemon-service";
|
|
48
|
+
import { hookLogWarn } from "./hook-logger";
|
|
42
49
|
|
|
43
50
|
export interface WizardIO {
|
|
44
51
|
stdin?: TTYIn;
|
|
@@ -263,6 +270,36 @@ export function setCustomPoliciesEnabled(
|
|
|
263
270
|
}
|
|
264
271
|
}
|
|
265
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Maps a daemon-install failure to one of a fixed set of codes, safe to
|
|
275
|
+
* send off the machine.
|
|
276
|
+
*
|
|
277
|
+
* `installDaemonService`'s `reason` is a diagnostic for the local log, not a
|
|
278
|
+
* telemetry field: on most failure paths it is an errno message naming an
|
|
279
|
+
* absolute path under `homedir()`, which carries the OS username. Only the
|
|
280
|
+
* classification travels.
|
|
281
|
+
*/
|
|
282
|
+
export function classifyDaemonInstallFailure(reason: string | undefined): string {
|
|
283
|
+
if (!reason) return "unknown";
|
|
284
|
+
if (/not supported on/.test(reason)) return "unsupported_platform";
|
|
285
|
+
if (/no prebuilt binary for/.test(reason)) return "unsupported_platform";
|
|
286
|
+
if (/binary not found/.test(reason)) return "binary_not_found";
|
|
287
|
+
// The download path is where a machine acquires the daemon at all, so its
|
|
288
|
+
// failures need to be distinguishable: a checksum mismatch is a supply-chain
|
|
289
|
+
// signal, "disabled" is a deliberate air-gapped opt-out, and a plain fetch
|
|
290
|
+
// failure is usually a proxy or an offline box — three very different
|
|
291
|
+
// stories that would otherwise all land in `service_manager_error`.
|
|
292
|
+
// The service is installed system-wide now, so "couldn't become root" is a
|
|
293
|
+
// first-class outcome with a specific remedy (re-run under sudo) rather
|
|
294
|
+
// than an opaque service_manager_error.
|
|
295
|
+
if (/root privileges are required/.test(reason)) return "needs_root";
|
|
296
|
+
if (/checksum mismatch|has no entry for/.test(reason)) return "checksum_mismatch";
|
|
297
|
+
if (/downloads are disabled/.test(reason)) return "downloads_disabled";
|
|
298
|
+
if (/failed to download/.test(reason)) return "download_failed";
|
|
299
|
+
if (/did not reach a running state/.test(reason)) return "did_not_start";
|
|
300
|
+
return "service_manager_error";
|
|
301
|
+
}
|
|
302
|
+
|
|
266
303
|
/**
|
|
267
304
|
* Summarise the custom policy files on disk, for the review screen.
|
|
268
305
|
*
|
|
@@ -322,6 +359,11 @@ export function reviewLines(state: {
|
|
|
322
359
|
lines.push(` Where : ${where}`);
|
|
323
360
|
lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`);
|
|
324
361
|
lines.push(` Policies : ${policies.length} enabled`);
|
|
362
|
+
if (scope === "user" && isDaemonSupportedPlatform()) {
|
|
363
|
+
lines.push(
|
|
364
|
+
` Daemon : failproofaid will be installed/started as a background service`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
325
367
|
|
|
326
368
|
// Reflect the Custom decision, not just what is on disk. Reporting
|
|
327
369
|
// "1 file (project) (auto-loaded)" after the user had just unticked the row
|
|
@@ -347,6 +389,10 @@ export function reviewLines(state: {
|
|
|
347
389
|
}
|
|
348
390
|
}
|
|
349
391
|
lines.push(` ${homeify(getConfigPathForScope(scope, cwd))} ${policies.length} policies`);
|
|
392
|
+
if (scope === "user" && isDaemonSupportedPlatform()) {
|
|
393
|
+
const servicePath = daemonServiceFilePath();
|
|
394
|
+
if (servicePath) lines.push(` ${homeify(servicePath)} failproofaid service`);
|
|
395
|
+
}
|
|
350
396
|
return lines;
|
|
351
397
|
}
|
|
352
398
|
|
|
@@ -568,6 +614,35 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
568
614
|
{ replace: true, quiet: true },
|
|
569
615
|
);
|
|
570
616
|
setCustomPoliciesEnabled(scope, cwd, customEnabled);
|
|
617
|
+
|
|
618
|
+
// Daemon setup — unconditional on Linux/macOS whenever the global scope
|
|
619
|
+
// was chosen (a daemon is per-machine, not per-project), no separate
|
|
620
|
+
// opt-in step or command. A failure here (binary not found, no service
|
|
621
|
+
// manager, ...) never fails the wizard — the rest of setup already
|
|
622
|
+
// applied, and this machine simply stays on the in-process path exactly
|
|
623
|
+
// as before, since `daemonConfigured` is only set on success.
|
|
624
|
+
let daemonInstalled = false;
|
|
625
|
+
if (scope === "user" && isDaemonSupportedPlatform()) {
|
|
626
|
+
const daemonResult = await installDaemonService();
|
|
627
|
+
if (daemonResult.installed) {
|
|
628
|
+
setDaemonConfigured(true);
|
|
629
|
+
daemonInstalled = true;
|
|
630
|
+
} else {
|
|
631
|
+
hookLogWarn(`failproofaid was not installed as a service: ${daemonResult.reason}`);
|
|
632
|
+
}
|
|
633
|
+
void emit("configure_daemon_install", {
|
|
634
|
+
installed: daemonResult.installed,
|
|
635
|
+
// A bounded classification, never the raw reason: on the failure path
|
|
636
|
+
// that string is an errno message from writeFileSync/execFileSync
|
|
637
|
+
// against a homedir()-derived path, so it routinely carries the OS
|
|
638
|
+
// username and the local filesystem layout ("/home/<user>/.config/
|
|
639
|
+
// systemd/user/failproofaid.service"). The full text stays local via
|
|
640
|
+
// the hookLogWarn above.
|
|
641
|
+
reason: daemonResult.installed ? null : classifyDaemonInstallFailure(daemonResult.reason),
|
|
642
|
+
platform: process.platform,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
571
646
|
await applied;
|
|
572
647
|
// Only now — a completed apply — is the launcher considered "seen", so the
|
|
573
648
|
// first-run bare invocation stops redirecting here and opens the dashboard.
|
|
@@ -584,9 +659,10 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
584
659
|
: customEnabled === false
|
|
585
660
|
? " · custom policies DISABLED"
|
|
586
661
|
: "";
|
|
662
|
+
const daemonNote = daemonInstalled ? " · background daemon enabled" : "";
|
|
587
663
|
const assistants = `${clis.length} assistant${clis.length === 1 ? "" : "s"}`;
|
|
588
664
|
outro(
|
|
589
|
-
`Setup complete — ${policies.length} policies${customNote} · ${assistants}`,
|
|
665
|
+
`Setup complete — ${policies.length} policies${customNote} · ${assistants}${daemonNote}`,
|
|
590
666
|
{ ok: true },
|
|
591
667
|
stdout,
|
|
592
668
|
);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client for talking to the failproofaid Rust daemon over its Unix
|
|
3
|
+
* socket. Used by `bin/failproofai.mjs`'s `--hook` path.
|
|
4
|
+
*
|
|
5
|
+
* Wire format matches `crates/PROTOCOL.md` exactly: one connection per
|
|
6
|
+
* request, a 4-byte big-endian u32 length prefix followed by that many
|
|
7
|
+
* bytes of UTF-8 JSON, camelCase fields, `"type"` as the tag.
|
|
8
|
+
*
|
|
9
|
+
* This module makes NO decision about what to do when the daemon can't be
|
|
10
|
+
* reached — `tryDaemonHook` just returns `null` on any failure. The caller
|
|
11
|
+
* (`bin/failproofai.mjs`) decides: fall back to full in-process evaluation
|
|
12
|
+
* on a machine that was never daemon-configured, or fail closed on one that
|
|
13
|
+
* was. See `isDaemonConfigured`.
|
|
14
|
+
*/
|
|
15
|
+
import { createConnection } from "node:net";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import type { IntegrationType } from "./types";
|
|
19
|
+
import { readHooksConfig } from "./hooks-config";
|
|
20
|
+
|
|
21
|
+
const PROTOCOL_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reaching the daemon and getting an answer out of it are two different
|
|
25
|
+
* questions, and the caller's fail-closed policy makes conflating them
|
|
26
|
+
* expensive: on a daemon-configured machine a timeout here is a *deny*, not
|
|
27
|
+
* a fallback.
|
|
28
|
+
*
|
|
29
|
+
* `CONNECT` is the "is anything listening?" budget. Generous for a local
|
|
30
|
+
* Unix socket (sub-millisecond, no network); a daemon that can't answer an
|
|
31
|
+
* accept inside this is unhealthy, and failing fast is exactly right — this
|
|
32
|
+
* is the budget that keeps a dead daemon from adding latency to every hook.
|
|
33
|
+
*
|
|
34
|
+
* `RESPONSE` starts once the connection is established, and must cover the
|
|
35
|
+
* whole evaluation the daemon runs on our behalf: `handler.ts` allows each
|
|
36
|
+
* custom policy up to 10s, `worker-server.ts` serializes requests so one
|
|
37
|
+
* can queue behind another, and a project with many convention policies
|
|
38
|
+
* pays real file I/O on its first evaluation. Budgeting those at connect
|
|
39
|
+
* speed turned a slow-but-correct evaluation into an intermittent denial of
|
|
40
|
+
* a legitimate tool call. Matched to the daemon's own ceiling for the same
|
|
41
|
+
* roundtrip (`worker.rs` sets a 30s read timeout on the worker socket), so
|
|
42
|
+
* this side never gives up on a request the daemon is still honestly
|
|
43
|
+
* working on.
|
|
44
|
+
*/
|
|
45
|
+
const DAEMON_CONNECT_TIMEOUT_MS = 150;
|
|
46
|
+
const DAEMON_RESPONSE_TIMEOUT_MS = 30_000;
|
|
47
|
+
|
|
48
|
+
const MAX_FRAME_LEN = 16 * 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
export interface DaemonHookRequest {
|
|
51
|
+
hookEvent: string;
|
|
52
|
+
cli: IntegrationType;
|
|
53
|
+
stdin: string;
|
|
54
|
+
/**
|
|
55
|
+
* Best-effort cwd of the *originating* CLI process (parsed from the
|
|
56
|
+
* hook's own stdin payload by the caller) — never the daemon's or this
|
|
57
|
+
* client process's own cwd. See PROTOCOL.md's note on the process.cwd()
|
|
58
|
+
* hazard.
|
|
59
|
+
*/
|
|
60
|
+
cwd?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DaemonHookResponse {
|
|
64
|
+
exitCode: number;
|
|
65
|
+
stdout: string;
|
|
66
|
+
stderr: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function socketPath(): string {
|
|
70
|
+
if (process.env.FAILPROOFAI_DAEMON_SOCKET) return process.env.FAILPROOFAI_DAEMON_SOCKET;
|
|
71
|
+
return resolve(homedir(), ".failproofai", "run", "failproofaid.sock");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `true` once `failproofai config` has installed and started the daemon for
|
|
76
|
+
* this machine (global scope only — see `HooksConfig.daemonConfigured`).
|
|
77
|
+
* Deliberately a single cheap global-only file read (`readHooksConfig`),
|
|
78
|
+
* not the merged project+local+global reader — whether *this machine* has a
|
|
79
|
+
* daemon is not a per-project setting, and the merge logic doesn't know
|
|
80
|
+
* about this key at all.
|
|
81
|
+
*/
|
|
82
|
+
export function isDaemonConfigured(): boolean {
|
|
83
|
+
try {
|
|
84
|
+
return readHooksConfig().daemonConfigured === true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function encodeFrame(value: unknown): Buffer {
|
|
91
|
+
const body = Buffer.from(JSON.stringify(value), "utf8");
|
|
92
|
+
const header = Buffer.alloc(4);
|
|
93
|
+
header.writeUInt32BE(body.length, 0);
|
|
94
|
+
return Buffer.concat([header, body]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Attempts one hook evaluation via the daemon. Returns `null` on **any**
|
|
99
|
+
* failure — no socket, connection refused, timeout, malformed response,
|
|
100
|
+
* protocol-version mismatch, or an explicit `error` message from the
|
|
101
|
+
* daemon. The caller never needs to distinguish failure modes; it just
|
|
102
|
+
* falls through to whatever its own fallback policy is.
|
|
103
|
+
*/
|
|
104
|
+
export async function tryDaemonHook(req: DaemonHookRequest): Promise<DaemonHookResponse | null> {
|
|
105
|
+
// Windows never has a daemon in this phase (see the plan's platform
|
|
106
|
+
// scope) — skip the attempt outright rather than depending on however
|
|
107
|
+
// Node happens to behave when handed a POSIX socket path on Windows.
|
|
108
|
+
if (process.platform === "win32") return null;
|
|
109
|
+
|
|
110
|
+
return new Promise<DaemonHookResponse | null>((resolvePromise) => {
|
|
111
|
+
let settled = false;
|
|
112
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
113
|
+
const arm = (ms: number) => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
timer = setTimeout(() => finish(null), ms);
|
|
116
|
+
// Don't let this timer alone keep the process alive if everything else
|
|
117
|
+
// has already finished — it's always cleared on the success/failure
|
|
118
|
+
// paths below, this just avoids it being the sole reason a --hook
|
|
119
|
+
// process lingers if something upstream forgets to await us.
|
|
120
|
+
timer.unref?.();
|
|
121
|
+
};
|
|
122
|
+
const finish = (result: DaemonHookResponse | null) => {
|
|
123
|
+
if (settled) return;
|
|
124
|
+
settled = true;
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
socket.removeAllListeners();
|
|
127
|
+
socket.destroy();
|
|
128
|
+
resolvePromise(result);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const socket = createConnection({ path: socketPath() });
|
|
132
|
+
arm(DAEMON_CONNECT_TIMEOUT_MS);
|
|
133
|
+
|
|
134
|
+
let recvBuf = Buffer.alloc(0);
|
|
135
|
+
let declaredLen: number | null = null;
|
|
136
|
+
|
|
137
|
+
socket.on("connect", () => {
|
|
138
|
+
// Connected: the daemon is demonstrably reachable, so the question is
|
|
139
|
+
// no longer "is it there" but "how long does this evaluation take".
|
|
140
|
+
arm(DAEMON_RESPONSE_TIMEOUT_MS);
|
|
141
|
+
socket.write(
|
|
142
|
+
encodeFrame({
|
|
143
|
+
type: "hook",
|
|
144
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
145
|
+
hookEvent: req.hookEvent,
|
|
146
|
+
cli: req.cli,
|
|
147
|
+
stdin: req.stdin,
|
|
148
|
+
cwd: req.cwd,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
socket.on("data", (chunk: Buffer) => {
|
|
154
|
+
recvBuf = Buffer.concat([recvBuf, chunk]);
|
|
155
|
+
|
|
156
|
+
if (declaredLen === null) {
|
|
157
|
+
if (recvBuf.length < 4) return;
|
|
158
|
+
declaredLen = recvBuf.readUInt32BE(0);
|
|
159
|
+
if (declaredLen > MAX_FRAME_LEN) {
|
|
160
|
+
finish(null);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
recvBuf = recvBuf.subarray(4);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (recvBuf.length < declaredLen) return;
|
|
167
|
+
|
|
168
|
+
const body = recvBuf.subarray(0, declaredLen);
|
|
169
|
+
let message: Record<string, unknown>;
|
|
170
|
+
try {
|
|
171
|
+
message = JSON.parse(body.toString("utf8")) as Record<string, unknown>;
|
|
172
|
+
} catch {
|
|
173
|
+
finish(null);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (message.protocolVersion !== PROTOCOL_VERSION) {
|
|
178
|
+
finish(null);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
message.type === "hookResult" &&
|
|
183
|
+
typeof message.exitCode === "number" &&
|
|
184
|
+
typeof message.stdout === "string" &&
|
|
185
|
+
typeof message.stderr === "string"
|
|
186
|
+
) {
|
|
187
|
+
finish({ exitCode: message.exitCode, stdout: message.stdout, stderr: message.stderr });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Anything else — an explicit `error` message, a `pong` (protocol
|
|
191
|
+
// confusion), or a well-formed-but-wrong-shape body — is treated
|
|
192
|
+
// identically to a connection failure: no partial trust.
|
|
193
|
+
finish(null);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
socket.on("error", () => finish(null));
|
|
197
|
+
socket.on("close", () => finish(null));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches the compiled `failproofaid` binary for this platform from the
|
|
3
|
+
* GitHub Release that matches this CLI's own version.
|
|
4
|
+
*
|
|
5
|
+
* Why a download rather than a dependency: the daemon is a 4-way
|
|
6
|
+
* cross-compiled Rust binary, and shipping it through npm means four
|
|
7
|
+
* per-platform packages that every install resolves, an npm org to own the
|
|
8
|
+
* scope, and a second publish channel that can silently drift from the first
|
|
9
|
+
* (it already did — the platform packages were declared as
|
|
10
|
+
* `optionalDependencies` and never published, so every install of them 404'd).
|
|
11
|
+
* The release assets have to exist for standalone installs regardless, so
|
|
12
|
+
* pointing the CLI at the same artifacts leaves exactly one channel.
|
|
13
|
+
*
|
|
14
|
+
* The URL is *constructed*, never discovered — no API call, no `releases/latest`
|
|
15
|
+
* redirect, no rate limit, and no chance of fetching a daemon built from
|
|
16
|
+
* different source than the CLI talking to it: the tag is this package's own
|
|
17
|
+
* version, so a CLI upgrade downloads its own matching binary.
|
|
18
|
+
*
|
|
19
|
+
* Everything here fails closed and never throws. A daemon that cannot be
|
|
20
|
+
* fetched leaves the machine on the in-process path it was already using;
|
|
21
|
+
* a daemon fetched over a corrupted connection is refused outright, because
|
|
22
|
+
* this writes an executable that a service manager will then run at login.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { resolve } from "node:path";
|
|
28
|
+
import { gunzipSync } from "node:zlib";
|
|
29
|
+
import { version } from "../../package.json";
|
|
30
|
+
import type { PlatformKey } from "./daemon-service";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Where release assets live. Overridable so an air-gapped or proxied site can
|
|
34
|
+
* mirror the four binaries internally — and so tests can serve them from a
|
|
35
|
+
* local HTTP server without touching the network.
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_BASE_URL = "https://github.com/FailproofAI/failproofai/releases/download";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One bound for the whole fetch. Long enough for a ~2 MB asset on a slow
|
|
41
|
+
* connection, short enough that a black-holed proxy doesn't turn the config
|
|
42
|
+
* wizard into a hang — the failure mode this replaces is a user staring at a
|
|
43
|
+
* spinner with no output.
|
|
44
|
+
*/
|
|
45
|
+
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
46
|
+
|
|
47
|
+
function baseUrl(): string {
|
|
48
|
+
return (process.env.FAILPROOFAI_DAEMON_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where downloaded binaries live. One directory, one file per version. */
|
|
52
|
+
export function daemonBinaryDir(): string {
|
|
53
|
+
return resolve(homedir(), ".failproofai", "bin");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The path this CLI's own daemon binary occupies.
|
|
58
|
+
*
|
|
59
|
+
* Versioned on purpose. An in-place overwrite would hit `ETXTBSY` against the
|
|
60
|
+
* running daemon on Linux, and would silently repoint a service unit at a
|
|
61
|
+
* binary built from different source; a per-version filename makes an upgrade
|
|
62
|
+
* a new file plus a rewritten unit, and leaves the old one recoverable.
|
|
63
|
+
*/
|
|
64
|
+
export function installedBinaryPath(ver: string = version): string {
|
|
65
|
+
return resolve(daemonBinaryDir(), `failproofaid-${ver}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The release asset URL for a platform — constructed, never discovered. */
|
|
69
|
+
export function daemonAssetUrl(key: PlatformKey, ver: string = version): string {
|
|
70
|
+
return `${baseUrl()}/v${ver}/failproofaid-${key}.gz`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The checksum manifest published alongside the four binaries. */
|
|
74
|
+
export function checksumsUrl(ver: string = version): string {
|
|
75
|
+
return `${baseUrl()}/v${ver}/SHA256SUMS`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface DaemonDownloadResult {
|
|
79
|
+
/** Absolute path to the installed, executable binary. */
|
|
80
|
+
path?: string;
|
|
81
|
+
/** Local diagnostic — never sent off the machine verbatim. */
|
|
82
|
+
error?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchBytes(url: string): Promise<Buffer> {
|
|
86
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
87
|
+
if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
|
|
88
|
+
return Buffer.from(await response.arrayBuffer());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parses a `sha256sum`-format manifest and returns the digest for one asset.
|
|
93
|
+
*
|
|
94
|
+
* A missing line is an error rather than a skipped check: the manifest is the
|
|
95
|
+
* only thing standing between a truncated or substituted download and an
|
|
96
|
+
* executable this process is about to install as a login service.
|
|
97
|
+
*/
|
|
98
|
+
export function digestFor(manifest: string, assetName: string): string | null {
|
|
99
|
+
for (const line of manifest.split("\n")) {
|
|
100
|
+
const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i);
|
|
101
|
+
if (match && match[2].trim() === assetName) return match[1].toLowerCase();
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Downloads, verifies and installs the daemon binary for `key`.
|
|
108
|
+
*
|
|
109
|
+
* Idempotent: an already-installed binary for this version is returned as-is,
|
|
110
|
+
* so re-running the wizard costs nothing.
|
|
111
|
+
*/
|
|
112
|
+
export async function downloadFailproofaidBinary(key: PlatformKey): Promise<DaemonDownloadResult> {
|
|
113
|
+
const target = installedBinaryPath();
|
|
114
|
+
if (existsSync(target)) return { path: target };
|
|
115
|
+
|
|
116
|
+
if (process.env.FAILPROOFAI_NO_DOWNLOAD) {
|
|
117
|
+
return { error: "daemon downloads are disabled (FAILPROOFAI_NO_DOWNLOAD)" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const assetName = `failproofaid-${key}.gz`;
|
|
121
|
+
// Same directory as the target so the install is a rename within one
|
|
122
|
+
// filesystem — atomic, and never a half-written binary a service manager
|
|
123
|
+
// could pick up. The pid keeps two concurrent wizards off each other's file.
|
|
124
|
+
const tempPath = `${target}.${process.pid}.tmp`;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const [asset, manifest] = await Promise.all([
|
|
128
|
+
fetchBytes(daemonAssetUrl(key)),
|
|
129
|
+
fetchBytes(checksumsUrl()).then((b) => b.toString("utf8")),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
const expected = digestFor(manifest, assetName);
|
|
133
|
+
if (!expected) {
|
|
134
|
+
return { error: `SHA256SUMS for v${version} has no entry for ${assetName}` };
|
|
135
|
+
}
|
|
136
|
+
const actual = createHash("sha256").update(asset).digest("hex");
|
|
137
|
+
if (actual !== expected) {
|
|
138
|
+
return { error: `checksum mismatch for ${assetName} (expected ${expected}, got ${actual})` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const binary = gunzipSync(asset);
|
|
142
|
+
mkdirSync(daemonBinaryDir(), { recursive: true });
|
|
143
|
+
writeFileSync(tempPath, binary);
|
|
144
|
+
chmodSync(tempPath, 0o755);
|
|
145
|
+
renameSync(tempPath, target);
|
|
146
|
+
return { path: target };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
rmSync(tempPath, { force: true });
|
|
149
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
150
|
+
return { error: `failed to download failproofaid v${version} for ${key}: ${message}` };
|
|
151
|
+
}
|
|
152
|
+
}
|