@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 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
@@ -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({ ...cur, status, ...(extra?.allowRef !== undefined ? { allowRef: extra.allowRef } : {}) });
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({ ...cur, status, ...(extra?.allowRef !== undefined ? { allowRef: extra.allowRef } : {}) });
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 d = new Date(nowMs);
118
- const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
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 !== "pending" && row.status !== "approved")
125
- continue;
126
- const created = row.createdAtMs ?? row.expiresAtMs - approvalTtlMs;
127
- if (created < start || created >= end)
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 dailyMax = opts.policy.rule(snap.ruleId)?.spend?.dailyMaxMinor;
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; approved spend
148
- // plus this amount is what the operator is about to commit.
149
- const others = (await opts.approvals.listAll()).filter((row) => row.ref !== snap.ref && row.status === "approved");
150
- const spent = spentTodayMinorOf(others, opts.now(), currency, opts.policy.approvalTtlMs);
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
- await opts.approvals.updateStatus(opts.ref, hit.kind === "allow" ? "approved" : "expired", hit.kind === "allow" ? { allowRef: hit.ref } : undefined);
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
- appendFileSync(join(dir, "approval-commands.jsonl"), `${JSON.stringify(cmd)}\n`, {
286
- encoding: "utf8",
287
- mode: 0o600,
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;
@@ -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
+ };
@@ -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;
@@ -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: join(dir, p.decisions),
137
- effects: join(dir, p.effects),
138
- inputs: join(dir, p.inputs),
139
- copyDecisions: join(dir, copyRel(p.id, "decisions.jsonl")),
140
- copyEffects: join(dir, copyRel(p.id, "effects.jsonl")),
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;