@verax-ai/proxy 0.3.0 → 0.4.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 +6 -1
- package/dist/approvals.d.ts +13 -4
- package/dist/approvals.js +140 -22
- package/dist/checkpoints.d.ts +9 -0
- package/dist/checkpoints.js +44 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/ledger-manifest.d.ts +9 -0
- package/dist/ledger-manifest.js +49 -6
- package/dist/ledger.d.ts +8 -0
- package/dist/ledger.js +45 -18
- package/dist/policy.d.ts +12 -1
- package/dist/policy.js +87 -16
- package/dist/proxy.js +177 -27
- package/dist/reconcile.d.ts +4 -1
- package/dist/reconcile.js +39 -2
- package/dist/types.d.ts +5 -2
- package/dist/verify-ledger.d.ts +39 -1
- package/dist/verify-ledger.js +731 -36
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -29,7 +29,12 @@ import { createProxy, loadPolicy, FileLedger, explain } from "@verax-ai/proxy";
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
- `loadPolicy` reads a policy file. `policy/default.json` in this package is
|
|
32
|
-
the one the body starts from.
|
|
32
|
+
the one the body starts from. Two rules with the same `id`, or two rules for
|
|
33
|
+
the same `tool`, are refused at load and the error names both ids.
|
|
34
|
+
- A `spend` rule may set `dayOffsetMinutes` (integer, −720..840, default 0).
|
|
35
|
+
The daily cap buckets `createdAtMs` shifted by that many minutes, so 0 is a
|
|
36
|
+
UTC day and 180 is the operator's day in UTC+3. Approval re-checks the same
|
|
37
|
+
bucket.
|
|
33
38
|
- `createProxy` wraps tool functions: each call is decided and recorded, then
|
|
34
39
|
run or refused.
|
|
35
40
|
- `FileLedger` and `MemoryLedger` hold the records, one `FileLedger` per
|
package/dist/approvals.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type ApprovalRow = {
|
|
|
22
22
|
payee?: unknown;
|
|
23
23
|
currency?: unknown;
|
|
24
24
|
createdAtMs?: number;
|
|
25
|
+
/** Set when the row becomes approved. Missing on older files: those count toward today. */
|
|
26
|
+
approvedAtMs?: number;
|
|
25
27
|
expiresAtMs: number;
|
|
26
28
|
status: "pending" | "approved" | "expired";
|
|
27
29
|
brain: string;
|
|
@@ -34,6 +36,7 @@ export type ApprovalsLog = {
|
|
|
34
36
|
listAll(): Promise<ApprovalRow[]>;
|
|
35
37
|
updateStatus(ref: string, status: "approved" | "expired", extra?: {
|
|
36
38
|
allowRef?: string;
|
|
39
|
+
approvedAtMs?: number;
|
|
37
40
|
}): Promise<void>;
|
|
38
41
|
};
|
|
39
42
|
export declare class MemoryApprovalsLog implements ApprovalsLog {
|
|
@@ -45,6 +48,7 @@ export declare class MemoryApprovalsLog implements ApprovalsLog {
|
|
|
45
48
|
listAll(): Promise<ApprovalRow[]>;
|
|
46
49
|
updateStatus(ref: string, status: "approved" | "expired", extra?: {
|
|
47
50
|
allowRef?: string;
|
|
51
|
+
approvedAtMs?: number;
|
|
48
52
|
}): Promise<void>;
|
|
49
53
|
}
|
|
50
54
|
export declare class FileApprovalsLog implements ApprovalsLog {
|
|
@@ -57,6 +61,7 @@ export declare class FileApprovalsLog implements ApprovalsLog {
|
|
|
57
61
|
listAll(): Promise<ApprovalRow[]>;
|
|
58
62
|
updateStatus(ref: string, status: "approved" | "expired", extra?: {
|
|
59
63
|
allowRef?: string;
|
|
64
|
+
approvedAtMs?: number;
|
|
60
65
|
}): Promise<void>;
|
|
61
66
|
private read;
|
|
62
67
|
}
|
|
@@ -74,18 +79,20 @@ export declare function spentTodayMinorOf(rows: Array<{
|
|
|
74
79
|
subject: string;
|
|
75
80
|
status: string;
|
|
76
81
|
createdAtMs?: number;
|
|
82
|
+
approvedAtMs?: number;
|
|
77
83
|
expiresAtMs: number;
|
|
78
84
|
args: Record<string, unknown>;
|
|
79
|
-
}>, nowMs: number, currency: string, approvalTtlMs: number): number;
|
|
85
|
+
}>, nowMs: number, currency: string, approvalTtlMs: number, dayOffsetMinutes?: number): number;
|
|
80
86
|
export declare function createApprovalBudgetGuard(opts: {
|
|
81
87
|
policy: Policy;
|
|
82
88
|
approvals: ApprovalsLog;
|
|
83
89
|
now: () => number;
|
|
90
|
+
ledger: Ledger;
|
|
84
91
|
}): (snap: ApprovalRow) => Promise<{
|
|
85
92
|
ok: true;
|
|
86
93
|
} | {
|
|
87
94
|
ok: false;
|
|
88
|
-
reason: "budget-exceeded";
|
|
95
|
+
reason: "budget-exceeded" | "rule-missing";
|
|
89
96
|
}>;
|
|
90
97
|
export declare function approvePending(opts: {
|
|
91
98
|
ledger: Ledger;
|
|
@@ -103,18 +110,20 @@ export declare function approvePending(opts: {
|
|
|
103
110
|
ok: true;
|
|
104
111
|
} | {
|
|
105
112
|
ok: false;
|
|
106
|
-
reason: "budget-exceeded";
|
|
113
|
+
reason: "budget-exceeded" | "rule-missing";
|
|
107
114
|
} | Promise<{
|
|
108
115
|
ok: true;
|
|
109
116
|
} | {
|
|
110
117
|
ok: false;
|
|
111
|
-
reason: "budget-exceeded";
|
|
118
|
+
reason: "budget-exceeded" | "rule-missing";
|
|
112
119
|
}>;
|
|
113
120
|
}): Promise<ApproveResult>;
|
|
114
121
|
export type ApprovalCommand = {
|
|
115
122
|
ref: string;
|
|
116
123
|
approverId: string;
|
|
117
124
|
atMs: number;
|
|
125
|
+
/** Absent on a command written before this field existed; drain applies that as `cli-script`. */
|
|
126
|
+
via?: "cli" | "cli-script";
|
|
118
127
|
};
|
|
119
128
|
export declare function enqueueApprovalCommand(dir: string, cmd: ApprovalCommand): void;
|
|
120
129
|
export declare function drainApprovalCommands(dir: string, apply: (cmd: ApprovalCommand) => Promise<void>): Promise<void>;
|
package/dist/approvals.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, } from "node:fs";
|
|
1
|
+
import { appendFileSync, closeSync, constants, existsSync, fchownSync, fstatSync, lstatSync, openSync, readdirSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
|
|
2
2
|
/** I/O seam so drain-busy tests can inject rename failures. */
|
|
3
3
|
export const approvalFs = {
|
|
4
4
|
renameSync,
|
|
@@ -10,6 +10,14 @@ import { appendDurable, lookupDecisionByRef, lookupResolvedBy, noteResolution }
|
|
|
10
10
|
import { effectDescriptor, sha256Canonical } from "./hash.js";
|
|
11
11
|
import { inputsLogFor } from "./inputs.js";
|
|
12
12
|
import { SerialQueue } from "./serial-queue.js";
|
|
13
|
+
function withStatus(cur, status, extra) {
|
|
14
|
+
return {
|
|
15
|
+
...cur,
|
|
16
|
+
status,
|
|
17
|
+
...(extra?.allowRef !== undefined ? { allowRef: extra.allowRef } : {}),
|
|
18
|
+
...(typeof extra?.approvedAtMs === "number" ? { approvedAtMs: extra.approvedAtMs } : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
13
21
|
function lastByRef(rows) {
|
|
14
22
|
const map = new Map();
|
|
15
23
|
for (const row of rows)
|
|
@@ -45,7 +53,7 @@ export class MemoryApprovalsLog {
|
|
|
45
53
|
const cur = this.byRef.get(ref);
|
|
46
54
|
if (!cur)
|
|
47
55
|
return;
|
|
48
|
-
await this.append(
|
|
56
|
+
await this.append(withStatus(cur, status, extra));
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
export class FileApprovalsLog {
|
|
@@ -73,7 +81,7 @@ export class FileApprovalsLog {
|
|
|
73
81
|
const cur = this.byRef.get(ref);
|
|
74
82
|
if (!cur)
|
|
75
83
|
return;
|
|
76
|
-
await this.append(
|
|
84
|
+
await this.append(withStatus(cur, status, extra));
|
|
77
85
|
}
|
|
78
86
|
read() {
|
|
79
87
|
try {
|
|
@@ -113,19 +121,30 @@ function approvalLockFor(ledger) {
|
|
|
113
121
|
bag[APPROVAL_LOCK] = new SerialQueue();
|
|
114
122
|
return bag[APPROVAL_LOCK];
|
|
115
123
|
}
|
|
116
|
-
export function spentTodayMinorOf(rows, nowMs, currency, approvalTtlMs) {
|
|
117
|
-
const
|
|
118
|
-
const
|
|
124
|
+
export function spentTodayMinorOf(rows, nowMs, currency, approvalTtlMs, dayOffsetMinutes = 0) {
|
|
125
|
+
const shift = dayOffsetMinutes * 60_000;
|
|
126
|
+
const d = new Date(nowMs + shift);
|
|
127
|
+
const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) - shift;
|
|
119
128
|
const end = start + 86_400_000;
|
|
120
129
|
let sum = 0;
|
|
121
130
|
for (const row of rows) {
|
|
122
131
|
if (row.subject !== "spend")
|
|
123
132
|
continue;
|
|
124
|
-
if (row.status
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
133
|
+
if (row.status === "approved") {
|
|
134
|
+
// Bucket by approval time. A row written before this field existed has
|
|
135
|
+
// no approvedAtMs and counts toward the day being checked.
|
|
136
|
+
if (typeof row.approvedAtMs === "number" && (row.approvedAtMs < start || row.approvedAtMs >= end)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (row.status === "pending") {
|
|
141
|
+
const created = row.createdAtMs ?? row.expiresAtMs - approvalTtlMs;
|
|
142
|
+
if (created < start || created >= end)
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
128
146
|
continue;
|
|
147
|
+
}
|
|
129
148
|
if (row.args.currency !== currency)
|
|
130
149
|
continue;
|
|
131
150
|
const amt = row.args.amountMinor;
|
|
@@ -138,16 +157,44 @@ export function createApprovalBudgetGuard(opts) {
|
|
|
138
157
|
return async (snap) => {
|
|
139
158
|
if (snap.subject !== "spend")
|
|
140
159
|
return { ok: true };
|
|
141
|
-
const
|
|
160
|
+
const rule = opts.policy.rule(snap.ruleId);
|
|
161
|
+
// A missing rule is not an uncapped rule. A rule that exists and names no
|
|
162
|
+
// dailyMaxMinor is the policy's choice to leave that spend uncapped.
|
|
163
|
+
if (rule === null)
|
|
164
|
+
return { ok: false, reason: "rule-missing" };
|
|
165
|
+
const dailyMax = rule.spend?.dailyMaxMinor;
|
|
142
166
|
if (dailyMax === undefined)
|
|
143
167
|
return { ok: true };
|
|
144
168
|
const currency = typeof snap.args.currency === "string" ? snap.args.currency : "";
|
|
145
169
|
const amount = typeof snap.args.amountMinor === "number" ? snap.args.amountMinor : 0;
|
|
146
170
|
// Sibling pending rows are not authorized yet. Counting them here would
|
|
147
|
-
// deadlock two 100-unit pendings against a later 150 cap
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
171
|
+
// deadlock two 100-unit pendings against a later 150 cap. A pending row
|
|
172
|
+
// whose ref the ledger already resolved as allow was authorized; the
|
|
173
|
+
// approvals file may still say pending if the process stopped before
|
|
174
|
+
// updateStatus. That row counts. Other pendings do not.
|
|
175
|
+
const listed = await opts.approvals.listAll();
|
|
176
|
+
const others = [];
|
|
177
|
+
for (const row of listed) {
|
|
178
|
+
if (row.ref === snap.ref)
|
|
179
|
+
continue;
|
|
180
|
+
if (row.status === "approved") {
|
|
181
|
+
others.push(row);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (row.status !== "pending" || row.subject !== "spend")
|
|
185
|
+
continue;
|
|
186
|
+
const hit = lookupResolvedBy(opts.ledger, row.ref);
|
|
187
|
+
if (hit?.kind !== "allow")
|
|
188
|
+
continue;
|
|
189
|
+
const allow = await lookupDecisionByRef(opts.ledger, hit.ref);
|
|
190
|
+
others.push({
|
|
191
|
+
...row,
|
|
192
|
+
status: "approved",
|
|
193
|
+
...(typeof allow?.timestampMs === "number" ? { approvedAtMs: allow.timestampMs } : {}),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const offset = opts.policy.rule(snap.ruleId)?.spend?.dayOffsetMinutes ?? opts.policy.dayOffsetMinutes ?? 0;
|
|
197
|
+
const spent = spentTodayMinorOf(others, opts.now(), currency, opts.policy.approvalTtlMs, offset);
|
|
151
198
|
if (spent + amount > dailyMax)
|
|
152
199
|
return { ok: false, reason: "budget-exceeded" };
|
|
153
200
|
return { ok: true };
|
|
@@ -173,7 +220,19 @@ async function hasResolves(inputsLog, ledger, deferRef) {
|
|
|
173
220
|
export async function approvePending(opts) {
|
|
174
221
|
return approvalLockFor(opts.ledger).enqueue(() => approvePendingUnlocked(opts));
|
|
175
222
|
}
|
|
223
|
+
function ledgerStateDir(ledger) {
|
|
224
|
+
const dir = ledger.dir;
|
|
225
|
+
return typeof dir === "string" && dir !== "" ? dir : null;
|
|
226
|
+
}
|
|
227
|
+
function approvalsHalted(ledger) {
|
|
228
|
+
const dir = ledgerStateDir(ledger);
|
|
229
|
+
if (!dir)
|
|
230
|
+
return false;
|
|
231
|
+
return existsSync(join(dir, "halted"));
|
|
232
|
+
}
|
|
176
233
|
async function approvePendingUnlocked(opts) {
|
|
234
|
+
if (approvalsHalted(opts.ledger))
|
|
235
|
+
return { ok: false, reason: "halted" };
|
|
177
236
|
const inputsLog = opts.inputsLog ?? inputsLogFor(opts.ledger);
|
|
178
237
|
const defer = await lookupDecisionByRef(opts.ledger, opts.ref);
|
|
179
238
|
if (!defer || defer.decision !== "defer")
|
|
@@ -187,7 +246,13 @@ async function approvePendingUnlocked(opts) {
|
|
|
187
246
|
if (resolved || (await hasResolves(inputsLog, opts.ledger, opts.ref))) {
|
|
188
247
|
const hit = resolved ?? lookupResolvedBy(opts.ledger, opts.ref);
|
|
189
248
|
if (snap?.status === "pending" && hit) {
|
|
190
|
-
|
|
249
|
+
if (hit.kind === "allow") {
|
|
250
|
+
const stamped = (await lookupDecisionByRef(opts.ledger, hit.ref))?.timestampMs ?? opts.now();
|
|
251
|
+
await opts.approvals.updateStatus(opts.ref, "approved", { allowRef: hit.ref, approvedAtMs: stamped });
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
await opts.approvals.updateStatus(opts.ref, "expired");
|
|
255
|
+
}
|
|
191
256
|
}
|
|
192
257
|
return alreadyResolved(hit?.kind === "allow" ? hit.ref : snap?.allowRef);
|
|
193
258
|
}
|
|
@@ -261,6 +326,9 @@ async function approvePendingUnlocked(opts) {
|
|
|
261
326
|
prevRecordHash,
|
|
262
327
|
}, opts.recordSigner.privateKeyPem, opts.recordSigner.publicKeyPem));
|
|
263
328
|
noteResolution(opts.ledger, opts.ref, { ref: allowRef, kind: "allow" });
|
|
329
|
+
// The allow is on the ledger. Mark the snapshot approved before the effect
|
|
330
|
+
// row, so a stop during appendEffect still counts this spend.
|
|
331
|
+
await opts.approvals.updateStatus(opts.ref, "approved", { allowRef, approvedAtMs: opts.now() });
|
|
264
332
|
if (snap.subject === "spend") {
|
|
265
333
|
const resultHash = sha256Canonical({
|
|
266
334
|
authorized: true,
|
|
@@ -278,14 +346,49 @@ async function approvePendingUnlocked(opts) {
|
|
|
278
346
|
actor: opts.approverId,
|
|
279
347
|
}, "self", resultHash);
|
|
280
348
|
}
|
|
281
|
-
await opts.approvals.updateStatus(opts.ref, "approved", { allowRef });
|
|
282
349
|
return { ok: true, allowRef };
|
|
283
350
|
}
|
|
284
351
|
export function enqueueApprovalCommand(dir, cmd) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
352
|
+
const file = join(dir, "approval-commands.jsonl");
|
|
353
|
+
const payload = Buffer.from(`${JSON.stringify(cmd)}\n`, "utf8");
|
|
354
|
+
const dirStat = lstatSync(dir);
|
|
355
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
356
|
+
throw new Error(`refusing: ${dir} is a link`);
|
|
357
|
+
}
|
|
358
|
+
let existing;
|
|
359
|
+
try {
|
|
360
|
+
existing = lstatSync(file);
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
if (err.code !== "ENOENT")
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
366
|
+
if (existing?.isSymbolicLink()) {
|
|
367
|
+
throw new Error(`refusing: ${file} is a link`);
|
|
368
|
+
}
|
|
369
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
370
|
+
const chownToDir = process.platform !== "win32" && uid !== undefined && uid !== dirStat.uid;
|
|
371
|
+
if (process.platform === "win32") {
|
|
372
|
+
appendFileSync(file, payload, { mode: 0o600 });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const nofollow = constants.O_NOFOLLOW ?? 0;
|
|
376
|
+
const flags = (existing
|
|
377
|
+
? constants.O_WRONLY | constants.O_APPEND
|
|
378
|
+
: constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL) | nofollow;
|
|
379
|
+
const fd = openSync(file, flags, 0o600);
|
|
380
|
+
try {
|
|
381
|
+
// A hard link would carry the chown to another file; only a single-link regular file is written.
|
|
382
|
+
const opened = fstatSync(fd);
|
|
383
|
+
if (!opened.isFile() || opened.nlink !== 1)
|
|
384
|
+
throw new Error(`refusing: ${file} is not a single-link file`);
|
|
385
|
+
if (chownToDir)
|
|
386
|
+
fchownSync(fd, dirStat.uid, dirStat.gid);
|
|
387
|
+
writeSync(fd, payload);
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
closeSync(fd);
|
|
391
|
+
}
|
|
289
392
|
}
|
|
290
393
|
export async function drainApprovalCommands(dir, apply) {
|
|
291
394
|
const live = join(dir, "approval-commands.jsonl");
|
|
@@ -320,8 +423,23 @@ export async function drainApprovalCommands(dir, apply) {
|
|
|
320
423
|
const poisonPath = join(dir, "approval-commands.poison.jsonl");
|
|
321
424
|
for (const name of names) {
|
|
322
425
|
const file = join(dir, name);
|
|
426
|
+
let text;
|
|
427
|
+
try {
|
|
428
|
+
text = readFileSync(file, "utf8");
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
const kept = join(dir, `approval-commands.unreadable-${Date.now()}`);
|
|
432
|
+
try {
|
|
433
|
+
renameSync(file, kept);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// Leave the processing name in place. The bytes stay either way.
|
|
437
|
+
}
|
|
438
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
439
|
+
process.stderr.write(`verax-drain: unreadable ${file}: ${message}\n`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
323
442
|
try {
|
|
324
|
-
const text = readFileSync(file, "utf8");
|
|
325
443
|
for (const line of text.split("\n")) {
|
|
326
444
|
if (line === "")
|
|
327
445
|
continue;
|
package/dist/checkpoints.d.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
import type { SignedCheckpoint } from "@cedulon/checkpoint";
|
|
2
2
|
export declare function checkpointsPath(dir: string): string;
|
|
3
3
|
export declare function loadCheckpoints(dir: string): SignedCheckpoint[];
|
|
4
|
+
/**
|
|
5
|
+
* Every non-empty line is either a checkpoint row or a named problem.
|
|
6
|
+
* A missing file is an empty result with no problem. A line that does not
|
|
7
|
+
* parse, and a line that parses but is not a checkpoint, are both problems.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readCheckpointFile(dir: string): {
|
|
10
|
+
rows: SignedCheckpoint[];
|
|
11
|
+
problems: string[];
|
|
12
|
+
};
|
package/dist/checkpoints.js
CHANGED
|
@@ -29,3 +29,47 @@ export function loadCheckpoints(dir) {
|
|
|
29
29
|
}
|
|
30
30
|
return out;
|
|
31
31
|
}
|
|
32
|
+
function isCheckpointRow(row) {
|
|
33
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
34
|
+
return false;
|
|
35
|
+
const rec = row;
|
|
36
|
+
return Boolean(rec.claims) && typeof rec.coseHex === "string";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Every non-empty line is either a checkpoint row or a named problem.
|
|
40
|
+
* A missing file is an empty result with no problem. A line that does not
|
|
41
|
+
* parse, and a line that parses but is not a checkpoint, are both problems.
|
|
42
|
+
*/
|
|
43
|
+
export function readCheckpointFile(dir) {
|
|
44
|
+
let text;
|
|
45
|
+
try {
|
|
46
|
+
text = readFileSync(checkpointsPath(dir), "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err.code === "ENOENT")
|
|
50
|
+
return { rows: [], problems: [] };
|
|
51
|
+
return { rows: [], problems: ["checkpoint file could not be read"] };
|
|
52
|
+
}
|
|
53
|
+
const rows = [];
|
|
54
|
+
const problems = [];
|
|
55
|
+
const lines = text.split("\n");
|
|
56
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
57
|
+
const line = lines[i];
|
|
58
|
+
if (line === "")
|
|
59
|
+
continue;
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(line);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
problems.push(`checkpoint line ${i + 1} is not a checkpoint`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!isCheckpointRow(parsed)) {
|
|
69
|
+
problems.push(`checkpoint line ${i + 1} is not a checkpoint`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
rows.push(parsed);
|
|
73
|
+
}
|
|
74
|
+
return { rows, problems };
|
|
75
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export { createProxy, LedgerDenyUnrecorded } from "./proxy.ts";
|
|
2
|
-
export { loadPolicy } from "./policy.ts";
|
|
2
|
+
export { loadPolicy, TERMINAL_CONTROL_CLASS } from "./policy.ts";
|
|
3
3
|
export { FileLedger, MemoryLedger, readLedgerTail } from "./ledger.ts";
|
|
4
4
|
export { indexCoverage, listPieceFiles } from "./ledger-manifest.ts";
|
|
5
5
|
export { explain } from "./explain.ts";
|
|
6
6
|
export { approvePending, approvalsLogFor, createApprovalBudgetGuard, enqueueApprovalCommand, loadApprovalsFromDir, } from "./approvals.ts";
|
|
7
|
-
export { loadEffectsFromDir, parseCardCsv, parseChannelJsonl, reconcile } from "./reconcile.ts";
|
|
7
|
+
export { loadDecisionRefsFromDir, loadEffectsFromDir, parseCardCsv, parseChannelJsonl, reconcile } from "./reconcile.ts";
|
|
8
8
|
export { tenantKey } from "./tenant.ts";
|
|
9
9
|
export { diskProbe } from "./disk.ts";
|
|
10
10
|
export { checkpointsPath } from "./checkpoints.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export { createProxy, LedgerDenyUnrecorded } from "./proxy.js";
|
|
2
|
-
export { loadPolicy } from "./policy.js";
|
|
2
|
+
export { loadPolicy, TERMINAL_CONTROL_CLASS } from "./policy.js";
|
|
3
3
|
export { FileLedger, MemoryLedger, readLedgerTail } from "./ledger.js";
|
|
4
4
|
export { indexCoverage, listPieceFiles } from "./ledger-manifest.js";
|
|
5
5
|
export { explain } from "./explain.js";
|
|
6
6
|
export { approvePending, approvalsLogFor, createApprovalBudgetGuard, enqueueApprovalCommand, loadApprovalsFromDir, } from "./approvals.js";
|
|
7
|
-
export { loadEffectsFromDir, parseCardCsv, parseChannelJsonl, reconcile } from "./reconcile.js";
|
|
7
|
+
export { loadDecisionRefsFromDir, loadEffectsFromDir, parseCardCsv, parseChannelJsonl, reconcile } from "./reconcile.js";
|
|
8
8
|
export { tenantKey } from "./tenant.js";
|
|
9
9
|
// The body writes checkpoints and signs its own effect attestations. Both were
|
|
10
10
|
// reached through a relative path into this package, which only exists inside
|
|
@@ -61,6 +61,15 @@ export declare function nextPieceId(existingIds: string[], atMs: number): string
|
|
|
61
61
|
*/
|
|
62
62
|
export declare function readLedgerManifest(dir: string): LedgerManifest | null;
|
|
63
63
|
export declare function writeLedgerManifest(dir: string, manifest: LedgerManifest): void;
|
|
64
|
+
/**
|
|
65
|
+
* A manifest piece path is usable only when it stays inside `dir` after
|
|
66
|
+
* normalization. Absolute paths, drive letters, UNC (`\\host\share`,
|
|
67
|
+
* `//host/share`) and `..` are refused here, before any filesystem call.
|
|
68
|
+
* Null means the path is confined; the string is the problem to report.
|
|
69
|
+
*/
|
|
70
|
+
export declare function ledgerPiecePathProblem(dir: string, rel: string): string | null;
|
|
71
|
+
/** Confined absolute path, or a thrown error. Does not touch the filesystem. */
|
|
72
|
+
export declare function requireLedgerPiecePath(dir: string, rel: string): string;
|
|
64
73
|
export declare function listPieceFiles(dir: string): PieceFiles[];
|
|
65
74
|
export declare function pieceOverlaps(piece: {
|
|
66
75
|
firstMs: number | null;
|
package/dist/ledger-manifest.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, posix, relative, resolve, win32 } from "node:path";
|
|
3
3
|
import { writeFileAtomic } from "./atomic-write.js";
|
|
4
4
|
export const LEGACY_PIECE_ID = "legacy";
|
|
5
5
|
export const DEFAULT_PIECE_MAX_ROWS = 50_000;
|
|
@@ -128,16 +128,59 @@ function asPieceRow(raw) {
|
|
|
128
128
|
export function writeLedgerManifest(dir, manifest) {
|
|
129
129
|
writeFileAtomic(manifestPath(dir), `${JSON.stringify(manifest)}\n`);
|
|
130
130
|
}
|
|
131
|
+
const PIECE_SEP = /[/\\]/;
|
|
132
|
+
function piecePathRefusal(rel) {
|
|
133
|
+
return `manifest piece path leaves the ledger directory: ${rel}`;
|
|
134
|
+
}
|
|
135
|
+
function looksUnc(rel) {
|
|
136
|
+
return rel.startsWith("\\\\") || rel.startsWith("//") || rel.startsWith("\\/") || rel.startsWith("/\\");
|
|
137
|
+
}
|
|
138
|
+
function looksDrive(rel) {
|
|
139
|
+
return /^[A-Za-z]:/.test(rel);
|
|
140
|
+
}
|
|
141
|
+
function hasDotDot(rel) {
|
|
142
|
+
return rel.split(PIECE_SEP).some((part) => part === "..");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A manifest piece path is usable only when it stays inside `dir` after
|
|
146
|
+
* normalization. Absolute paths, drive letters, UNC (`\\host\share`,
|
|
147
|
+
* `//host/share`) and `..` are refused here, before any filesystem call.
|
|
148
|
+
* Null means the path is confined; the string is the problem to report.
|
|
149
|
+
*/
|
|
150
|
+
export function ledgerPiecePathProblem(dir, rel) {
|
|
151
|
+
if (typeof rel !== "string" || rel === "" || rel.includes("\0"))
|
|
152
|
+
return piecePathRefusal(String(rel));
|
|
153
|
+
if (looksUnc(rel) || looksDrive(rel) || hasDotDot(rel) || win32.isAbsolute(rel) || posix.isAbsolute(rel)) {
|
|
154
|
+
return piecePathRefusal(rel);
|
|
155
|
+
}
|
|
156
|
+
const root = resolve(dir);
|
|
157
|
+
const target = resolve(dir, rel);
|
|
158
|
+
const fromRoot = relative(root, target);
|
|
159
|
+
if (fromRoot === "" ||
|
|
160
|
+
win32.isAbsolute(fromRoot) ||
|
|
161
|
+
posix.isAbsolute(fromRoot) ||
|
|
162
|
+
fromRoot.split(PIECE_SEP).some((part) => part === "..")) {
|
|
163
|
+
return piecePathRefusal(rel);
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
/** Confined absolute path, or a thrown error. Does not touch the filesystem. */
|
|
168
|
+
export function requireLedgerPiecePath(dir, rel) {
|
|
169
|
+
const problem = ledgerPiecePathProblem(dir, rel);
|
|
170
|
+
if (problem !== null)
|
|
171
|
+
throw new Error(problem);
|
|
172
|
+
return resolve(dir, rel);
|
|
173
|
+
}
|
|
131
174
|
export function listPieceFiles(dir) {
|
|
132
175
|
const manifest = readLedgerManifest(dir);
|
|
133
176
|
const pieces = manifest?.pieces ?? [legacyPieceRow()];
|
|
134
177
|
return pieces.map((p) => ({
|
|
135
178
|
id: p.id,
|
|
136
|
-
decisions:
|
|
137
|
-
effects:
|
|
138
|
-
inputs:
|
|
139
|
-
copyDecisions:
|
|
140
|
-
copyEffects:
|
|
179
|
+
decisions: requireLedgerPiecePath(dir, p.decisions),
|
|
180
|
+
effects: requireLedgerPiecePath(dir, p.effects),
|
|
181
|
+
inputs: requireLedgerPiecePath(dir, p.inputs),
|
|
182
|
+
copyDecisions: requireLedgerPiecePath(dir, copyRel(p.id, "decisions.jsonl")),
|
|
183
|
+
copyEffects: requireLedgerPiecePath(dir, copyRel(p.id, "effects.jsonl")),
|
|
141
184
|
closed: p.closed,
|
|
142
185
|
firstMs: p.firstMs,
|
|
143
186
|
lastMs: p.lastMs,
|
package/dist/ledger.d.ts
CHANGED
|
@@ -132,6 +132,8 @@ export declare class FileLedger implements Ledger {
|
|
|
132
132
|
/** After lock handoff a new instance reloads; the lost owner cannot append. */
|
|
133
133
|
private loadCaches;
|
|
134
134
|
private applyActivePaths;
|
|
135
|
+
/** Manifest piece path, refused before any filesystem call when it leaves this directory. */
|
|
136
|
+
private pieceFile;
|
|
135
137
|
private activePiece;
|
|
136
138
|
private applyIndexLine;
|
|
137
139
|
private pruneCounted;
|
|
@@ -148,6 +150,12 @@ export declare class FileLedger implements Ledger {
|
|
|
148
150
|
private ownsLock;
|
|
149
151
|
/** Lock file on disk, not a process counter. */
|
|
150
152
|
lockStatus(): "held" | "free";
|
|
153
|
+
/**
|
|
154
|
+
* Record the port this process is listening on. Called once the socket is
|
|
155
|
+
* bound, so the number is the one the kernel gave us. An older lock has no
|
|
156
|
+
* port; this rewrite keeps the pid, the start time, and the token.
|
|
157
|
+
*/
|
|
158
|
+
recordListenPort(port: number): void;
|
|
151
159
|
private markLost;
|
|
152
160
|
private assertOwned;
|
|
153
161
|
private lockedError;
|