@andares/pi-toolkits 0.9.8 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -5
- package/README.zh-CN.md +102 -5
- package/package.json +8 -3
- package/src/features/init/analyze.ts +162 -0
- package/src/features/init/apply.test.ts +264 -0
- package/src/features/init/apply.ts +693 -0
- package/src/features/init/audit.test.ts +292 -0
- package/src/features/init/audit.ts +586 -0
- package/src/features/init/coverage.test.ts +93 -0
- package/src/features/init/coverage.ts +163 -0
- package/src/features/init/detect.test.ts +237 -0
- package/src/features/init/detect.ts +808 -0
- package/src/features/init/dispatch.ts +135 -0
- package/src/features/init/index.test.ts +331 -0
- package/src/features/init/index.ts +569 -0
- package/src/features/init/plan.test.ts +261 -0
- package/src/features/init/plan.ts +867 -0
- package/src/features/init/render.ts +131 -0
- package/src/features/init/spec.ts +429 -0
- package/src/features/init/templates/agents-skeleton.md +44 -0
- package/src/features/init/templates/memory-readme.md +99 -0
- package/src/features/init/templates/signpost.md +15 -0
- package/src/features/init/templates/state-gitignore +6 -0
- package/src/features/init/templates.test.ts +58 -0
- package/src/features/init/templates.ts +79 -0
- package/src/features/init/test-helpers.ts +89 -0
- package/src/features/init/tool.ts +72 -0
- package/src/features/init/types.ts +284 -0
- package/src/features/init/ui.ts +160 -0
- package/src/features/permission/audit.test.ts +159 -0
- package/src/features/permission/audit.ts +145 -0
- package/src/features/permission/index.test.ts +138 -3
- package/src/features/permission/index.ts +108 -12
- package/src/features/permission/policy.ts +39 -3
- package/src/index.ts +9 -2
- package/src/lib/approval-log.test.ts +289 -0
- package/src/lib/approval-log.ts +724 -0
- package/src/lib/gates.audit.test.ts +343 -0
- package/src/lib/gates.ts +291 -10
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* approval-log — capped, rolling, append-only audit of approval decisions.
|
|
3
|
+
*
|
|
4
|
+
* The log is write-only telemetry: nothing in this module is ever consulted
|
|
5
|
+
* when a permission verdict is made, so a full disk, a read-only home, or a
|
|
6
|
+
* deleted directory changes no decision. Failures therefore fail **open**
|
|
7
|
+
* (logging is disabled for the process with one warning), unlike the gate
|
|
8
|
+
* itself, which fails closed.
|
|
9
|
+
*
|
|
10
|
+
* On-disk shape: one JSON object per line (`JSON.stringify` escapes newlines,
|
|
11
|
+
* so a record never spans lines), files named `audit-NNNNNN.jsonl` and read in
|
|
12
|
+
* lexical order. A file is rotated when it reaches 1000 records **or** 8 MiB;
|
|
13
|
+
* after a new file is created, the lowest-numbered file is removed so at most
|
|
14
|
+
* ten files (≈10k typical records) remain.
|
|
15
|
+
*
|
|
16
|
+
* Why JSONL rather than SQLite or CSV:
|
|
17
|
+
* - appends never rewrite existing bytes (no write amplification, no VACUUM);
|
|
18
|
+
* - a crash can leave at most one truncated tail line, which readers skip;
|
|
19
|
+
* - `jq` / `readline` can stream all files for analysis, and the nested
|
|
20
|
+
* command decomposition has a natural home;
|
|
21
|
+
* - rotation is an `unlink`, not a compaction pass.
|
|
22
|
+
*
|
|
23
|
+
* Writes are synchronous and unbatched: approval events happen at tool-call
|
|
24
|
+
* pace (seconds apart), so one ~2 KiB append plus a readdir/stat costs tens of
|
|
25
|
+
* microseconds. No `fsync` is issued — the page cache coalesces appends and
|
|
26
|
+
* the kernel writes them back sequentially, which keeps physical writes close
|
|
27
|
+
* to the logical volume and leaves SSD wear negligible.
|
|
28
|
+
*
|
|
29
|
+
* Redaction is applied to every string in the record (commands, unit args,
|
|
30
|
+
* prompts, reasons) before serialization. `write` / `edit` *content* bodies are
|
|
31
|
+
* never logged at all: the policy only needs paths.
|
|
32
|
+
*/
|
|
33
|
+
import {
|
|
34
|
+
appendFileSync,
|
|
35
|
+
chmodSync,
|
|
36
|
+
closeSync,
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
openSync,
|
|
40
|
+
readFileSync,
|
|
41
|
+
readSync,
|
|
42
|
+
readdirSync,
|
|
43
|
+
statSync,
|
|
44
|
+
unlinkSync,
|
|
45
|
+
writeFileSync,
|
|
46
|
+
} from "node:fs";
|
|
47
|
+
import { join } from "node:path";
|
|
48
|
+
import {
|
|
49
|
+
getAgentDir,
|
|
50
|
+
VERSION,
|
|
51
|
+
} from "@earendil-works/pi-coding-agent";
|
|
52
|
+
import type {
|
|
53
|
+
ExtensionContext,
|
|
54
|
+
ToolCallEvent,
|
|
55
|
+
} from "@earendil-works/pi-coding-agent";
|
|
56
|
+
|
|
57
|
+
/** Bump when a reader can no longer assume the current field layout. */
|
|
58
|
+
export const APPROVAL_LOG_SCHEMA = 1;
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_MAX_FILES = 10;
|
|
61
|
+
export const DEFAULT_MAX_RECORDS = 1000;
|
|
62
|
+
export const DEFAULT_MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
63
|
+
export const DEFAULT_MAX_RECORD_BYTES = 16 * 1024;
|
|
64
|
+
|
|
65
|
+
/** `interesting` skips plain mode-allowed calls; `all` logs them too. */
|
|
66
|
+
export type ApprovalLogMode = "off" | "interesting" | "all";
|
|
67
|
+
|
|
68
|
+
/** Everything a record may contain; keeps the log JSON-serializable by type. */
|
|
69
|
+
export type JsonValue =
|
|
70
|
+
| string
|
|
71
|
+
| number
|
|
72
|
+
| boolean
|
|
73
|
+
| null
|
|
74
|
+
| JsonValue[]
|
|
75
|
+
| { [key: string]: JsonValue | undefined };
|
|
76
|
+
|
|
77
|
+
export type ApprovalRecord = { [key: string]: JsonValue | undefined };
|
|
78
|
+
|
|
79
|
+
export interface ApprovalLogOptions {
|
|
80
|
+
/** Directory; defaults to `$PI_APPROVAL_LOG_DIR` or `<agentDir>/approval-log`. */
|
|
81
|
+
readonly dir?: string;
|
|
82
|
+
/** Defaults to `$PI_APPROVAL_LOG` or `interesting`. */
|
|
83
|
+
readonly mode?: ApprovalLogMode;
|
|
84
|
+
readonly maxFiles?: number;
|
|
85
|
+
readonly maxRecords?: number;
|
|
86
|
+
readonly maxFileBytes?: number;
|
|
87
|
+
readonly maxRecordBytes?: number;
|
|
88
|
+
readonly now?: () => Date;
|
|
89
|
+
/** Receives the one-time failure warning. Defaults to stderr. */
|
|
90
|
+
readonly warn?: (message: string) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ApprovalLog {
|
|
94
|
+
readonly dir: string;
|
|
95
|
+
readonly mode: ApprovalLogMode;
|
|
96
|
+
/** False once a write failed; the process stops logging. */
|
|
97
|
+
readonly active: boolean;
|
|
98
|
+
append(record: ApprovalRecord): void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* -------------------------------------------------------------------------- */
|
|
102
|
+
/* Record envelope */
|
|
103
|
+
/* -------------------------------------------------------------------------- */
|
|
104
|
+
|
|
105
|
+
const EXTENSION_VERSION: string | undefined = (() => {
|
|
106
|
+
try {
|
|
107
|
+
const pkg = JSON.parse(
|
|
108
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
|
109
|
+
) as { version?: unknown };
|
|
110
|
+
return typeof pkg.version === "string" ? pkg.version : undefined;
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
})();
|
|
115
|
+
|
|
116
|
+
/** Session id, or `undefined` when the context has no session manager. */
|
|
117
|
+
export function sessionIdOf(
|
|
118
|
+
ctx: ExtensionContext | undefined,
|
|
119
|
+
): string | undefined {
|
|
120
|
+
try {
|
|
121
|
+
return ctx?.sessionManager?.getSessionId();
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Common envelope carried by every record. */
|
|
128
|
+
export function auditBase(
|
|
129
|
+
ctx: ExtensionContext | undefined,
|
|
130
|
+
now: Date = new Date(),
|
|
131
|
+
): ApprovalRecord {
|
|
132
|
+
const model = ctx?.model;
|
|
133
|
+
return {
|
|
134
|
+
v: APPROVAL_LOG_SCHEMA,
|
|
135
|
+
ts: now.toISOString(),
|
|
136
|
+
session: sessionIdOf(ctx),
|
|
137
|
+
pid: process.pid,
|
|
138
|
+
pi: VERSION,
|
|
139
|
+
ext: EXTENSION_VERSION,
|
|
140
|
+
os: process.platform,
|
|
141
|
+
model: model ? `${model.provider}/${model.id}` : undefined,
|
|
142
|
+
cwd: ctx?.cwd,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Tool-call fields, with the input reduced to what policy analysis needs. */
|
|
147
|
+
export function auditTool(event: ToolCallEvent): ApprovalRecord {
|
|
148
|
+
return {
|
|
149
|
+
tool: event.toolName,
|
|
150
|
+
toolCall: event.toolCallId,
|
|
151
|
+
parentToolCall: event.parentToolCallId,
|
|
152
|
+
input: sanitizeToolInput(event.toolName, event.input),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* -------------------------------------------------------------------------- */
|
|
157
|
+
/* Truncation */
|
|
158
|
+
/* -------------------------------------------------------------------------- */
|
|
159
|
+
|
|
160
|
+
/** Middle-truncate long text, keeping the head and a short tail. */
|
|
161
|
+
export function clipText(value: string, maxChars = 4096): string {
|
|
162
|
+
if (value.length <= maxChars) return value;
|
|
163
|
+
const tail = Math.min(512, Math.floor(maxChars / 8));
|
|
164
|
+
const head = maxChars - tail;
|
|
165
|
+
const omitted = value.length - maxChars;
|
|
166
|
+
return `${value.slice(0, head)}…[+${omitted} chars]…${value.slice(-tail)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sanitizeValue(value: unknown, depth: number): JsonValue {
|
|
170
|
+
if (typeof value === "string") return clipText(value, 4096);
|
|
171
|
+
if (
|
|
172
|
+
typeof value === "number" ||
|
|
173
|
+
typeof value === "boolean" ||
|
|
174
|
+
value === null
|
|
175
|
+
) {
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
if (value === undefined) return null;
|
|
179
|
+
if (depth >= 4) {
|
|
180
|
+
try {
|
|
181
|
+
return clipText(JSON.stringify(value), 1024);
|
|
182
|
+
} catch {
|
|
183
|
+
return "[unserializable]";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
return value.slice(0, 64).map((item) => sanitizeValue(item, depth + 1));
|
|
188
|
+
}
|
|
189
|
+
if (typeof value === "object") {
|
|
190
|
+
const out: ApprovalRecord = {};
|
|
191
|
+
let count = 0;
|
|
192
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
193
|
+
if (count++ >= 64) break;
|
|
194
|
+
out[key] = sanitizeValue(item, depth + 1);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
return String(value);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Tool input as it may be logged.
|
|
203
|
+
*
|
|
204
|
+
* `write`/`edit` bodies are replaced by byte counts: they are not needed to
|
|
205
|
+
* judge or mine a path decision, and logging them would turn the audit trail
|
|
206
|
+
* into a copy of every edited file.
|
|
207
|
+
*/
|
|
208
|
+
export function sanitizeToolInput(
|
|
209
|
+
toolName: string,
|
|
210
|
+
input: unknown,
|
|
211
|
+
): JsonValue {
|
|
212
|
+
const record =
|
|
213
|
+
typeof input === "object" && input !== null
|
|
214
|
+
? (input as Record<string, unknown>)
|
|
215
|
+
: {};
|
|
216
|
+
if (toolName === "write" || toolName === "edit") {
|
|
217
|
+
const out: ApprovalRecord = {};
|
|
218
|
+
if (typeof record.path === "string") out.path = record.path;
|
|
219
|
+
const content = toolName === "write" ? record.content : record.newText;
|
|
220
|
+
if (typeof content === "string") {
|
|
221
|
+
out.contentBytes = Buffer.byteLength(content);
|
|
222
|
+
}
|
|
223
|
+
if (toolName === "edit" && typeof record.oldText === "string") {
|
|
224
|
+
out.oldTextBytes = Buffer.byteLength(record.oldText);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
if (toolName === "bash" && typeof record.command === "string") {
|
|
229
|
+
return { ...record, command: clipText(record.command, 8192) };
|
|
230
|
+
}
|
|
231
|
+
if (toolName === "powershell" && typeof record.script === "string") {
|
|
232
|
+
return { ...record, script: clipText(record.script, 8192) };
|
|
233
|
+
}
|
|
234
|
+
return sanitizeValue(input, 0);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* -------------------------------------------------------------------------- */
|
|
238
|
+
/* Redaction */
|
|
239
|
+
/* -------------------------------------------------------------------------- */
|
|
240
|
+
|
|
241
|
+
/** Best-effort credential patterns; the file is still sensitive regardless. */
|
|
242
|
+
type Replacer = string | ((match: string, ...groups: string[]) => string);
|
|
243
|
+
|
|
244
|
+
const REDACTIONS: ReadonlyArray<readonly [RegExp, Replacer]> = [
|
|
245
|
+
[
|
|
246
|
+
/(\b(?:authorization|proxy-authorization)\b\s*[:=]\s*)?\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
247
|
+
(_match: string, prefix?: string) => `${prefix ?? ""}[redacted]`,
|
|
248
|
+
],
|
|
249
|
+
[
|
|
250
|
+
/(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|token|password|passwd|pwd)\b\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi,
|
|
251
|
+
(_match: string, prefix: string) => `${prefix}[redacted]`,
|
|
252
|
+
],
|
|
253
|
+
[
|
|
254
|
+
/(\bhttps?:\/\/[^/\s:@]+:)[^/\s@]+(@)/gi,
|
|
255
|
+
(_match: string, prefix: string, suffix: string) =>
|
|
256
|
+
`${prefix}[redacted]${suffix}`,
|
|
257
|
+
],
|
|
258
|
+
[
|
|
259
|
+
/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})\b/g,
|
|
260
|
+
"[redacted]",
|
|
261
|
+
],
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
function redactText(value: string): string {
|
|
265
|
+
let out = value;
|
|
266
|
+
for (const [pattern, replacement] of REDACTIONS) {
|
|
267
|
+
out =
|
|
268
|
+
typeof replacement === "string"
|
|
269
|
+
? out.replace(pattern, replacement)
|
|
270
|
+
: out.replace(
|
|
271
|
+
pattern,
|
|
272
|
+
replacement as (
|
|
273
|
+
substring: string,
|
|
274
|
+
...args: string[]
|
|
275
|
+
) => string,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function redactValue(
|
|
282
|
+
value: JsonValue,
|
|
283
|
+
state: { changed: boolean },
|
|
284
|
+
depth = 0,
|
|
285
|
+
): JsonValue {
|
|
286
|
+
if (typeof value === "string") {
|
|
287
|
+
const out = redactText(value);
|
|
288
|
+
if (out !== value) state.changed = true;
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
if (Array.isArray(value)) {
|
|
292
|
+
return value.map((item) => redactValue(item, state, depth + 1));
|
|
293
|
+
}
|
|
294
|
+
if (value !== null && typeof value === "object") {
|
|
295
|
+
if (depth >= 8) return value;
|
|
296
|
+
const out: ApprovalRecord = {};
|
|
297
|
+
for (const [key, item] of Object.entries(value as ApprovalRecord)) {
|
|
298
|
+
out[key] = redactValue(item ?? null, state, depth + 1);
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isRecord(value: JsonValue): value is ApprovalRecord {
|
|
306
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function redactRecord(record: ApprovalRecord): {
|
|
310
|
+
record: ApprovalRecord;
|
|
311
|
+
changed: boolean;
|
|
312
|
+
} {
|
|
313
|
+
const state = { changed: false };
|
|
314
|
+
const value = redactValue(record, state, 0);
|
|
315
|
+
return { record: isRecord(value) ? value : record, changed: state.changed };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* -------------------------------------------------------------------------- */
|
|
319
|
+
/* Serialization guard */
|
|
320
|
+
/* -------------------------------------------------------------------------- */
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Serialize one record, dropping optional detail (with a marker) if it still
|
|
324
|
+
* exceeds the per-record budget. The verdict/answer envelope survives every
|
|
325
|
+
* step, so a pathological command cannot hide the decision itself.
|
|
326
|
+
*
|
|
327
|
+
* Returns `undefined` when the record cannot be serialized at all (a value
|
|
328
|
+
* `JSON.stringify` rejects, such as a BigInt or an exotic object that survived
|
|
329
|
+
* sanitization). That is a property of *this* record, not of the log, so the
|
|
330
|
+
* caller skips it instead of disabling logging for the whole process.
|
|
331
|
+
*/
|
|
332
|
+
function encodeRecord(
|
|
333
|
+
record: ApprovalRecord,
|
|
334
|
+
maxBytes: number,
|
|
335
|
+
): string | undefined {
|
|
336
|
+
const fits = (value: ApprovalRecord): string | undefined => {
|
|
337
|
+
try {
|
|
338
|
+
const line = JSON.stringify(value);
|
|
339
|
+
return Buffer.byteLength(line) <= maxBytes ? line : undefined;
|
|
340
|
+
} catch {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
let line = fits(record);
|
|
346
|
+
if (line !== undefined) return line;
|
|
347
|
+
|
|
348
|
+
const candidate: ApprovalRecord = { ...record };
|
|
349
|
+
if (Array.isArray(candidate.units)) {
|
|
350
|
+
candidate.units = {
|
|
351
|
+
omitted: true,
|
|
352
|
+
count: candidate.units.length,
|
|
353
|
+
};
|
|
354
|
+
candidate.truncated = true;
|
|
355
|
+
}
|
|
356
|
+
line = fits(candidate);
|
|
357
|
+
if (line !== undefined) return line;
|
|
358
|
+
|
|
359
|
+
if (candidate.prompt !== undefined) {
|
|
360
|
+
candidate.prompt = undefined;
|
|
361
|
+
candidate.truncated = true;
|
|
362
|
+
line = fits(candidate);
|
|
363
|
+
if (line !== undefined) return line;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (candidate.input !== undefined) {
|
|
367
|
+
candidate.input = { omitted: true };
|
|
368
|
+
candidate.truncated = true;
|
|
369
|
+
line = fits(candidate);
|
|
370
|
+
if (line !== undefined) return line;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (candidate.reason !== undefined) {
|
|
374
|
+
candidate.reason = clipText(String(candidate.reason), 512);
|
|
375
|
+
candidate.truncated = true;
|
|
376
|
+
line = fits(candidate);
|
|
377
|
+
if (line !== undefined) return line;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Last resort: keep only the decision envelope. `truncated` makes the loss
|
|
381
|
+
// visible to every reader instead of silently dropping fields.
|
|
382
|
+
try {
|
|
383
|
+
return JSON.stringify({
|
|
384
|
+
v: candidate.v,
|
|
385
|
+
ts: candidate.ts,
|
|
386
|
+
event: candidate.event,
|
|
387
|
+
session: candidate.session,
|
|
388
|
+
tool: candidate.tool,
|
|
389
|
+
verdict: candidate.verdict,
|
|
390
|
+
answer: candidate.answer,
|
|
391
|
+
outcome: candidate.outcome,
|
|
392
|
+
truncated: true,
|
|
393
|
+
});
|
|
394
|
+
} catch {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* -------------------------------------------------------------------------- */
|
|
400
|
+
/* Rolling writer */
|
|
401
|
+
/* -------------------------------------------------------------------------- */
|
|
402
|
+
|
|
403
|
+
const FILE_PATTERN = /^audit-(\d{6,})\.jsonl$/;
|
|
404
|
+
|
|
405
|
+
interface LogFile {
|
|
406
|
+
readonly index: number;
|
|
407
|
+
readonly path: string;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
interface ActiveFile extends LogFile {
|
|
411
|
+
count: number;
|
|
412
|
+
bytes: number;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Newlines in `[from, to)` of an append-only file.
|
|
417
|
+
*
|
|
418
|
+
* Used to fold another process's appends into our record count: the bytes we
|
|
419
|
+
* have not observed yet are exactly those lines, so counting them costs the
|
|
420
|
+
* size of the delta rather than a re-read of the whole file. Without it the
|
|
421
|
+
* cap would silently become "1000 per process".
|
|
422
|
+
*/
|
|
423
|
+
function countNewlines(path: string, from: number, to: number): number {
|
|
424
|
+
const chunk = 64 * 1024;
|
|
425
|
+
const buffer = Buffer.allocUnsafe(chunk);
|
|
426
|
+
const fd = openSync(path, "r");
|
|
427
|
+
try {
|
|
428
|
+
let position = from;
|
|
429
|
+
let count = 0;
|
|
430
|
+
while (position < to) {
|
|
431
|
+
const wanted = Math.min(chunk, to - position);
|
|
432
|
+
const read = readSync(fd, buffer, 0, wanted, position);
|
|
433
|
+
if (read <= 0) break;
|
|
434
|
+
for (let i = 0; i < read; i++) {
|
|
435
|
+
if (buffer[i] === 0x0a) count++;
|
|
436
|
+
}
|
|
437
|
+
position += read;
|
|
438
|
+
}
|
|
439
|
+
return count;
|
|
440
|
+
} finally {
|
|
441
|
+
closeSync(fd);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function listLogFiles(dir: string): LogFile[] {
|
|
446
|
+
try {
|
|
447
|
+
return readdirSync(dir)
|
|
448
|
+
.flatMap((name) => {
|
|
449
|
+
const match = FILE_PATTERN.exec(name);
|
|
450
|
+
return match
|
|
451
|
+
? [{ index: Number(match[1]), path: join(dir, name) }]
|
|
452
|
+
: [];
|
|
453
|
+
})
|
|
454
|
+
.sort((a, b) => a.index - b.index);
|
|
455
|
+
} catch {
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function parseMode(value: string | undefined): ApprovalLogMode | undefined {
|
|
461
|
+
if (value === undefined) return undefined;
|
|
462
|
+
const normalized = value.trim().toLowerCase();
|
|
463
|
+
if (["off", "0", "false", "no"].includes(normalized)) return "off";
|
|
464
|
+
if (normalized === "all") return "all";
|
|
465
|
+
if (["interesting", "on", "1", "true", "yes"].includes(normalized)) {
|
|
466
|
+
return "interesting";
|
|
467
|
+
}
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function createApprovalLog(
|
|
472
|
+
options: ApprovalLogOptions = {},
|
|
473
|
+
): ApprovalLog {
|
|
474
|
+
const mode =
|
|
475
|
+
options.mode ??
|
|
476
|
+
parseMode(process.env.PI_APPROVAL_LOG) ??
|
|
477
|
+
"interesting";
|
|
478
|
+
const dir =
|
|
479
|
+
options.dir ??
|
|
480
|
+
process.env.PI_APPROVAL_LOG_DIR ??
|
|
481
|
+
join(getAgentDir(), "approval-log");
|
|
482
|
+
const maxFiles = Math.max(1, options.maxFiles ?? DEFAULT_MAX_FILES);
|
|
483
|
+
const maxRecords = Math.max(1, options.maxRecords ?? DEFAULT_MAX_RECORDS);
|
|
484
|
+
const maxFileBytes = Math.max(
|
|
485
|
+
1,
|
|
486
|
+
options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
|
|
487
|
+
);
|
|
488
|
+
const maxRecordBytes = Math.max(
|
|
489
|
+
1,
|
|
490
|
+
options.maxRecordBytes ?? DEFAULT_MAX_RECORD_BYTES,
|
|
491
|
+
);
|
|
492
|
+
const now = options.now ?? (() => new Date());
|
|
493
|
+
const warn =
|
|
494
|
+
options.warn ??
|
|
495
|
+
((message: string): void => {
|
|
496
|
+
// `console.warn` is what the host and the other features use for a
|
|
497
|
+
// degraded (not fatal) path.
|
|
498
|
+
console.warn(message);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
let active = mode !== "off";
|
|
502
|
+
let initialized = mode === "off";
|
|
503
|
+
let warned = false;
|
|
504
|
+
/** Separate flag: a skipped record must not swallow the "disabled" notice. */
|
|
505
|
+
let warnedSkip = false;
|
|
506
|
+
let current: ActiveFile | undefined;
|
|
507
|
+
|
|
508
|
+
function fail(error: unknown): void {
|
|
509
|
+
active = false;
|
|
510
|
+
if (warned) return;
|
|
511
|
+
warned = true;
|
|
512
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
513
|
+
warn(`[pi-toolkits] approval log disabled: ${message}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function createFile(startIndex: number): ActiveFile {
|
|
517
|
+
for (let index = startIndex; index < startIndex + 1000; index++) {
|
|
518
|
+
const path = join(
|
|
519
|
+
dir,
|
|
520
|
+
`audit-${String(index).padStart(6, "0")}.jsonl`,
|
|
521
|
+
);
|
|
522
|
+
try {
|
|
523
|
+
writeFileSync(path, "", { flag: "wx", mode: 0o600 });
|
|
524
|
+
return { index, path, count: 0, bytes: 0 };
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
throw new Error(`could not allocate a log file index under ${dir}`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function prune(): void {
|
|
535
|
+
const files = listLogFiles(dir);
|
|
536
|
+
const excess = files.length - maxFiles;
|
|
537
|
+
for (let i = 0; i < excess; i++) {
|
|
538
|
+
try {
|
|
539
|
+
unlinkSync(files[i].path);
|
|
540
|
+
} catch {
|
|
541
|
+
// A concurrent process may have removed it already.
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function rotate(): void {
|
|
547
|
+
const files = listLogFiles(dir);
|
|
548
|
+
const next = (files.at(-1)?.index ?? 0) + 1;
|
|
549
|
+
current = createFile(next);
|
|
550
|
+
// The new file counts towards the cap, so pruning after creation is what
|
|
551
|
+
// makes file number 11 remove file number 1.
|
|
552
|
+
prune();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function adopt(file: LogFile): void {
|
|
556
|
+
const size = statSync(file.path).size;
|
|
557
|
+
if (size > maxFileBytes) {
|
|
558
|
+
// Only our own writer creates these names, but a copied-in or corrupted
|
|
559
|
+
// file must not turn startup into a multi-gigabyte read. Treat it as
|
|
560
|
+
// full and start a fresh file; the oversized one is pruned normally.
|
|
561
|
+
current = {
|
|
562
|
+
index: file.index,
|
|
563
|
+
path: file.path,
|
|
564
|
+
count: maxRecords,
|
|
565
|
+
bytes: size,
|
|
566
|
+
};
|
|
567
|
+
rotate();
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const buffer = readFileSync(file.path);
|
|
571
|
+
const unterminated =
|
|
572
|
+
buffer.length > 0 && buffer[buffer.length - 1] !== 0x0a;
|
|
573
|
+
if (unterminated) {
|
|
574
|
+
// A crash may have left a partial line; terminate it so the next
|
|
575
|
+
// record starts on a line of its own. Readers still skip the fragment.
|
|
576
|
+
appendFileSync(file.path, "\n", { mode: 0o600 });
|
|
577
|
+
}
|
|
578
|
+
let count = 0;
|
|
579
|
+
for (const byte of buffer) {
|
|
580
|
+
if (byte === 0x0a) count++;
|
|
581
|
+
}
|
|
582
|
+
if (unterminated) count++;
|
|
583
|
+
current = {
|
|
584
|
+
index: file.index,
|
|
585
|
+
path: file.path,
|
|
586
|
+
count,
|
|
587
|
+
bytes: statSync(file.path).size,
|
|
588
|
+
};
|
|
589
|
+
if (current.count >= maxRecords || current.bytes >= maxFileBytes) {
|
|
590
|
+
rotate();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function ensureInitialized(): void {
|
|
595
|
+
if (initialized) return;
|
|
596
|
+
try {
|
|
597
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
598
|
+
// mkdir's mode is masked by umask; make the intent explicit.
|
|
599
|
+
try {
|
|
600
|
+
chmodSync(dir, 0o700);
|
|
601
|
+
} catch {
|
|
602
|
+
// Non-POSIX filesystem: keep going.
|
|
603
|
+
}
|
|
604
|
+
const files = listLogFiles(dir);
|
|
605
|
+
if (files.length === 0) current = createFile(1);
|
|
606
|
+
else adopt(files[files.length - 1]);
|
|
607
|
+
// A directory copied from elsewhere (or left over after a config
|
|
608
|
+
// change) may hold more than the cap; trim before the first append.
|
|
609
|
+
prune();
|
|
610
|
+
initialized = true;
|
|
611
|
+
} catch (error) {
|
|
612
|
+
fail(error);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Detect a rotation performed by another pi process, a pruned file, or a
|
|
618
|
+
* boundary reached by concurrent appends. Runs once per record: a readdir of
|
|
619
|
+
* at most ten names plus one stat.
|
|
620
|
+
*/
|
|
621
|
+
function refresh(): void {
|
|
622
|
+
const files = listLogFiles(dir);
|
|
623
|
+
if (files.length === 0) {
|
|
624
|
+
current = createFile(1);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const newest = files[files.length - 1];
|
|
628
|
+
if (!current) {
|
|
629
|
+
adopt(newest);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (newest.index > current.index) {
|
|
633
|
+
adopt(newest);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (!existsSync(current.path)) {
|
|
637
|
+
adopt(newest);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
let size: number;
|
|
641
|
+
try {
|
|
642
|
+
size = statSync(current.path).size;
|
|
643
|
+
} catch {
|
|
644
|
+
adopt(newest);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (size !== current.bytes) {
|
|
648
|
+
if (size > current.bytes) {
|
|
649
|
+
// Another process appended: fold its lines into the count so the
|
|
650
|
+
// 1000-record cap stays a cap rather than a per-process counter.
|
|
651
|
+
try {
|
|
652
|
+
current.count += countNewlines(current.path, current.bytes, size);
|
|
653
|
+
} catch {
|
|
654
|
+
// Leave the count as a lower bound; the byte cap still bounds growth.
|
|
655
|
+
}
|
|
656
|
+
current.bytes = size;
|
|
657
|
+
} else {
|
|
658
|
+
// The file shrank: it was truncated or replaced, so the running count
|
|
659
|
+
// is meaningless. Re-derive it from the file itself.
|
|
660
|
+
adopt({ index: current.index, path: current.path });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (size >= maxFileBytes) {
|
|
664
|
+
rotate();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (current.count >= maxRecords || current.bytes >= maxFileBytes) {
|
|
669
|
+
// Rotate lazily: the next record opens a fresh file. This keeps the
|
|
670
|
+
// last file full instead of leaving an empty 11th file behind when the
|
|
671
|
+
// process stops right at a boundary.
|
|
672
|
+
rotate();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function append(record: ApprovalRecord): void {
|
|
677
|
+
if (!active) return;
|
|
678
|
+
try {
|
|
679
|
+
ensureInitialized();
|
|
680
|
+
if (!active || !current) return;
|
|
681
|
+
refresh();
|
|
682
|
+
if (!active || !current) return;
|
|
683
|
+
|
|
684
|
+
const stamped: ApprovalRecord = {
|
|
685
|
+
v: APPROVAL_LOG_SCHEMA,
|
|
686
|
+
...record,
|
|
687
|
+
};
|
|
688
|
+
if (typeof stamped.ts !== "string") {
|
|
689
|
+
stamped.ts = now().toISOString();
|
|
690
|
+
}
|
|
691
|
+
const redacted = redactRecord(stamped);
|
|
692
|
+
const safe = redacted.record;
|
|
693
|
+
if (redacted.changed) safe.redacted = true;
|
|
694
|
+
|
|
695
|
+
const encoded = encodeRecord(safe, maxRecordBytes);
|
|
696
|
+
if (encoded === undefined) {
|
|
697
|
+
// One unserializable record is not a reason to go blind for the rest
|
|
698
|
+
// of the process (the alternative, `fail()`, would).
|
|
699
|
+
if (!warnedSkip) {
|
|
700
|
+
warnedSkip = true;
|
|
701
|
+
warn(
|
|
702
|
+
"[pi-toolkits] approval log skipped a record it could not serialize",
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const line = `${encoded}\n`;
|
|
708
|
+
appendFileSync(current.path, line, { mode: 0o600 });
|
|
709
|
+
current.count++;
|
|
710
|
+
current.bytes += Buffer.byteLength(line);
|
|
711
|
+
} catch (error) {
|
|
712
|
+
fail(error);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
dir,
|
|
718
|
+
mode,
|
|
719
|
+
get active() {
|
|
720
|
+
return active;
|
|
721
|
+
},
|
|
722
|
+
append,
|
|
723
|
+
};
|
|
724
|
+
}
|