failproofai 1.0.2-beta.4 → 1.0.2-beta.6
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 +5 -5
- 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 +2 -2
- 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 +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- 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 +2 -2
- 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/_not-found/__PAGE__.segment.rsc +13 -13
- 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 +4 -4
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/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/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +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 +2 -2
- 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 +13 -13
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
- 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 +2 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- 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 +2 -2
- 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 +2 -2
- 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 +2 -2
- 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 +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 +2 -2
- 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/app/settings/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__03r-lqq._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__12021ot._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0clqfab._.js → [root-of-the-server]__1tg4pu6._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
- package/.next/standalone/.next/server/chunks/{_1lbh81u._.js → _19uwam-._.js} +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +7 -7
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1m84s36._.js → [root-of-the-server]__00-s7h8._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +6 -6
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__14muoc4._.js → [root-of-the-server]__128se8m._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0jzbk4_._.js → [root-of-the-server]__190won8._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0rsd3ll._.js → [root-of-the-server]__1sc84bb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_0ovx4lz._.js → _0no96tu._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_1dkh76z._.js → _11fc-hb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_1db3-8n._.js → _1if9ixi._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- 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/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_1cf5ya2._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_15bjxkm._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
- 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 +19 -19
- package/.next/standalone/.next/static/chunks/{0lshnlpy1m6bc.js → 0931avh-06lj5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0k_yvjc1yz0pq.js → 0h-3zffoy-_ji.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0ds64uculrze8.js → 0p84npzxdgpc5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0d_re32wscs0b.js → 0sdgnflvw8kta.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2dbi_x0c8n8fi.js → 0z7au3njlak0j.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1o0ahew9568ls.js → 1id7o44jjegf0.js} +1 -1
- package/.next/standalone/.next/static/chunks/{00_t-xvc59tkb.js → 276n-edifojbd.js} +2 -2
- package/.next/standalone/.next/static/chunks/{07j2u70_3qsx8.js → 2jfskwd2u8o5r.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2817_8e94ah9z.js → 2pxr7nr1nyotn.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1oz0svxwt9574.js → 3i8rg2fv9nd6e.js} +2 -2
- package/.next/standalone/.next/static/chunks/{1q76jk_1_wgjq.js → 3vu_7_eor1m-k.js} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-17utl_jw0wj11.js → turbopack-2x-o099q64i8h.js} +1 -1
- package/.next/standalone/lib/opencode-db.ts +77 -0
- package/.next/standalone/lib/opencode-projects.ts +37 -41
- package/.next/standalone/lib/opencode-sessions.ts +59 -36
- package/.next/standalone/package.json +9 -9
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +116 -34
- package/dist/cli.mjs +1732 -1177
- package/dist/worker.mjs +180 -165
- package/lib/opencode-db.ts +77 -0
- package/lib/opencode-projects.ts +37 -41
- package/lib/opencode-sessions.ts +59 -36
- package/package.json +9 -9
- package/src/audit/cache.ts +167 -11
- package/src/audit/cli-adapters/claude.ts +111 -1
- package/src/audit/cli-adapters/index.ts +31 -1
- package/src/audit/cli.ts +12 -0
- package/src/audit/index.ts +164 -13
- package/src/hooks/configure-wizard.ts +112 -18
- package/src/hooks/fp-reset.ts +1 -1
- package/src/hooks/handler.ts +13 -1
- package/src/hooks/manager.ts +69 -4
- package/src/hooks/onboarding-attempt.ts +4 -4
- package/src/hooks/pack-cli.ts +399 -32
- package/src/hooks/pack-store.ts +21 -8
- package/src/hooks/read-stdin.ts +78 -8
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1l-khtb._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mhmdzs._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +0 -3
- /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → mAYN9BOxWVFREnVp3yCHp}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → mAYN9BOxWVFREnVp3yCHp}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → mAYN9BOxWVFREnVp3yCHp}/_ssgManifest.js +0 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* transcripts under <sessionId>/subagents/) via lib/claude-sessions.ts, then
|
|
6
6
|
* parses each via lib/log-entries.ts.
|
|
7
7
|
*/
|
|
8
|
-
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { readFile, open, type FileHandle } from "node:fs/promises";
|
|
9
9
|
import {
|
|
10
10
|
listClaudeProjects,
|
|
11
11
|
listClaudeTranscripts,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { parseLogContent, type LogSource } from "../../../lib/log-entries";
|
|
15
15
|
import type { NormalizedToolEvent, TranscriptMetadata } from "../types";
|
|
16
16
|
import { logEntriesToEvents } from "./shared";
|
|
17
|
+
import type { IncrementalEvents } from "./index";
|
|
17
18
|
|
|
18
19
|
export interface ListOpts {
|
|
19
20
|
/** Restrict to sessions whose decoded cwd matches one of these paths. */
|
|
@@ -95,3 +96,112 @@ export async function streamClaudeEvents(
|
|
|
95
96
|
cwd,
|
|
96
97
|
});
|
|
97
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The events after `fromByte`, and nothing before it.
|
|
102
|
+
*
|
|
103
|
+
* Claude writes session transcripts as append-only JSONL, which is what makes
|
|
104
|
+
* this sound: the bytes before `fromByte` were parsed on an earlier run and
|
|
105
|
+
* have not moved. The caller has already established that — see
|
|
106
|
+
* `anchorHashFor` in the audit cache — so this reads from the offset and does
|
|
107
|
+
* not re-verify it.
|
|
108
|
+
*
|
|
109
|
+
* Two details carry the correctness:
|
|
110
|
+
*
|
|
111
|
+
* - The read starts at `fromByte`, which the caller only ever sets to a
|
|
112
|
+
* position it previously reported through `bytesConsumed`, so it always
|
|
113
|
+
* lands at the start of a line.
|
|
114
|
+
* - It stops at the last NEWLINE, not at end-of-file. A transcript is
|
|
115
|
+
* frequently being written while the audit reads it, so the tail is often
|
|
116
|
+
* half a line; parsing it would drop or mangle that event, and reporting
|
|
117
|
+
* the file size as consumed would mean the next run started after it and
|
|
118
|
+
* lost it for good.
|
|
119
|
+
*/
|
|
120
|
+
export async function streamClaudeEventsFrom(
|
|
121
|
+
meta: TranscriptMetadata,
|
|
122
|
+
fromByte: number,
|
|
123
|
+
): Promise<IncrementalEvents | null> {
|
|
124
|
+
if (!Number.isFinite(fromByte) || fromByte < 0) return null;
|
|
125
|
+
let handle: FileHandle | null = null;
|
|
126
|
+
let chunk: string;
|
|
127
|
+
try {
|
|
128
|
+
handle = await open(meta.transcriptPath, "r");
|
|
129
|
+
const { size } = await handle.stat();
|
|
130
|
+
if (fromByte > size) return null; // truncated under us; caller re-scans
|
|
131
|
+
if (fromByte === size) {
|
|
132
|
+
return { events: [], bytesConsumed: fromByte };
|
|
133
|
+
}
|
|
134
|
+
const buf = Buffer.alloc(size - fromByte);
|
|
135
|
+
await handle.read(buf, 0, buf.length, fromByte);
|
|
136
|
+
chunk = buf.toString("utf-8");
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
} finally {
|
|
140
|
+
if (handle) {
|
|
141
|
+
try { await handle.close(); } catch { /* nothing useful to do */ }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Everything up to the last newline is unambiguously complete. What follows
|
|
146
|
+
// it is either a line still being written, or the last line of a file that
|
|
147
|
+
// simply does not end in a newline — and those need opposite treatment:
|
|
148
|
+
// dropping the first is required, dropping the second loses the final event
|
|
149
|
+
// of every completed transcript.
|
|
150
|
+
//
|
|
151
|
+
// The only signal available in the bytes is whether the remainder PARSES. A
|
|
152
|
+
// half-written JSONL record does not (its braces are unclosed); a whole one
|
|
153
|
+
// missing only its newline does. So a parseable tail is taken and counted,
|
|
154
|
+
// and an unparseable one is left for the next run to pick up whole — which
|
|
155
|
+
// is also what stops it being counted twice when it arrives.
|
|
156
|
+
const lastNewline = chunk.lastIndexOf("\n");
|
|
157
|
+
const head = lastNewline === -1 ? "" : chunk.slice(0, lastNewline + 1);
|
|
158
|
+
const tail = chunk.slice(lastNewline + 1);
|
|
159
|
+
const tailIsWholeLine = tail.trim().length > 0 && (() => {
|
|
160
|
+
try {
|
|
161
|
+
JSON.parse(tail);
|
|
162
|
+
return true;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
const complete = tailIsWholeLine ? head + tail : head;
|
|
168
|
+
if (complete.length === 0) {
|
|
169
|
+
// Nothing whole to parse, and the offset does not move.
|
|
170
|
+
return { events: [], bytesConsumed: fromByte };
|
|
171
|
+
}
|
|
172
|
+
// Byte length, not string length — a multi-byte character in the tail would
|
|
173
|
+
// otherwise put the next resume offset mid-character.
|
|
174
|
+
const bytesConsumed = fromByte + Buffer.byteLength(complete, "utf-8");
|
|
175
|
+
|
|
176
|
+
let entries;
|
|
177
|
+
try {
|
|
178
|
+
entries = await parseLogContent(complete, "session" as LogSource);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let cwd = "";
|
|
184
|
+
for (const line of complete.split("\n", 50)) {
|
|
185
|
+
if (!line.trim()) continue;
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(line) as { cwd?: unknown };
|
|
188
|
+
if (typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
|
|
189
|
+
cwd = parsed.cwd;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// skip malformed lines
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
events: logEntriesToEvents(entries, {
|
|
199
|
+
cli: "claude",
|
|
200
|
+
sessionId: meta.sessionId,
|
|
201
|
+
transcriptPath: meta.transcriptPath,
|
|
202
|
+
cwd,
|
|
203
|
+
}),
|
|
204
|
+
bytesConsumed,
|
|
205
|
+
cwd: cwd || undefined,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -11,7 +11,7 @@ import type { IntegrationType } from "../../hooks/types";
|
|
|
11
11
|
import type { NormalizedToolEvent, TranscriptMetadata } from "../types";
|
|
12
12
|
import type { ListOpts } from "./claude";
|
|
13
13
|
|
|
14
|
-
import { listClaudeTranscriptMetadata, streamClaudeEvents } from "./claude";
|
|
14
|
+
import { listClaudeTranscriptMetadata, streamClaudeEvents, streamClaudeEventsFrom } from "./claude";
|
|
15
15
|
import { listCodexTranscriptMetadata, streamCodexEvents } from "./codex";
|
|
16
16
|
import { listCopilotTranscriptMetadata, streamCopilotEvents } from "./copilot";
|
|
17
17
|
import { listCursorTranscriptMetadata, streamCursorEvents } from "./cursor";
|
|
@@ -26,10 +26,39 @@ import { listGooseTranscriptMetadata, streamGooseEvents } from "./goose";
|
|
|
26
26
|
|
|
27
27
|
export type { ListOpts };
|
|
28
28
|
|
|
29
|
+
/** What an append-only source can hand back when asked to resume. */
|
|
30
|
+
export interface IncrementalEvents {
|
|
31
|
+
events: NormalizedToolEvent[];
|
|
32
|
+
/**
|
|
33
|
+
* How far into the file `events` accounts for, at a LINE boundary — which is
|
|
34
|
+
* NOT the file size when a transcript is being appended to as it is read. The
|
|
35
|
+
* trailing partial line is left unparsed and uncounted so the next run picks
|
|
36
|
+
* it up whole; recording the file size instead would skip it permanently.
|
|
37
|
+
*/
|
|
38
|
+
bytesConsumed: number;
|
|
39
|
+
/** The session cwd, when the resumed region carried one. */
|
|
40
|
+
cwd?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
export interface CliAdapter {
|
|
30
44
|
cli: IntegrationType;
|
|
31
45
|
listTranscripts: (opts?: ListOpts) => Promise<TranscriptMetadata[]>;
|
|
32
46
|
streamEvents: (meta: TranscriptMetadata) => Promise<NormalizedToolEvent[]>;
|
|
47
|
+
/**
|
|
48
|
+
* Read only the events after `fromByte`, for sources whose transcripts are
|
|
49
|
+
* append-only files.
|
|
50
|
+
*
|
|
51
|
+
* OPTIONAL, and deliberately a separate method rather than a parameter on
|
|
52
|
+
* `streamEvents`. A parameter an adapter ignored would return the WHOLE
|
|
53
|
+
* transcript to a caller that is about to merge it into an existing partial
|
|
54
|
+
* result — every event counted twice, quietly, on exactly the sources that
|
|
55
|
+
* had not been updated. Absence of this method is what tells the caller it
|
|
56
|
+
* cannot resume, and there is no way to be wrong about that by omission.
|
|
57
|
+
*/
|
|
58
|
+
streamEventsFrom?: (
|
|
59
|
+
meta: TranscriptMetadata,
|
|
60
|
+
fromByte: number,
|
|
61
|
+
) => Promise<IncrementalEvents | null>;
|
|
33
62
|
}
|
|
34
63
|
|
|
35
64
|
export const ADAPTERS: Record<IntegrationType, CliAdapter> = {
|
|
@@ -37,6 +66,7 @@ export const ADAPTERS: Record<IntegrationType, CliAdapter> = {
|
|
|
37
66
|
cli: "claude",
|
|
38
67
|
listTranscripts: listClaudeTranscriptMetadata,
|
|
39
68
|
streamEvents: streamClaudeEvents,
|
|
69
|
+
streamEventsFrom: streamClaudeEventsFrom,
|
|
40
70
|
},
|
|
41
71
|
codex: {
|
|
42
72
|
cli: "codex",
|
package/src/audit/cli.ts
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* an exit code. See `runScheduledAudit`.
|
|
26
26
|
*/
|
|
27
27
|
import { runAudit } from "./index";
|
|
28
|
+
import { hasInstalledPacks } from "../hooks/pack-manifest";
|
|
29
|
+
import { CORE_SOURCE } from "../hooks/pack-store";
|
|
28
30
|
import { acquireAuditLock, type AuditLockInfo } from "./audit-lock";
|
|
29
31
|
import { writeDashboardCache } from "./dashboard-cache";
|
|
30
32
|
import type { AuditResult, RunAuditOptions } from "./types";
|
|
@@ -504,6 +506,16 @@ export async function runPostSetupAudit(): Promise<void> {
|
|
|
504
506
|
writeDashboardCache({}, result);
|
|
505
507
|
printSummary(result);
|
|
506
508
|
process.stdout.write("\n");
|
|
509
|
+
// The audit says what ALREADY happened; nothing here says how to stop it
|
|
510
|
+
// happening again. This is the first thing a new machine runs, and setup
|
|
511
|
+
// installs no policies by design, so without this the whole first session
|
|
512
|
+
// ends on a count of findings and no way to act on it.
|
|
513
|
+
if (!hasInstalledPacks()) {
|
|
514
|
+
process.stdout.write(
|
|
515
|
+
` ${c(DIM, "none of this is being enforced yet. take ours, or anyone's:")}\n` +
|
|
516
|
+
` ${c(CYAN, `failproofai policies add ${CORE_SOURCE}`)}\n\n`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
507
519
|
} finally {
|
|
508
520
|
attempt.lock.release();
|
|
509
521
|
}
|
package/src/audit/index.ts
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
import { batchAll } from "../../lib/concurrency";
|
|
11
11
|
import { BUILTIN_POLICIES } from "../hooks/builtin-policies";
|
|
12
12
|
import { readMergedHooksConfig } from "../hooks/hooks-config";
|
|
13
|
+
import { readInstalledPacks } from "../hooks/pack-manifest";
|
|
13
14
|
import { normalizePolicyName } from "../hooks/policy-registry";
|
|
14
15
|
import { INTEGRATION_TYPES, type IntegrationType } from "../hooks/types";
|
|
15
16
|
import { ADAPTERS } from "./cli-adapters";
|
|
16
17
|
import { AUDIT_DETECTORS } from "./detectors";
|
|
17
18
|
import { severityForBuiltin } from "./features";
|
|
18
|
-
import {
|
|
19
|
+
import { readCachedTranscript, writeCachedTranscriptResult } from "./cache";
|
|
19
20
|
import { initReplay, replayEvent, restoreReplay } from "./replay";
|
|
20
21
|
import {
|
|
21
22
|
AUDIT_EXAMPLE_MAX_CHARS,
|
|
@@ -87,7 +88,24 @@ function parseSinceOpt(since: string | undefined): number | undefined {
|
|
|
87
88
|
throw new Error(`Invalid --since value: "${since}" (expected e.g. "7d", "30d", or "2026-04-01")`);
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
/**
|
|
92
|
+
* What a scan covered, alongside the result — so the caller can record a resume
|
|
93
|
+
* point without re-deriving it.
|
|
94
|
+
*
|
|
95
|
+
* `bytesScanned` is 0 when the source cannot be resumed at all (a database, or
|
|
96
|
+
* an adapter with no `streamEventsFrom`), which is what stops a resume point
|
|
97
|
+
* being written for something that has no bytes to resume from.
|
|
98
|
+
*/
|
|
99
|
+
interface ScanOutcome {
|
|
100
|
+
result: TranscriptAuditResult;
|
|
101
|
+
bytesScanned: number;
|
|
102
|
+
detectorState: DetectorSessionState;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function scanOneTranscript(
|
|
106
|
+
meta: TranscriptMetadata,
|
|
107
|
+
resume?: { fromByte: number; detectorState: DetectorSessionState },
|
|
108
|
+
): Promise<ScanOutcome> {
|
|
91
109
|
const empty: TranscriptAuditResult = {
|
|
92
110
|
transcriptPath: meta.transcriptPath,
|
|
93
111
|
cli: meta.cli,
|
|
@@ -102,17 +120,54 @@ async function scanOneTranscript(meta: TranscriptMetadata): Promise<TranscriptAu
|
|
|
102
120
|
rangeByName: {},
|
|
103
121
|
};
|
|
104
122
|
|
|
123
|
+
const adapter = ADAPTERS[meta.cli];
|
|
105
124
|
// Stream failures must propagate so the orchestrator counts them in
|
|
106
125
|
// `errors` rather than silently returning an empty hits map.
|
|
107
|
-
|
|
108
|
-
|
|
126
|
+
let events: NormalizedToolEvent[];
|
|
127
|
+
let bytesScanned = 0;
|
|
128
|
+
// A resumed scan carries the stateful detectors' state forward across the
|
|
129
|
+
// boundary. Starting them empty would silently change what they find — the
|
|
130
|
+
// re-read detector's countdown spans tool calls, so an edit before the offset
|
|
131
|
+
// and its wasteful re-read after it would stop being a pair.
|
|
132
|
+
const sessionState: DetectorSessionState = resume?.detectorState ?? {};
|
|
133
|
+
|
|
134
|
+
if (adapter.streamEventsFrom) {
|
|
135
|
+
// A full scan is a resume from zero, deliberately — not a separate branch.
|
|
136
|
+
//
|
|
137
|
+
// The separate branch is what got this wrong: it recorded `meta.sizeBytes`
|
|
138
|
+
// as scanned, which is the whole FILE, including a trailing partial line
|
|
139
|
+
// that was never parsed. Transcripts are appended to while the audit reads
|
|
140
|
+
// them, so that tail is routinely half a line — and the next run then
|
|
141
|
+
// resumed past an event nobody had read. Only the reader knows where the
|
|
142
|
+
// last complete line ended, so only the reader gets to say.
|
|
143
|
+
const scan = await adapter.streamEventsFrom(meta, resume?.fromByte ?? 0);
|
|
144
|
+
// Null means it could not be read from there after all — truncated,
|
|
145
|
+
// unreadable, unparseable. A thrown error would count as a scan failure,
|
|
146
|
+
// so fall back to a full read instead and keep the transcript scannable.
|
|
147
|
+
if (!scan) {
|
|
148
|
+
events = await adapter.streamEvents(meta);
|
|
149
|
+
bytesScanned = 0;
|
|
150
|
+
} else {
|
|
151
|
+
events = scan.events;
|
|
152
|
+
bytesScanned = scan.bytesConsumed;
|
|
153
|
+
empty.cwd = scan.cwd ?? "";
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
// A source with no byte offsets to speak of — a database. Scanned whole,
|
|
157
|
+
// every time, and given no resume point to mislead the next run with.
|
|
158
|
+
events = await adapter.streamEvents(meta);
|
|
159
|
+
bytesScanned = 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (events.length === 0) {
|
|
163
|
+
return { result: empty, bytesScanned, detectorState: sessionState };
|
|
164
|
+
}
|
|
109
165
|
|
|
110
166
|
const result = empty;
|
|
111
167
|
result.eventsScanned = events.length;
|
|
112
168
|
// Capture the session's cwd from the first event that carried one — every
|
|
113
169
|
// event in a single transcript shares the same cwd by construction.
|
|
114
|
-
result.cwd = events[0].cwd || "";
|
|
115
|
-
const sessionState: DetectorSessionState = {};
|
|
170
|
+
result.cwd = result.cwd || events[0].cwd || "";
|
|
116
171
|
|
|
117
172
|
for (const event of events) {
|
|
118
173
|
// Run audit detectors first (stateful, must see every event).
|
|
@@ -146,7 +201,7 @@ async function scanOneTranscript(meta: TranscriptMetadata): Promise<TranscriptAu
|
|
|
146
201
|
}
|
|
147
202
|
}
|
|
148
203
|
|
|
149
|
-
return result;
|
|
204
|
+
return { result, bytesScanned, detectorState: sessionState };
|
|
150
205
|
}
|
|
151
206
|
|
|
152
207
|
function formatPolicyExample(_policyName: string, event: NormalizedToolEvent): string {
|
|
@@ -159,6 +214,55 @@ function formatPolicyExample(_policyName: string, event: NormalizedToolEvent): s
|
|
|
159
214
|
return `${event.toolName}`;
|
|
160
215
|
}
|
|
161
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Fold a tail-scan into the result the cache already held for the same
|
|
219
|
+
* transcript.
|
|
220
|
+
*
|
|
221
|
+
* Mirrors `recordHit` exactly, and has to: counts add, examples stay capped at
|
|
222
|
+
* the same limit, ranges widen. The one asymmetry is `cwd` — the cached value
|
|
223
|
+
* came from the FIRST event in the file and the tail's came from the first
|
|
224
|
+
* event after the offset, so the older one wins.
|
|
225
|
+
*/
|
|
226
|
+
function mergeIncremental(
|
|
227
|
+
cached: TranscriptAuditResult,
|
|
228
|
+
tail: TranscriptAuditResult,
|
|
229
|
+
): TranscriptAuditResult {
|
|
230
|
+
const out: TranscriptAuditResult = {
|
|
231
|
+
...cached,
|
|
232
|
+
mtimeMs: tail.mtimeMs,
|
|
233
|
+
sizeBytes: tail.sizeBytes,
|
|
234
|
+
cwd: cached.cwd || tail.cwd || "",
|
|
235
|
+
eventsScanned: (cached.eventsScanned ?? 0) + (tail.eventsScanned ?? 0),
|
|
236
|
+
hitsByName: { ...cached.hitsByName },
|
|
237
|
+
examplesByName: {},
|
|
238
|
+
rangeByName: { ...cached.rangeByName },
|
|
239
|
+
};
|
|
240
|
+
for (const [name, list] of Object.entries(cached.examplesByName)) {
|
|
241
|
+
out.examplesByName[name] = [...list];
|
|
242
|
+
}
|
|
243
|
+
for (const [name, count] of Object.entries(tail.hitsByName)) {
|
|
244
|
+
out.hitsByName[name] = (out.hitsByName[name] ?? 0) + count;
|
|
245
|
+
}
|
|
246
|
+
for (const [name, list] of Object.entries(tail.examplesByName)) {
|
|
247
|
+
const exs = out.examplesByName[name] ?? [];
|
|
248
|
+
for (const ex of list) {
|
|
249
|
+
if (exs.length >= AUDIT_MAX_EXAMPLES_PER_NAME) break;
|
|
250
|
+
exs.push(ex);
|
|
251
|
+
}
|
|
252
|
+
out.examplesByName[name] = exs;
|
|
253
|
+
}
|
|
254
|
+
for (const [name, range] of Object.entries(tail.rangeByName)) {
|
|
255
|
+
const existing = out.rangeByName[name];
|
|
256
|
+
if (!existing) {
|
|
257
|
+
out.rangeByName[name] = { ...range };
|
|
258
|
+
} else {
|
|
259
|
+
if (range.first < existing.first) existing.first = range.first;
|
|
260
|
+
if (range.last > existing.last) existing.last = range.last;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
162
266
|
function recordHit(
|
|
163
267
|
result: TranscriptAuditResult,
|
|
164
268
|
name: string,
|
|
@@ -276,12 +380,42 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
|
|
|
276
380
|
const clis = (opts.clis ?? Array.from(INTEGRATION_TYPES)) as IntegrationType[];
|
|
277
381
|
const sinceMs = parseSinceOpt(opts.since);
|
|
278
382
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
383
|
+
// What the user actually has switched on — drives the "already protected"
|
|
384
|
+
// vs "slipping through" split.
|
|
385
|
+
//
|
|
386
|
+
// Read from BOTH places it can live, because it moved and this only followed
|
|
387
|
+
// it half way. `enabledPolicies` is the pre-packs key, still written by
|
|
388
|
+
// `policies add <name>` and still true on a machine running the migration
|
|
389
|
+
// shim. Everything installed as a PACK records its selection in
|
|
390
|
+
// `installed.json` instead, and `pack-store` never writes `enabledPolicies`
|
|
391
|
+
// at all — so reading that key alone meant installing a pack could not move
|
|
392
|
+
// this split. Every policy read as slipping through no matter what was
|
|
393
|
+
// enforcing, and the number the audit leads with never changed however many
|
|
394
|
+
// policies you installed. Verified: same 302 hits, same 17 findings, before
|
|
395
|
+
// and after installing all 38.
|
|
396
|
+
//
|
|
397
|
+
// The split, not the REPLAY. What gets replayed stays the compiled builtins
|
|
398
|
+
// on purpose — see `initReplay`: an audit is a fixed yardstick, and one that
|
|
399
|
+
// changed shape with whatever pack a machine happened to have could not be
|
|
400
|
+
// compared against its own history. A pack policy sharing a builtin's name
|
|
401
|
+
// marks that builtin protected, which is exactly right: the same rule is
|
|
402
|
+
// enforcing, whichever artifact carries it.
|
|
281
403
|
const userConfig = readMergedHooksConfig();
|
|
282
404
|
const enabledBuiltins = new Set(
|
|
283
405
|
(userConfig.enabledPolicies ?? []).map((n) => normalizePolicyName(n)),
|
|
284
406
|
);
|
|
407
|
+
try {
|
|
408
|
+
for (const pack of readInstalledPacks().packs) {
|
|
409
|
+
// `enabled: null` means the whole pack was taken, so every policy in its
|
|
410
|
+
// catalog counts — the field records a SELECTION, and its absence is not
|
|
411
|
+
// an empty one.
|
|
412
|
+
const taken = pack.enabled ?? pack.policies.map((entry) => entry.name);
|
|
413
|
+
for (const name of taken) enabledBuiltins.add(normalizePolicyName(name));
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
// An unreadable manifest must not fail the audit. It costs the split its
|
|
417
|
+
// pack half, which is the same answer this gave for every machine before.
|
|
418
|
+
}
|
|
285
419
|
|
|
286
420
|
// 1. Discover transcripts across all selected CLIs.
|
|
287
421
|
const allTranscripts: TranscriptMetadata[] = [];
|
|
@@ -300,14 +434,31 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
|
|
|
300
434
|
let skipped = 0;
|
|
301
435
|
let errors = 0;
|
|
302
436
|
const tasks = allTranscripts.map((meta) => async (): Promise<TranscriptAuditResult> => {
|
|
437
|
+
let resume: { fromByte: number; detectorState: DetectorSessionState } | undefined;
|
|
438
|
+
let cachedPrefix: TranscriptAuditResult | null = null;
|
|
303
439
|
if (!opts.noCache) {
|
|
304
|
-
const
|
|
305
|
-
if (
|
|
440
|
+
const found = readCachedTranscript(meta.transcriptPath, meta.mtimeMs, meta.sizeBytes);
|
|
441
|
+
if (found?.kind === "hit") return found.result;
|
|
442
|
+
resume = found?.kind === "resume"
|
|
443
|
+
? { fromByte: found.fromByte, detectorState: found.detectorState }
|
|
444
|
+
: undefined;
|
|
445
|
+
cachedPrefix = found?.kind === "resume" ? found.result : null;
|
|
306
446
|
}
|
|
307
447
|
try {
|
|
308
|
-
const
|
|
448
|
+
const scan = await scanOneTranscript(meta, resume);
|
|
449
|
+
// A resumed scan produced hits for the TAIL only; the cached result holds
|
|
450
|
+
// everything before it. Merging is what makes the two halves one answer.
|
|
451
|
+
const fresh = cachedPrefix ? mergeIncremental(cachedPrefix, scan.result) : scan.result;
|
|
309
452
|
if (!opts.noCache) {
|
|
310
|
-
writeCachedTranscriptResult(
|
|
453
|
+
writeCachedTranscriptResult(
|
|
454
|
+
meta.transcriptPath,
|
|
455
|
+
meta.mtimeMs,
|
|
456
|
+
meta.sizeBytes,
|
|
457
|
+
fresh,
|
|
458
|
+
scan.bytesScanned > 0
|
|
459
|
+
? { bytesScanned: scan.bytesScanned, detectorState: scan.detectorState }
|
|
460
|
+
: undefined,
|
|
461
|
+
);
|
|
311
462
|
}
|
|
312
463
|
return fresh;
|
|
313
464
|
} catch {
|