failproofai 1.0.0-beta.1 → 1.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ig795d._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1yo1gyz._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_14q3-7b.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{1d71o1_tav721.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{07ix3lb4gib0x.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{35584kn409ad1.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{191njgoik03fm.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2ovmv4esqalpb.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{30is-6r00sbd7.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1b3a1scouf40o.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{24gs8ip6_spcb.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2294 -786
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +308 -18
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/daemon-service.ts +125 -24
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/static/chunks/0fdx7ikab08-g.js +0 -6
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
* `configure-wizard.ts` calls the functions here directly, the same
|
|
9
9
|
* relationship it already has with `manager.ts`'s `installHooks()`.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
mkdtempSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
} from "node:fs";
|
|
12
20
|
import { homedir, tmpdir, userInfo } from "node:os";
|
|
13
21
|
import { resolve, dirname } from "node:path";
|
|
14
22
|
import { execFileSync } from "node:child_process";
|
|
@@ -45,6 +53,14 @@ const SERVICE_START_POLL_MS = 100;
|
|
|
45
53
|
*/
|
|
46
54
|
const SERVICE_SETTLE_MS = 750;
|
|
47
55
|
|
|
56
|
+
/**
|
|
57
|
+
* How long `sudo -v` may sit at its password prompt. Unlike every other
|
|
58
|
+
* timeout here, this one is waiting on a human typing, not on a service
|
|
59
|
+
* manager — three attempts at a password is well inside two minutes, and
|
|
60
|
+
* sudo gives up on its own long before that.
|
|
61
|
+
*/
|
|
62
|
+
const SUDO_PROMPT_TIMEOUT_MS = 120_000;
|
|
63
|
+
|
|
48
64
|
/**
|
|
49
65
|
* Writes (or clears) the machine-wide `daemonConfigured` marker in
|
|
50
66
|
* `~/.failproofai/policies-config.json`.
|
|
@@ -175,6 +191,14 @@ export function resolveWorkerCommand(): string | null {
|
|
|
175
191
|
if (!packageRoot) return null;
|
|
176
192
|
const workerScript = resolve(packageRoot, "dist", "worker.mjs");
|
|
177
193
|
if (!existsSync(workerScript)) return null;
|
|
194
|
+
// Both paths are shell-quoted: the daemon runs this value through `sh -c`
|
|
195
|
+
// (`WorkerCommand::Shell` in crates/failproofaid/src/worker.rs), so an
|
|
196
|
+
// unquoted path splits on its spaces and the worker never starts. Not
|
|
197
|
+
// hypothetical on macOS, where `/Users/First Last/...` is ordinary — and
|
|
198
|
+
// more exposed since execPath replaced a bare `node`, because execPath is
|
|
199
|
+
// home-derived. systemd's own `Environment="..."` quoting does not help
|
|
200
|
+
// here; that protects the unit parse, not the later shell split.
|
|
201
|
+
//
|
|
178
202
|
// `process.execPath`, not a bare `node`. A system-scope service does not
|
|
179
203
|
// inherit a login environment, so its PATH is the system default — and the
|
|
180
204
|
// single most common way to install Node is nvm, which puts it under
|
|
@@ -183,7 +207,13 @@ export function resolveWorkerCommand(): string | null {
|
|
|
183
207
|
// silently, on exactly the machines least likely to notice. execPath is
|
|
184
208
|
// whatever runtime is executing this CLI right now (node for the published
|
|
185
209
|
// bin, bun in a source checkout), absolute either way.
|
|
186
|
-
return `${process.execPath} ${workerScript}`;
|
|
210
|
+
return `${shellQuote(process.execPath)} ${shellQuote(workerScript)}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** POSIX single-quoting: everything is literal inside '…', and a literal
|
|
214
|
+
* quote is closed, escaped and reopened. */
|
|
215
|
+
function shellQuote(value: string): string {
|
|
216
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
187
217
|
}
|
|
188
218
|
|
|
189
219
|
/**
|
|
@@ -224,15 +254,33 @@ function legacySystemdUserUnitPath(): string {
|
|
|
224
254
|
return resolve(homedir(), ".config", "systemd", "user", "failproofaid.service");
|
|
225
255
|
}
|
|
226
256
|
|
|
227
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Per user, for the same reason the systemd unit is: a LaunchDaemon's
|
|
259
|
+
* contents are entirely user-specific — `UserName`, the ExecStart path under
|
|
260
|
+
* that user's `~/.failproofai/bin`, the log paths under that user's home — so
|
|
261
|
+
* one shared label means the second user's install silently overwrites the
|
|
262
|
+
* first's daemon, and their uninstall deletes it.
|
|
263
|
+
*/
|
|
264
|
+
function launchdLabel(user: string = serviceUser()): string {
|
|
265
|
+
return `ai.failproof.failproofaid.${user}`;
|
|
266
|
+
}
|
|
228
267
|
|
|
229
|
-
function launchdPlistPath(): string {
|
|
230
|
-
return `/Library/LaunchDaemons/${
|
|
268
|
+
function launchdPlistPath(user: string = serviceUser()): string {
|
|
269
|
+
return `/Library/LaunchDaemons/${launchdLabel(user)}.plist`;
|
|
231
270
|
}
|
|
232
271
|
|
|
233
|
-
/** The pre-1.0.0-beta.1 LaunchAgent
|
|
272
|
+
/** The pre-1.0.0-beta.1 LaunchAgent. */
|
|
234
273
|
function legacyLaunchAgentPlistPath(): string {
|
|
235
|
-
return resolve(homedir(), "Library", "LaunchAgents",
|
|
274
|
+
return resolve(homedir(), "Library", "LaunchAgents", "ai.failproof.failproofaid.plist");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The 1.0.0-beta.1 LaunchDaemon, before the label was namespaced. Removed on
|
|
279
|
+
* install like the LaunchAgent is: it holds the same singleton flock, so
|
|
280
|
+
* leaving it would leave two daemons racing for one socket.
|
|
281
|
+
*/
|
|
282
|
+
function legacySharedLaunchDaemonPath(): string {
|
|
283
|
+
return "/Library/LaunchDaemons/ai.failproof.failproofaid.plist";
|
|
236
284
|
}
|
|
237
285
|
|
|
238
286
|
/**
|
|
@@ -255,7 +303,38 @@ export function daemonStatusCommand(): string | null {
|
|
|
255
303
|
if (!isDaemonSupportedPlatform()) return null;
|
|
256
304
|
return process.platform === "linux"
|
|
257
305
|
? `systemctl status ${systemdUnitName()}`
|
|
258
|
-
: `sudo launchctl print system/${
|
|
306
|
+
: `sudo launchctl print system/${launchdLabel()}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Acquires sudo credentials up front, prompting in the terminal if needed.
|
|
311
|
+
*
|
|
312
|
+
* `sudo -v` refreshes the user's sudo timestamp and returns; every later
|
|
313
|
+
* `sudo -n` in this run then succeeds against that cached credential. Doing it
|
|
314
|
+
* here, once, at a known point in the wizard is what makes the rest of the
|
|
315
|
+
* install non-interactive — the alternative, an interactive sudo firing from
|
|
316
|
+
* underneath a half-drawn TUI screen, is a password prompt the user cannot
|
|
317
|
+
* see.
|
|
318
|
+
*
|
|
319
|
+
* NOT the same as running the whole CLI under sudo, which is actively wrong:
|
|
320
|
+
* `homedir()` would become /root, so the hooks, the policy config, the
|
|
321
|
+
* downloaded binary and the unit's own `User=` would all be root's rather
|
|
322
|
+
* than the user's, quietly configuring the wrong account.
|
|
323
|
+
*
|
|
324
|
+
* `stdio: "inherit"` so the prompt and the typed password go straight to the
|
|
325
|
+
* real terminal. The timeout is generous because a human is typing.
|
|
326
|
+
*/
|
|
327
|
+
export function primeElevation(): boolean {
|
|
328
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) return true;
|
|
329
|
+
if (canElevate()) return true; // NOPASSWD, or already primed
|
|
330
|
+
try {
|
|
331
|
+
execFileSync("sudo", ["-v"], { stdio: "inherit", timeout: SUDO_PROMPT_TIMEOUT_MS });
|
|
332
|
+
return canElevate();
|
|
333
|
+
} catch {
|
|
334
|
+
// Wrong password, no sudo rights, or the user pressed ctrl-C. All of them
|
|
335
|
+
// mean the same thing here: carry on without a daemon.
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
259
338
|
}
|
|
260
339
|
|
|
261
340
|
/** True when privileged commands can run without prompting for a password. */
|
|
@@ -289,12 +368,19 @@ function runPrivileged(command: string, args: string[]): void {
|
|
|
289
368
|
* mode in the same step, so the file is never briefly world-writable.
|
|
290
369
|
*/
|
|
291
370
|
function writePrivilegedFile(destination: string, contents: string, mode = "0644"): void {
|
|
292
|
-
|
|
371
|
+
// mkdtempSync, not a name built from pid + timestamp in the shared tmpdir.
|
|
372
|
+
// That name is guessable, and `writeFileSync` follows a symlink already
|
|
373
|
+
// sitting at the path — so on a multi-user box another local user could
|
|
374
|
+
// pre-create it and have `install` copy content they control into
|
|
375
|
+
// /etc/systemd/system as root. mkdtemp gives a 0700 directory with a name
|
|
376
|
+
// they cannot predict, so there is nothing to pre-create.
|
|
377
|
+
const stagingDir = mkdtempSync(resolve(tmpdir(), "failproofaid-stage-"));
|
|
378
|
+
const staging = resolve(stagingDir, "service-definition");
|
|
293
379
|
try {
|
|
294
380
|
writeFileSync(staging, contents, "utf8");
|
|
295
381
|
runPrivileged("install", ["-m", mode, staging, destination]);
|
|
296
382
|
} finally {
|
|
297
|
-
rmSync(
|
|
383
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
298
384
|
}
|
|
299
385
|
}
|
|
300
386
|
|
|
@@ -326,17 +412,30 @@ function removeLegacyUserService(): void {
|
|
|
326
412
|
/* best-effort */
|
|
327
413
|
}
|
|
328
414
|
} else {
|
|
329
|
-
const
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
415
|
+
const legacyAgent = legacyLaunchAgentPlistPath();
|
|
416
|
+
if (existsSync(legacyAgent)) {
|
|
417
|
+
try {
|
|
418
|
+
execFileSync("launchctl", ["unload", "-w", legacyAgent], {
|
|
419
|
+
stdio: "ignore",
|
|
420
|
+
timeout: SERVICE_CMD_TIMEOUT_MS,
|
|
421
|
+
});
|
|
422
|
+
} catch {
|
|
423
|
+
/* not loaded */
|
|
424
|
+
}
|
|
425
|
+
unlinkSync(legacyAgent);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// The 1.0.0-beta.1 daemon, before the label was per-user. Privileged,
|
|
429
|
+
// and only reachable on a path that has already elevated.
|
|
430
|
+
const legacyDaemon = legacySharedLaunchDaemonPath();
|
|
431
|
+
if (existsSync(legacyDaemon)) {
|
|
432
|
+
try {
|
|
433
|
+
runPrivileged("launchctl", ["unload", "-w", legacyDaemon]);
|
|
434
|
+
} catch {
|
|
435
|
+
/* not loaded */
|
|
436
|
+
}
|
|
437
|
+
runPrivileged("rm", ["-f", legacyDaemon]);
|
|
338
438
|
}
|
|
339
|
-
unlinkSync(legacy);
|
|
340
439
|
}
|
|
341
440
|
} catch (err) {
|
|
342
441
|
hookLogWarn(`could not remove the legacy user-scope daemon: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -390,7 +489,7 @@ function launchdPlistContents(binaryPath: string, logDir: string, workerCmd: str
|
|
|
390
489
|
<plist version="1.0">
|
|
391
490
|
<dict>
|
|
392
491
|
<key>Label</key>
|
|
393
|
-
<string>${
|
|
492
|
+
<string>${escapeXml(launchdLabel())}</string>
|
|
394
493
|
<key>ProgramArguments</key>
|
|
395
494
|
<array>
|
|
396
495
|
<string>${escapeXml(binaryPath)}</string>
|
|
@@ -471,8 +570,10 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
|
|
|
471
570
|
return {
|
|
472
571
|
installed: false,
|
|
473
572
|
reason:
|
|
474
|
-
"root privileges are required to install the failproofaid system service, and sudo
|
|
475
|
-
`
|
|
573
|
+
"root privileges are required to install the failproofaid system service, and sudo credentials were not available. " +
|
|
574
|
+
"Re-run `failproofai config` and approve the sudo prompt — do NOT run the CLI itself under sudo, which would " +
|
|
575
|
+
"configure root's account instead of yours. To install by hand: " +
|
|
576
|
+
daemonInstallCommands(binaryPath, workerCmd).join(" && "),
|
|
476
577
|
};
|
|
477
578
|
}
|
|
478
579
|
|
|
@@ -631,7 +732,7 @@ export function daemonServiceStatus(): DaemonServiceStatus {
|
|
|
631
732
|
// `launchctl list` cannot see — unlike systemd, reading the state needs
|
|
632
733
|
// the same elevation installing it did.
|
|
633
734
|
const root = typeof process.getuid === "function" && process.getuid() === 0;
|
|
634
|
-
const args = ["print", `system/${
|
|
735
|
+
const args = ["print", `system/${launchdLabel()}`];
|
|
635
736
|
const out = root
|
|
636
737
|
? execFileSync("launchctl", args, {
|
|
637
738
|
stdio: ["ignore", "pipe", "ignore"],
|
package/src/hooks/handler.ts
CHANGED
|
@@ -41,6 +41,8 @@ import { resolveTranscriptPath } from "./resolve-transcript-path";
|
|
|
41
41
|
import { getInstanceId } from "../../lib/telemetry-id";
|
|
42
42
|
import { hookLogInfo, hookLogWarn } from "./hook-logger";
|
|
43
43
|
import { readStdinPayload } from "./read-stdin";
|
|
44
|
+
import { readActiveCloudManagedPolicies, type CloudManagedPolicyArtifact } from "./cloud-managed-policies";
|
|
45
|
+
import { readActivePause, type ActivePause } from "./session-pause";
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
48
|
* Canonicalize an event name to PascalCase. Codex sends snake_case event names
|
|
@@ -134,6 +136,37 @@ export interface EvaluateHookEventOptions {
|
|
|
134
136
|
fallbackCwd?: string;
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Runs an observe-mode policy under the same timeout and error handling as an
|
|
141
|
+
* enforcing one, so what gets measured is what would actually have happened —
|
|
142
|
+
* including a policy that times out, which in enforce mode is an allow and must
|
|
143
|
+
* be recorded as one rather than as a would-deny.
|
|
144
|
+
*/
|
|
145
|
+
async function runObserved(
|
|
146
|
+
hook: CustomHook,
|
|
147
|
+
ctx: Parameters<CustomHook["fn"]>[0],
|
|
148
|
+
hookName: string,
|
|
149
|
+
eventType: string,
|
|
150
|
+
cli: IntegrationType,
|
|
151
|
+
): Promise<PolicyResult> {
|
|
152
|
+
try {
|
|
153
|
+
return await Promise.race([
|
|
154
|
+
hook.fn(ctx),
|
|
155
|
+
new Promise<PolicyResult>((_, reject) => setTimeout(() => reject(new Error("timeout")), 10_000)),
|
|
156
|
+
]);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
+
void trackHookEvent(getInstanceId(), "custom_hook_error", {
|
|
160
|
+
hook_name: hookName,
|
|
161
|
+
error_type: msg === "timeout" ? "timeout" : "exception",
|
|
162
|
+
event_type: eventType,
|
|
163
|
+
cli,
|
|
164
|
+
is_observe_mode: true,
|
|
165
|
+
});
|
|
166
|
+
return { decision: "allow" };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
137
170
|
/**
|
|
138
171
|
* The core hook-evaluation logic, decoupled from process stdin/stdout so it
|
|
139
172
|
* can be called repeatedly inside a long-lived process (the daemon's warm
|
|
@@ -209,6 +242,20 @@ export async function evaluateHookEvent(
|
|
|
209
242
|
let config: HooksConfig;
|
|
210
243
|
let customHooksList: CustomHook[] = [];
|
|
211
244
|
let conventionHookNames = new Set<string>();
|
|
245
|
+
let activePause: ActivePause | null = null;
|
|
246
|
+
/** Registered policy name → where it came from. See the set() below. */
|
|
247
|
+
const policyAttribution = new Map<
|
|
248
|
+
string,
|
|
249
|
+
{ source: "custom" | "convention" | "cloud"; cloudPolicyId?: string; cloudRevision?: number }
|
|
250
|
+
>();
|
|
251
|
+
let cloudGeneration: number | undefined;
|
|
252
|
+
/** What observe-mode policies WOULD have done, had they been enforcing. */
|
|
253
|
+
const observedResults: Array<{
|
|
254
|
+
policyId: string;
|
|
255
|
+
revision: number;
|
|
256
|
+
decision: "deny" | "instruct";
|
|
257
|
+
reason: string | null;
|
|
258
|
+
}> = [];
|
|
212
259
|
|
|
213
260
|
if (opts?.forceDecision) {
|
|
214
261
|
// Fail-closed: no config/custom-hook loading at all — a daemon that
|
|
@@ -228,25 +275,80 @@ export async function evaluateHookEvent(
|
|
|
228
275
|
// Load enabled policies (merge across project/local/global scopes)
|
|
229
276
|
config = readMergedHooksConfig(session.cwd);
|
|
230
277
|
clearPolicies();
|
|
231
|
-
|
|
278
|
+
|
|
279
|
+
// A session pause suspends LOCAL policy only, for a bounded time. Cloud
|
|
280
|
+
// assignments are exempt below for the same reason `disabledCustomPolicies`
|
|
281
|
+
// already exempts them: a locally-issued command that could switch off a
|
|
282
|
+
// centrally assigned policy would make cloud enforcement decorative.
|
|
283
|
+
activePause = readActivePause(session.sessionId);
|
|
284
|
+
registerBuiltinPolicies(activePause ? [] : config.enabledPolicies);
|
|
285
|
+
|
|
286
|
+
// Cloud-managed policies are daemon-reconciled artifacts, but they use
|
|
287
|
+
// the same public JS policy API as local custom policies. Verify and add
|
|
288
|
+
// only the paths referenced by the atomically active generation.
|
|
289
|
+
const cloudManagedPolicies = readActiveCloudManagedPolicies();
|
|
290
|
+
// Recorded on every row once a machine is managed, whether or not a cloud
|
|
291
|
+
// policy decided this event: "what was deployed here at the time" is a
|
|
292
|
+
// separate question from "what decided", and only the former can tell a
|
|
293
|
+
// rollout that changed nothing from one that never reached the machine.
|
|
294
|
+
cloudGeneration = cloudManagedPolicies[0]?.generation;
|
|
295
|
+
const configuredCustomPaths = config.customPoliciesPaths ?? config.customPoliciesPath;
|
|
296
|
+
const allExplicitPaths =
|
|
297
|
+
cloudManagedPolicies.length === 0
|
|
298
|
+
? configuredCustomPaths
|
|
299
|
+
: [
|
|
300
|
+
...(typeof configuredCustomPaths === "string" ? [configuredCustomPaths] : configuredCustomPaths ?? []),
|
|
301
|
+
...cloudManagedPolicies.map((policy) => policy.path),
|
|
302
|
+
];
|
|
232
303
|
|
|
233
304
|
// Load and register custom hooks (layer 2, after builtins)
|
|
234
|
-
const loadResult = await loadAllCustomHooks(
|
|
305
|
+
const loadResult = await loadAllCustomHooks(allExplicitPaths, {
|
|
235
306
|
sessionCwd: session.cwd,
|
|
236
307
|
customPoliciesEnabled: config.customPoliciesEnabled,
|
|
308
|
+
...(cloudManagedPolicies.length > 0 ? { cloudManagedPolicies } : {}),
|
|
237
309
|
});
|
|
238
310
|
customHooksList = loadResult.hooks;
|
|
239
311
|
const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []);
|
|
240
312
|
conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
|
|
241
313
|
|
|
242
314
|
for (const hook of customHooksList) {
|
|
243
|
-
const
|
|
244
|
-
|
|
315
|
+
const taggedHook = hook as CustomHook & {
|
|
316
|
+
__policyId?: string;
|
|
317
|
+
__cloudManaged?: CloudManagedPolicyArtifact;
|
|
318
|
+
};
|
|
319
|
+
const policyId = taggedHook.__policyId;
|
|
320
|
+
const cloudManaged = taggedHook.__cloudManaged;
|
|
321
|
+
// Local config cannot disable a centrally assigned policy merely by
|
|
322
|
+
// copying its generated ID into disabledCustomPolicies.
|
|
323
|
+
if (!cloudManaged && policyId && disabledCustomPolicies.has(policyId)) continue;
|
|
324
|
+
// Same rule for a session pause — it suspends local policy, never cloud.
|
|
325
|
+
if (!cloudManaged && activePause) continue;
|
|
245
326
|
const hookName = hook.name;
|
|
246
327
|
const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope;
|
|
247
328
|
const isConvention = !!conventionScope;
|
|
248
|
-
const prefix =
|
|
329
|
+
const prefix = cloudManaged
|
|
330
|
+
? `cloud/${cloudManaged.id}@${cloudManaged.revision}`
|
|
331
|
+
: isConvention
|
|
332
|
+
? `.failproofai-${conventionScope}`
|
|
333
|
+
: "custom";
|
|
334
|
+
// Observe mode: run it for real, record what it decided, then hand back
|
|
335
|
+
// an allow. Evaluating and discarding is the whole point — a rollout is
|
|
336
|
+
// measured against real traffic before it can break anyone's work, and
|
|
337
|
+
// a policy that did not actually run would measure nothing.
|
|
338
|
+
const observeOnly = cloudManaged?.effect === "observe";
|
|
249
339
|
const fn: PolicyFunction = async (ctx): Promise<PolicyResult> => {
|
|
340
|
+
if (observeOnly) {
|
|
341
|
+
const shadow = await runObserved(hook, ctx, hookName, eventType, cli);
|
|
342
|
+
if (shadow.decision !== "allow") {
|
|
343
|
+
observedResults.push({
|
|
344
|
+
policyId: cloudManaged!.id,
|
|
345
|
+
revision: cloudManaged!.revision,
|
|
346
|
+
decision: shadow.decision,
|
|
347
|
+
reason: shadow.reason ?? null,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return { decision: "allow" };
|
|
351
|
+
}
|
|
250
352
|
try {
|
|
251
353
|
const result = await Promise.race([
|
|
252
354
|
hook.fn(ctx),
|
|
@@ -268,8 +370,18 @@ export async function evaluateHookEvent(
|
|
|
268
370
|
return { decision: "allow" };
|
|
269
371
|
}
|
|
270
372
|
};
|
|
373
|
+
const registeredName = `${prefix}/${hookName}`;
|
|
374
|
+
// Record where this policy came from as structured data, keyed by the
|
|
375
|
+
// exact name the evaluator will report back. The display name already
|
|
376
|
+
// encodes it ("cloud/org-guard@7/…"), but only as a string — so the one
|
|
377
|
+
// question cloud attribution has to answer, "which rollout produced
|
|
378
|
+
// this decision", could only be answered by re-parsing our own label.
|
|
379
|
+
policyAttribution.set(registeredName, {
|
|
380
|
+
source: cloudManaged ? "cloud" : isConvention ? "convention" : "custom",
|
|
381
|
+
...(cloudManaged ? { cloudPolicyId: cloudManaged.id, cloudRevision: cloudManaged.revision } : {}),
|
|
382
|
+
});
|
|
271
383
|
registerPolicy(
|
|
272
|
-
|
|
384
|
+
registeredName,
|
|
273
385
|
hook.description ?? "",
|
|
274
386
|
fn,
|
|
275
387
|
hook.match ?? {},
|
|
@@ -335,6 +447,32 @@ export async function evaluateHookEvent(
|
|
|
335
447
|
cwd: session.cwd,
|
|
336
448
|
permissionMode: session.permissionMode,
|
|
337
449
|
hookEventName: session.hookEventName,
|
|
450
|
+
// Attribution. A builtin is anything registered that is not in the map,
|
|
451
|
+
// so its absence is meaningful rather than missing — but only when a
|
|
452
|
+
// policy actually decided; a plain allow names nobody.
|
|
453
|
+
...(result.policyName
|
|
454
|
+
? (() => {
|
|
455
|
+
const attribution = policyAttribution.get(result.policyName);
|
|
456
|
+
return {
|
|
457
|
+
policySource: attribution?.source ?? ("builtin" as const),
|
|
458
|
+
...(attribution?.cloudPolicyId ? { cloudPolicyId: attribution.cloudPolicyId } : {}),
|
|
459
|
+
...(attribution?.cloudRevision !== undefined
|
|
460
|
+
? { cloudRevision: attribution.cloudRevision }
|
|
461
|
+
: {}),
|
|
462
|
+
};
|
|
463
|
+
})()
|
|
464
|
+
: {}),
|
|
465
|
+
...(cloudGeneration !== undefined ? { cloudGeneration } : {}),
|
|
466
|
+
// The point of observe mode is this record. Without it the rollout is
|
|
467
|
+
// unmeasurable and the row is indistinguishable from one where the policy
|
|
468
|
+
// never matched at all.
|
|
469
|
+
...(observedResults.length > 0 ? { observed: observedResults } : {}),
|
|
470
|
+
// Without these, a row logged during a pause is indistinguishable from
|
|
471
|
+
// one where every policy ran and allowed — the log would assert a clean
|
|
472
|
+
// window over exactly the window that was not enforced.
|
|
473
|
+
...(activePause
|
|
474
|
+
? { pausedBy: activePause.setBy, pauseExpiresAt: activePause.expiresAt }
|
|
475
|
+
: {}),
|
|
338
476
|
};
|
|
339
477
|
try {
|
|
340
478
|
persistHookActivity(activityEntry);
|
|
@@ -69,6 +69,45 @@ export interface HookActivityEntry {
|
|
|
69
69
|
cwd?: string;
|
|
70
70
|
permissionMode?: string;
|
|
71
71
|
hookEventName?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Set only on events evaluated while a session pause was active. Absent on
|
|
74
|
+
* every ordinary row and on every row written before pausing existed, so —
|
|
75
|
+
* like `matchedPolicies` — consumers must treat `undefined` as "not paused"
|
|
76
|
+
* rather than rendering it as unknown.
|
|
77
|
+
*/
|
|
78
|
+
pausedBy?: string;
|
|
79
|
+
/** Epoch ms the pause lifts. Present iff `pausedBy` is. */
|
|
80
|
+
pauseExpiresAt?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Where the policy that DECIDED came from. Absent when nothing decided (a
|
|
83
|
+
* plain allow) and on rows written before this existed — so, like
|
|
84
|
+
* `matchedPolicies`, `undefined` means "unknown", not "builtin".
|
|
85
|
+
*/
|
|
86
|
+
policySource?: "builtin" | "custom" | "convention" | "cloud";
|
|
87
|
+
/** Cloud policy id of the decider. Present only when `policySource` is "cloud". */
|
|
88
|
+
cloudPolicyId?: string;
|
|
89
|
+
/** Immutable revision of that policy — the half of attribution that identifies WHICH version ran. */
|
|
90
|
+
cloudRevision?: number;
|
|
91
|
+
/**
|
|
92
|
+
* The cloud generation active when this event was evaluated, recorded on
|
|
93
|
+
* every row of a managed machine regardless of what decided. "What was
|
|
94
|
+
* deployed here" is a different question from "what decided", and only this
|
|
95
|
+
* distinguishes a rollout that changed no outcomes from one that never
|
|
96
|
+
* arrived.
|
|
97
|
+
*/
|
|
98
|
+
cloudGeneration?: number;
|
|
99
|
+
/**
|
|
100
|
+
* What observe-mode policies WOULD have done, had they been enforcing. This
|
|
101
|
+
* record is the entire point of observe mode: without it the row is
|
|
102
|
+
* indistinguishable from one where the policy never matched, and the rollout
|
|
103
|
+
* being trialled is unmeasurable.
|
|
104
|
+
*/
|
|
105
|
+
observed?: Array<{
|
|
106
|
+
policyId: string;
|
|
107
|
+
revision: number;
|
|
108
|
+
decision: "deny" | "instruct";
|
|
109
|
+
reason: string | null;
|
|
110
|
+
}>;
|
|
72
111
|
}
|
|
73
112
|
|
|
74
113
|
export interface HookActivityFilters {
|
|
@@ -77,6 +116,12 @@ export interface HookActivityFilters {
|
|
|
77
116
|
policyName?: string;
|
|
78
117
|
sessionId?: string;
|
|
79
118
|
integration?: IntegrationType;
|
|
119
|
+
/**
|
|
120
|
+
* Filter by where the deciding policy came from. "Show me everything my
|
|
121
|
+
* organization's policies decided" is the question cloud rollout reporting
|
|
122
|
+
* is built on, and it is unanswerable without this.
|
|
123
|
+
*/
|
|
124
|
+
source?: "builtin" | "custom" | "convention" | "cloud";
|
|
80
125
|
}
|
|
81
126
|
|
|
82
127
|
export interface HookActivityStats {
|
|
@@ -298,6 +343,11 @@ export function searchHookActivity(
|
|
|
298
343
|
return false;
|
|
299
344
|
}
|
|
300
345
|
if (filters.integration && entry.integration !== filters.integration) return false;
|
|
346
|
+
// Rows written before attribution existed have no policySource. They are
|
|
347
|
+
// excluded from every source filter rather than guessed at — a wrong
|
|
348
|
+
// attribution is worse than a missing one when the point is proving which
|
|
349
|
+
// rollout decided something.
|
|
350
|
+
if (filters.source && entry.policySource !== filters.source) return false;
|
|
301
351
|
return true;
|
|
302
352
|
});
|
|
303
353
|
|
|
@@ -77,8 +77,9 @@ export async function resolveLocalImport(
|
|
|
77
77
|
export async function createEsmShim(
|
|
78
78
|
distIndex: string,
|
|
79
79
|
distUrl: string,
|
|
80
|
+
tmpSuffix = TMP_SUFFIX,
|
|
80
81
|
): Promise<{ shimPath: string; shimUrl: string }> {
|
|
81
|
-
const shimPath = distIndex
|
|
82
|
+
const shimPath = `${distIndex}${tmpSuffix}.shim.mjs`;
|
|
82
83
|
const shimCode = [
|
|
83
84
|
`import _cjs from '${distUrl}';`,
|
|
84
85
|
`export const customPolicies = _cjs.customPolicies;`,
|
|
@@ -101,6 +102,7 @@ export async function rewriteFileTree(
|
|
|
101
102
|
entryPath: string,
|
|
102
103
|
distUrl: string | null,
|
|
103
104
|
distIndex: string | null,
|
|
105
|
+
tmpSuffix = TMP_SUFFIX,
|
|
104
106
|
): Promise<string[]> {
|
|
105
107
|
const queue: string[] = [entryPath];
|
|
106
108
|
const visited = new Set<string>();
|
|
@@ -108,7 +110,7 @@ export async function rewriteFileTree(
|
|
|
108
110
|
|
|
109
111
|
let esmShimUrl: string | null = null;
|
|
110
112
|
if (distIndex && distUrl) {
|
|
111
|
-
const shim = await createEsmShim(distIndex, distUrl);
|
|
113
|
+
const shim = await createEsmShim(distIndex, distUrl, tmpSuffix);
|
|
112
114
|
tmpFiles.push(shim.shimPath);
|
|
113
115
|
esmShimUrl = shim.shimUrl;
|
|
114
116
|
}
|
|
@@ -148,7 +150,7 @@ export async function rewriteFileTree(
|
|
|
148
150
|
if (!visited.has(resolved) && !queue.includes(resolved)) {
|
|
149
151
|
queue.push(resolved);
|
|
150
152
|
}
|
|
151
|
-
let relPath = relative(dir, resolved +
|
|
153
|
+
let relPath = relative(dir, resolved + tmpSuffix).split("\\").join("/");
|
|
152
154
|
if (!relPath.startsWith(".")) relPath = "./" + relPath;
|
|
153
155
|
rewrites.set(specifier, relPath);
|
|
154
156
|
}
|
|
@@ -163,7 +165,7 @@ export async function rewriteFileTree(
|
|
|
163
165
|
code = code.replace(new RegExp(`"${escaped}"`, "g"), `"${replacement}"`);
|
|
164
166
|
}
|
|
165
167
|
|
|
166
|
-
const tmpPath = filePath +
|
|
168
|
+
const tmpPath = filePath + tmpSuffix;
|
|
167
169
|
await writeFile(tmpPath, code, "utf-8");
|
|
168
170
|
tmpFiles.push(tmpPath);
|
|
169
171
|
}
|
|
@@ -80,8 +80,36 @@ export interface LlmConfig {
|
|
|
80
80
|
model?: string;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The collector's non-secret settings. The ingest credential deliberately does
|
|
85
|
+
* NOT live here: this file is written with a bare `writeFileSync`, so it
|
|
86
|
+
* inherits the umask and lands at 0664 inside a 0775 `~/.failproofai/` — an API
|
|
87
|
+
* key here would be readable by every local user. It lives in
|
|
88
|
+
* `~/.failproofai/ingest.json` at 0600 instead.
|
|
89
|
+
*
|
|
90
|
+
* Read by the Rust daemon (`crates/fpai-collect/src/config.rs`); the field
|
|
91
|
+
* names must stay in step with the `Settings` struct there.
|
|
92
|
+
*/
|
|
93
|
+
export interface CollectorConfig {
|
|
94
|
+
/**
|
|
95
|
+
* Ship agent session transcripts. Defaults to false and is a SEPARATE opt-in
|
|
96
|
+
* from configuring a key, because transcripts carry prompts, file contents
|
|
97
|
+
* and whatever was pasted into a terminal.
|
|
98
|
+
*/
|
|
99
|
+
sessions?: boolean;
|
|
100
|
+
/** Ship hook activity. Carries decisions and tool names, never file contents. */
|
|
101
|
+
hooks?: boolean;
|
|
102
|
+
/** `decisions` keeps every deny/instruct exact and aggregates the ~99% allows. */
|
|
103
|
+
hooksVerbosity?: "all" | "decisions" | "off";
|
|
104
|
+
redact?: "minimal" | "off";
|
|
105
|
+
/** Label stamped on every event. Must not contain a comma — ingest skips those lines. */
|
|
106
|
+
environment?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
83
109
|
export interface HooksConfig {
|
|
84
110
|
enabledPolicies: string[];
|
|
111
|
+
/** Collector settings; see {@link CollectorConfig}. Absent means unconfigured. */
|
|
112
|
+
collector?: CollectorConfig;
|
|
85
113
|
llm?: LlmConfig;
|
|
86
114
|
policyParams?: Record<string, Record<string, unknown>>;
|
|
87
115
|
/** Explicit custom policy files, loaded in array order. */
|
|
@@ -98,6 +126,13 @@ export interface HooksConfig {
|
|
|
98
126
|
* Only ever written explicitly as `false`.
|
|
99
127
|
*/
|
|
100
128
|
customPoliciesEnabled?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Upper bound, in ms, on how long `failproofai config --pause` may suspend
|
|
131
|
+
* enforcement. Only ever LOWERS the built-in 8h ceiling — a value above it is
|
|
132
|
+
* clamped back down, because a ceiling a project can raise is not a ceiling.
|
|
133
|
+
* Absent means the built-in ceiling applies.
|
|
134
|
+
*/
|
|
135
|
+
maxPauseMs?: number;
|
|
101
136
|
/**
|
|
102
137
|
* Record of the convention policies (`.failproofai/policies/*policies.mjs`)
|
|
103
138
|
* discovered at this scope the last time a failproofai command ran.
|