@verax-ai/proxy 0.2.2 → 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/dist/ledger.js CHANGED
@@ -13,7 +13,7 @@ import { canonical, decisionRecordHash } from "@cedulon/core";
13
13
  import { coseToHex, signCoseSign1 } from "@cedulon/cose";
14
14
  import { signEffectExtract, } from "@cedulon/effect-extract";
15
15
  import { readJsonlTail, seekOffsetByTimestamp } from "./jsonl-tail.js";
16
- import { copyRel, decisionIndexCount, DEFAULT_PIECE_MAX_BYTES, DEFAULT_PIECE_MAX_ROWS, indexPath, LEGACY_PIECE_ID, legacyPieceRow, newPieceRow, nextPieceId, pieceOverlaps, readLedgerIndex, readLedgerManifest, writeLedgerManifest, } from "./ledger-manifest.js";
16
+ import { copyRel, decisionIndexCount, DEFAULT_PIECE_MAX_BYTES, DEFAULT_PIECE_MAX_ROWS, indexPath, LEGACY_PIECE_ID, legacyPieceRow, newPieceRow, nextPieceId, pieceOverlaps, readLedgerIndex, readLedgerManifest, requireLedgerPiecePath, writeLedgerManifest, } from "./ledger-manifest.js";
17
17
  import { tenantKey } from "./tenant.js";
18
18
  import { sha256Canonical } from "./hash.js";
19
19
  import { SerialQueue } from "./serial-queue.js";
@@ -63,7 +63,7 @@ export function readLedgerTail(path, visit, chunkBytesOrOpts) {
63
63
  return readJsonlTail(path, visit, { open: ledgerFs.open, ...opts });
64
64
  }
65
65
  async function readPieceWindow(dir, piece, fileRel, toMs, tsOf, visit) {
66
- const path = join(dir, fileRel);
66
+ const path = requireLedgerPiecePath(dir, fileRel);
67
67
  const seekTarget = toMs + WINDOW_SLACK_MS;
68
68
  if (piece.lastMs !== null && seekTarget < piece.lastMs) {
69
69
  const endOffset = await seekOffsetByTimestamp(path, seekTarget, tsOf, { open: ledgerFs.open });
@@ -263,7 +263,7 @@ export class MemoryLedger {
263
263
  effectClass: "duplicate-effect",
264
264
  timestampMs: row.timestampMs,
265
265
  actor: row.actor,
266
- }, DEFAULT_WITNESS, resultHash, undefined));
266
+ }, DEFAULT_WITNESS, resultHash, this.effectSigner));
267
267
  throw new Error(`duplicate-effect:${row.ref}`);
268
268
  }
269
269
  this._effects.push(asStoredEffect(row, witnessClass, resultHash, this.effectSigner));
@@ -380,7 +380,7 @@ export class FileLedger {
380
380
  inputsPaths() {
381
381
  return {
382
382
  active: this.inputsPath,
383
- all: this.pieces.map((p) => join(this.dir, p.inputs)),
383
+ all: this.pieces.map((p) => this.pieceFile(p.inputs)),
384
384
  };
385
385
  }
386
386
  /** After lock handoff a new instance reloads; the lost owner cannot append. */
@@ -456,11 +456,15 @@ export class FileLedger {
456
456
  applyActivePaths() {
457
457
  const piece = this.pieces.find((p) => p.id === this.activeId) ?? this.pieces[this.pieces.length - 1];
458
458
  this.activeId = piece.id;
459
- this.decisionsPath = join(this.dir, piece.decisions);
460
- this.effectsPath = join(this.dir, piece.effects);
461
- this.inputsPath = join(this.dir, piece.inputs);
462
- this.copyDecisionsPath = join(this.dir, copyRel(piece.id, "decisions.jsonl"));
463
- this.copyEffectsPath = join(this.dir, copyRel(piece.id, "effects.jsonl"));
459
+ this.decisionsPath = this.pieceFile(piece.decisions);
460
+ this.effectsPath = this.pieceFile(piece.effects);
461
+ this.inputsPath = this.pieceFile(piece.inputs);
462
+ this.copyDecisionsPath = this.pieceFile(copyRel(piece.id, "decisions.jsonl"));
463
+ this.copyEffectsPath = this.pieceFile(copyRel(piece.id, "effects.jsonl"));
464
+ }
465
+ /** Manifest piece path, refused before any filesystem call when it leaves this directory. */
466
+ pieceFile(rel) {
467
+ return requireLedgerPiecePath(this.dir, rel);
464
468
  }
465
469
  activePiece() {
466
470
  return this.pieces.find((p) => p.id === this.activeId) ?? this.pieces[this.pieces.length - 1];
@@ -509,10 +513,10 @@ export class FileLedger {
509
513
  rebuildIndex(activeDecisions) {
510
514
  const lines = [];
511
515
  for (const piece of this.pieces) {
512
- const path = join(this.dir, piece.decisions);
516
+ const path = this.pieceFile(piece.decisions);
513
517
  const recs = piece.id === this.activeId ? activeDecisions : readJsonlSync(path);
514
- const inputLists = inputsByRef(join(this.dir, piece.inputs));
515
- const effectSet = primaryEffectRefs(join(this.dir, piece.effects));
518
+ const inputLists = inputsByRef(this.pieceFile(piece.inputs));
519
+ const effectSet = primaryEffectRefs(this.pieceFile(piece.effects));
516
520
  const seen = new Map();
517
521
  for (const rec of recs) {
518
522
  const row = indexRowOf(rec);
@@ -638,10 +642,10 @@ export class FileLedger {
638
642
  }
639
643
  }
640
644
  ensurePieceFiles(piece) {
641
- mkdirSync(join(this.dir, dirname(piece.decisions)), { recursive: true, mode: 0o700 });
642
- mkdirSync(join(this.dir, dirname(copyRel(piece.id, "decisions.jsonl"))), { recursive: true, mode: 0o700 });
645
+ mkdirSync(dirname(this.pieceFile(piece.decisions)), { recursive: true, mode: 0o700 });
646
+ mkdirSync(dirname(this.pieceFile(copyRel(piece.id, "decisions.jsonl"))), { recursive: true, mode: 0o700 });
643
647
  for (const rel of [piece.decisions, piece.effects, piece.inputs, copyRel(piece.id, "decisions.jsonl"), copyRel(piece.id, "effects.jsonl")]) {
644
- const path = join(this.dir, rel);
648
+ const path = this.pieceFile(rel);
645
649
  if (!existsSync(path))
646
650
  writeFileSync(path, "", { encoding: "utf8", mode: 0o600 });
647
651
  }
@@ -704,6 +708,29 @@ export class FileLedger {
704
708
  lockStatus() {
705
709
  return this.ownsLock() ? "held" : "free";
706
710
  }
711
+ /**
712
+ * Record the port this process is listening on. Called once the socket is
713
+ * bound, so the number is the one the kernel gave us. An older lock has no
714
+ * port; this rewrite keeps the pid, the start time, and the token.
715
+ */
716
+ recordListenPort(port) {
717
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536) {
718
+ throw new Error("ledger-listen-port");
719
+ }
720
+ this.assertOwned();
721
+ const existing = readLock(this.lockPath);
722
+ if (!existing || existing.token !== this.token) {
723
+ this.markLost();
724
+ throw new Error("ledger-lost-lock");
725
+ }
726
+ const body = `${JSON.stringify({
727
+ pid: existing.pid,
728
+ startedAt: existing.startedAt,
729
+ token: this.token,
730
+ port,
731
+ })}\n`;
732
+ writeFileAtomic(this.lockPath, body);
733
+ }
707
734
  markLost() {
708
735
  this.lost = true;
709
736
  }
@@ -897,7 +924,7 @@ export class FileLedger {
897
924
  effectClass: "duplicate-effect",
898
925
  timestampMs: row.timestampMs,
899
926
  actor: row.actor,
900
- }, DEFAULT_WITNESS, resultHash, undefined));
927
+ }, DEFAULT_WITNESS, resultHash, this.effectSigner));
901
928
  await appendDurable(this.effectsPath, dup);
902
929
  await this.pulse("effects.jsonl", dup, "effect");
903
930
  this.activeEffectN += 1;
@@ -915,14 +942,14 @@ export class FileLedger {
915
942
  async decisions() {
916
943
  const out = [];
917
944
  for (const piece of this.pieces) {
918
- out.push(...(await readJsonl(join(this.dir, piece.decisions))));
945
+ out.push(...(await readJsonl(this.pieceFile(piece.decisions))));
919
946
  }
920
947
  return out;
921
948
  }
922
949
  async effects() {
923
950
  const out = [];
924
951
  for (const piece of this.pieces) {
925
- out.push(...(await readJsonl(join(this.dir, piece.effects))));
952
+ out.push(...(await readJsonl(this.pieceFile(piece.effects))));
926
953
  }
927
954
  return out;
928
955
  }
package/dist/policy.d.ts CHANGED
@@ -4,6 +4,11 @@ export type PolicySpend = {
4
4
  currency: string;
5
5
  payees: readonly string[];
6
6
  dailyMaxMinor?: number;
7
+ /**
8
+ * Minutes added to `createdAtMs` before the UTC day bucket.
9
+ * 0 is UTC. −720..840 covers the offsets an operator's clock can sit in.
10
+ */
11
+ dayOffsetMinutes?: number;
7
12
  /** Statement stamps for this payee. Optional; never guessed. */
8
13
  descriptors?: readonly string[];
9
14
  };
@@ -21,7 +26,7 @@ export type PolicyDocument = {
21
26
  default: "deny";
22
27
  approvalTtlMs?: number;
23
28
  egress?: readonly string[];
24
- /** Body-wide: a call with no `_inputs` key is deny inputs-required. */
29
+ /** Body-wide: a call with no `_inputs` key, or an empty list, is deny inputs-required. */
25
30
  requireInputs?: true;
26
31
  limits?: {
27
32
  ratePerMinute?: number;
@@ -32,6 +37,12 @@ export type PolicyDocument = {
32
37
  };
33
38
  /** Named per tool. Do not sniff `host` / `url` on arbitrary arguments. */
34
39
  export declare const HOST_EXTRACTORS: Record<string, (args: Record<string, unknown>) => string | undefined>;
40
+ /**
41
+ * One class for the spend gate and the approve prompt: every control (Cc,
42
+ * C0 and C1), every format character (Cf: bidi, ZWJ/ZWNJ, U+FEFF), and the
43
+ * line and paragraph separators (Zl, Zp).
44
+ */
45
+ export declare const TERMINAL_CONTROL_CLASS = "\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}";
35
46
  export declare function parsePolicyDocument(json: unknown): PolicyDocument;
36
47
  export declare function ruleTextHash(text: string): string;
37
48
  export declare function loadPolicy(json: unknown): Policy;
package/dist/policy.js CHANGED
@@ -1,20 +1,50 @@
1
1
  import { parseIJson } from "@cedulon/core";
2
2
  import { defaultDiskFreeBytes } from "./disk.js";
3
3
  import { sha256Canonical } from "./hash.js";
4
+ /** One DNS label: letters, digits, hyphens; no leading or trailing hyphen. */
5
+ function plainDnsLabel(label) {
6
+ if (label.length === 0 || label.length > 63)
7
+ return false;
8
+ return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label);
9
+ }
10
+ /** A hostname and nothing else: no port, path, whitespace, or trailing dot. */
11
+ function plainDnsHost(host) {
12
+ if (host.length === 0 || host.length > 253)
13
+ return undefined;
14
+ const labels = host.split(".");
15
+ if (labels.length === 0 || labels.some((label) => !plainDnsLabel(label)))
16
+ return undefined;
17
+ return host.toLowerCase();
18
+ }
19
+ /**
20
+ * `to` is one address. More than one `@`, a local part that carries a list
21
+ * separator or whitespace, or a host that is not a plain DNS name yields no
22
+ * host, and the egress rule then denies `egress-host-missing`.
23
+ */
24
+ function hasControl(value) {
25
+ for (let i = 0; i < value.length; i += 1) {
26
+ const c = value.charCodeAt(i);
27
+ if (c < 0x20 || c === 0x7f)
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+ function messageSendHost(args) {
33
+ const to = args.to;
34
+ if (typeof to !== "string" || to === "")
35
+ return undefined;
36
+ const at = to.indexOf("@");
37
+ if (at <= 0 || at !== to.lastIndexOf("@"))
38
+ return undefined;
39
+ const local = to.slice(0, at);
40
+ const host = to.slice(at + 1);
41
+ if (local === "" || /[\s,;<>"]/.test(local) || hasControl(local))
42
+ return undefined;
43
+ return plainDnsHost(host);
44
+ }
4
45
  /** Named per tool. Do not sniff `host` / `url` on arbitrary arguments. */
5
46
  export const HOST_EXTRACTORS = {
6
- "message.send": (args) => {
7
- const to = args.to;
8
- if (typeof to !== "string" || to === "")
9
- return undefined;
10
- const at = to.lastIndexOf("@");
11
- if (at < 0)
12
- return undefined;
13
- const host = to.slice(at + 1).trim().toLowerCase();
14
- if (host === "" || host.includes("/") || host.includes(" "))
15
- return undefined;
16
- return host;
17
- },
47
+ "message.send": messageSendHost,
18
48
  };
19
49
  const DEFAULT_APPROVAL_TTL_MS = 86_400_000;
20
50
  function asRule(raw, index) {
@@ -90,6 +120,15 @@ function asSpend(raw, id) {
90
120
  }
91
121
  spend.dailyMaxMinor = rec.dailyMaxMinor;
92
122
  }
123
+ if (rec.dayOffsetMinutes !== undefined) {
124
+ if (typeof rec.dayOffsetMinutes !== "number" ||
125
+ !Number.isInteger(rec.dayOffsetMinutes) ||
126
+ rec.dayOffsetMinutes < -720 ||
127
+ rec.dayOffsetMinutes > 840) {
128
+ throw new Error(`policy-rule-spend-day-offset:${id}`);
129
+ }
130
+ spend.dayOffsetMinutes = rec.dayOffsetMinutes;
131
+ }
93
132
  if (rec.descriptors !== undefined) {
94
133
  if (!Array.isArray(rec.descriptors) ||
95
134
  rec.descriptors.length === 0 ||
@@ -100,6 +139,13 @@ function asSpend(raw, id) {
100
139
  }
101
140
  return spend;
102
141
  }
142
+ /**
143
+ * One class for the spend gate and the approve prompt: every control (Cc,
144
+ * C0 and C1), every format character (Cf: bidi, ZWJ/ZWNJ, U+FEFF), and the
145
+ * line and paragraph separators (Zl, Zp).
146
+ */
147
+ export const TERMINAL_CONTROL_CLASS = "\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}";
148
+ const SPEND_CONTROL = new RegExp(`[${TERMINAL_CONTROL_CLASS}]`, "u");
103
149
  function spendArgsInvalid(args) {
104
150
  const keys = Object.keys(args);
105
151
  const allowed = new Set(["amountMinor", "currency", "payee", "reference"]);
@@ -110,9 +156,11 @@ function spendArgsInvalid(args) {
110
156
  return true;
111
157
  if (typeof args.currency !== "string" || !/^[A-Z]{3}$/.test(args.currency))
112
158
  return true;
113
- if (typeof args.payee !== "string" || args.payee === "")
159
+ if (typeof args.payee !== "string" || args.payee === "" || SPEND_CONTROL.test(args.payee))
160
+ return true;
161
+ if (typeof args.reference !== "string" || args.reference.length > 140 || SPEND_CONTROL.test(args.reference))
114
162
  return true;
115
- if (typeof args.reference !== "string" || args.reference.length > 140)
163
+ if (SPEND_CONTROL.test(args.currency))
116
164
  return true;
117
165
  return false;
118
166
  }
@@ -158,8 +206,24 @@ export function parsePolicyDocument(json) {
158
206
  }
159
207
  document.requireInputs = true;
160
208
  }
209
+ rejectDuplicateRules(document.rules);
161
210
  return document;
162
211
  }
212
+ function rejectDuplicateRules(rules) {
213
+ const seenId = new Set();
214
+ const toolOwner = new Map();
215
+ for (const rule of rules) {
216
+ if (seenId.has(rule.id)) {
217
+ throw new Error(`duplicate rule id ${rule.id} and ${rule.id}`);
218
+ }
219
+ seenId.add(rule.id);
220
+ const prior = toolOwner.get(rule.tool);
221
+ if (prior !== undefined) {
222
+ throw new Error(`duplicate rule for tool ${rule.tool}: ${prior} and ${rule.id}`);
223
+ }
224
+ toolOwner.set(rule.tool, rule.id);
225
+ }
226
+ }
163
227
  function asLimits(raw) {
164
228
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
165
229
  throw new Error("policy-limits");
@@ -188,8 +252,10 @@ export function loadPolicy(json) {
188
252
  ...(document.limits?.ratePerMinute !== undefined ? { ratePerMinute: document.limits.ratePerMinute } : {}),
189
253
  ...(document.limits?.dailyMax !== undefined ? { dailyMax: document.limits.dailyMax } : {}),
190
254
  };
255
+ const spendRule = document.rules.find((rule) => rule.tool === "spend");
191
256
  return {
192
257
  hash,
258
+ dayOffsetMinutes: spendRule?.spend?.dayOffsetMinutes ?? 0,
193
259
  approvalTtlMs: document.approvalTtlMs ?? DEFAULT_APPROVAL_TTL_MS,
194
260
  limits,
195
261
  ...(document.requireInputs ? { requireInputs: true } : {}),
@@ -201,8 +267,13 @@ export function loadPolicy(json) {
201
267
  ? {
202
268
  id: found.id,
203
269
  text: found.text,
204
- ...(found.spend?.dailyMaxMinor !== undefined
205
- ? { spend: { dailyMaxMinor: found.spend.dailyMaxMinor } }
270
+ ...(found.spend
271
+ ? {
272
+ spend: {
273
+ ...(found.spend.dailyMaxMinor !== undefined ? { dailyMaxMinor: found.spend.dailyMaxMinor } : {}),
274
+ dayOffsetMinutes: found.spend.dayOffsetMinutes ?? 0,
275
+ },
276
+ }
206
277
  : {}),
207
278
  }
208
279
  : null;
package/dist/proxy.js CHANGED
@@ -73,6 +73,16 @@ function threwReplay(ref) {
73
73
  isError: true,
74
74
  };
75
75
  }
76
+ /** The tool returned. The effect row did not. That is not `:threw`. */
77
+ function effectUnrecorded(ref) {
78
+ return {
79
+ content: [{ type: "text", text: `effect-unrecorded:${ref}` }],
80
+ isError: true,
81
+ };
82
+ }
83
+ function isEffectUnrecorded(result) {
84
+ return result.content.some((part) => part.text.startsWith("effect-unrecorded:"));
85
+ }
76
86
  async function replayAfterEffect(ledger, ref) {
77
87
  const primary = (await ledger.effects()).find((e) => e.row.ref === ref && e.row.effectClass !== "duplicate-effect");
78
88
  if (primary?.row.effectClass.endsWith(":threw")) {
@@ -137,6 +147,27 @@ function readRef(args) {
137
147
  return "invalid";
138
148
  return raw;
139
149
  }
150
+ /**
151
+ * The input-validity fold a new decision uses. A cited input that is missing,
152
+ * mismatched, or outside validFromMs..validUntilMs is a deny for that reason.
153
+ * An approved retry calls this same function before inner; it does not grow a second copy.
154
+ */
155
+ function applyInputValidity(inputReason, verdict) {
156
+ if (inputReason !== null)
157
+ return { decision: "deny", reasonCode: inputReason };
158
+ return verdict;
159
+ }
160
+ function inputIdentityHash(rows) {
161
+ return sha256Canonical(rows.map((row) => {
162
+ const id = {
163
+ id: row.id,
164
+ versionHash: row.versionHash,
165
+ };
166
+ if (row.source !== undefined)
167
+ id.source = row.source;
168
+ return id;
169
+ }));
170
+ }
140
171
  function toolCallOf(call) {
141
172
  const args = { ...call.arguments };
142
173
  delete args._inputs;
@@ -152,6 +183,7 @@ export function createProxy(deps) {
152
183
  policy: deps.policy,
153
184
  approvals,
154
185
  now: deps.now,
186
+ ledger: deps.ledger,
155
187
  });
156
188
  deps.ledger.effectSigner = deps.effectSigner;
157
189
  const ledgerDir = deps.ledger.dir;
@@ -233,7 +265,7 @@ export function createProxy(deps) {
233
265
  });
234
266
  return denied("spend-reauth-required", ref);
235
267
  }
236
- async function runInner(call, principal, ref) {
268
+ async function runInner(call, principal, ref, effectRecorded) {
237
269
  const dispatchedName = call.name;
238
270
  const dispatchedArgs = deepFreeze(structuredClone(call.arguments));
239
271
  const dispatchedHash = sha256Canonical(effectDescriptor(dispatchedName, dispatchedArgs));
@@ -242,17 +274,9 @@ export function createProxy(deps) {
242
274
  name: dispatchedName,
243
275
  arguments: dispatchedArgs,
244
276
  });
277
+ let result;
245
278
  try {
246
- const result = await deps.inner(frozenCall, principal, ref);
247
- const row = {
248
- ref,
249
- effectHash: dispatchedHash,
250
- effectClass: dispatchedName,
251
- timestampMs: deps.now(),
252
- actor: principal.brain,
253
- };
254
- await deps.ledger.appendEffect(row, "self", sha256Canonical(result));
255
- return result;
279
+ result = await deps.inner(frozenCall, principal, ref);
256
280
  }
257
281
  catch (err) {
258
282
  const row = {
@@ -262,9 +286,33 @@ export function createProxy(deps) {
262
286
  timestampMs: deps.now(),
263
287
  actor: principal.brain,
264
288
  };
265
- await deps.ledger.appendEffect(row, "self", sha256Canonical(thrownPayload(err)));
289
+ try {
290
+ await deps.ledger.appendEffect(row, "self", sha256Canonical(thrownPayload(err)));
291
+ effectRecorded.ok = true;
292
+ }
293
+ catch (writeErr) {
294
+ throw writeErr;
295
+ }
266
296
  throw err;
267
297
  }
298
+ const row = {
299
+ ref,
300
+ effectHash: dispatchedHash,
301
+ effectClass: dispatchedName,
302
+ timestampMs: deps.now(),
303
+ actor: principal.brain,
304
+ };
305
+ try {
306
+ await deps.ledger.appendEffect(row, "self", sha256Canonical(result));
307
+ effectRecorded.ok = true;
308
+ }
309
+ catch {
310
+ // The tool already returned. A failed effect write is not a throw of the
311
+ // tool, so this does not record `:threw`. The start mark stays (effectRecorded
312
+ // stays false) and a restart answers outcome-unknown instead of running again.
313
+ return effectUnrecorded(ref);
314
+ }
315
+ return result;
268
316
  }
269
317
  async function resolveInputs(call, principal, timestampMs) {
270
318
  const declared = declaredInputs(call.arguments);
@@ -274,7 +322,8 @@ export function createProxy(deps) {
274
322
  reasonCode: "input-invalid",
275
323
  };
276
324
  }
277
- if (declared === null && deps.policy.requireInputs === true) {
325
+ if (deps.policy.requireInputs === true &&
326
+ (declared === null || declared.length === 0)) {
278
327
  return {
279
328
  inputs: { principal: principalInputs(principal), inputs: [] },
280
329
  reasonCode: "inputs-required",
@@ -309,22 +358,57 @@ export function createProxy(deps) {
309
358
  reasonCode: null,
310
359
  };
311
360
  }
361
+ async function boundInputsReason(rows, timestampMs, principal) {
362
+ for (const item of rows) {
363
+ if (deps.resolveInput) {
364
+ const got = await deps.resolveInput(item.id, principal);
365
+ if (!got ||
366
+ got.versionHash !== item.versionHash ||
367
+ got.validFromMs > timestampMs ||
368
+ got.validUntilMs < timestampMs) {
369
+ return "input-invalid";
370
+ }
371
+ continue;
372
+ }
373
+ if (item.validFromMs > timestampMs || item.validUntilMs < timestampMs)
374
+ return "input-invalid";
375
+ }
376
+ return null;
377
+ }
378
+ async function signedRetryDeny(reasonCode, requestHash, inputs, timestampMs, subject, scopedRef) {
379
+ const denyRef = deps.nonce();
380
+ await writeRecord({
381
+ decision: "deny",
382
+ reasonCode,
383
+ ref: denyRef,
384
+ requestHash,
385
+ inputs: {
386
+ ...inputs,
387
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
388
+ },
389
+ effectHash: null,
390
+ subject,
391
+ timestampMs,
392
+ });
393
+ return denied(reasonCode, denyRef);
394
+ }
312
395
  function launchInner(dispatchedCall, principal, allowRef, flightKey) {
313
396
  // Written before the tool runs, removed after the effect is recorded. A
314
397
  // mark that survives the process is what tells a restarted body that this
315
398
  // ref was already started (in-flight-log.ts).
316
399
  if (stateDir !== null)
317
400
  markStarted(stateDir, flightKey, dispatchedCall.name);
318
- const work = runInner(dispatchedCall, principal, allowRef);
401
+ const effectRecorded = { ok: false };
402
+ const work = runInner(dispatchedCall, principal, allowRef, effectRecorded);
319
403
  inFlight.set(flightKey, work);
320
- return { kind: "run", work, key: flightKey, replayRef: allowRef };
404
+ return { kind: "run", work, key: flightKey, replayRef: allowRef, effectRecorded };
321
405
  }
322
406
  async function retryPolicyDeny(dispatchedCall, principal, requestHash, inputs, timestampMs, subject, scopedRef) {
323
407
  let spendCtx;
324
408
  if (dispatchedCall.name === "spend") {
325
409
  const approvalRows = await approvals.listAll();
326
410
  spendCtx = {
327
- spentTodayMinor: (currency) => spentTodayMinorOf(approvalRows, timestampMs, currency, deps.policy.approvalTtlMs),
411
+ spentTodayMinor: (currency) => spentTodayMinorOf(approvalRows, timestampMs, currency, deps.policy.approvalTtlMs, deps.policy.dayOffsetMinutes ?? 0),
328
412
  };
329
413
  }
330
414
  const verdict = deps.policy.evaluate(dispatchedCall, principal, spendCtx);
@@ -362,8 +446,8 @@ export function createProxy(deps) {
362
446
  nonce: deps.nonce,
363
447
  ref: cmd.ref,
364
448
  approverId: cmd.approverId,
365
- // Only `verax approve` queues a command, when the ledger is locked.
366
- via: "cli",
449
+ // A command with no `via` is an old queue file; treat it as a script, not a person.
450
+ via: cmd.via === "cli" ? "cli" : "cli-script",
367
451
  policyHash: defer.policyHash,
368
452
  approvals,
369
453
  inputsLog,
@@ -405,6 +489,21 @@ export function createProxy(deps) {
405
489
  }
406
490
  const given = readRef(call.arguments);
407
491
  const plan = await admission.enqueue(async () => {
492
+ // A call that waited here behind another admission reads the halt flag again.
493
+ if (stateDir && existsSync(join(stateDir, "halted"))) {
494
+ const ref = deps.nonce();
495
+ await writeRecord({
496
+ decision: "deny",
497
+ reasonCode: "halted",
498
+ ref,
499
+ requestHash,
500
+ inputs: resolved.inputs,
501
+ effectHash: null,
502
+ subject: call.name,
503
+ timestampMs,
504
+ });
505
+ return { kind: "done", result: denied("halted", ref) };
506
+ }
408
507
  if (given === "invalid") {
409
508
  const ref = deps.nonce();
410
509
  await writeRecord({
@@ -473,7 +572,10 @@ export function createProxy(deps) {
473
572
  return { kind: "done", result: deferred(given) };
474
573
  }
475
574
  if (!snap?.allowRef) {
476
- await approvals.updateStatus(scopedRef, "approved", { allowRef: allow.ref });
575
+ await approvals.updateStatus(scopedRef, "approved", {
576
+ allowRef: allow.ref,
577
+ approvedAtMs: allow.timestampMs,
578
+ });
477
579
  }
478
580
  if (await hasPrimaryEffect(deps.ledger, allow.ref)) {
479
581
  return { kind: "done", result: await replayAfterEffect(deps.ledger, allow.ref) };
@@ -511,6 +613,49 @@ export function createProxy(deps) {
511
613
  });
512
614
  return { kind: "done", result: denied("outcome-unknown", unknownRef) };
513
615
  }
616
+ const declared = declaredInputs(call.arguments);
617
+ if (declared === "invalid") {
618
+ return {
619
+ kind: "done",
620
+ result: await signedRetryDeny("input-invalid", requestHash, resolved.inputs, timestampMs, call.name, scopedRef),
621
+ };
622
+ }
623
+ const boundRows = bound?.inputs ?? [];
624
+ if (declared !== null && inputIdentityHash(declared) !== inputIdentityHash(boundRows)) {
625
+ return {
626
+ kind: "done",
627
+ result: await signedRetryDeny("inputs-changed", requestHash, resolved.inputs, timestampMs, call.name, scopedRef),
628
+ };
629
+ }
630
+ const validityReason = await boundInputsReason(boundRows, timestampMs, principal);
631
+ const gated = applyInputValidity(validityReason, {
632
+ decision: "allow",
633
+ reasonCode: "approved-by-operator",
634
+ });
635
+ if (gated.decision !== "allow") {
636
+ return {
637
+ kind: "done",
638
+ result: await signedRetryDeny(gated.reasonCode, requestHash, resolved.inputs, timestampMs, call.name, scopedRef),
639
+ };
640
+ }
641
+ if (deps.checkTenantMismatch &&
642
+ (await deps.checkTenantMismatch(dispatched, principal))) {
643
+ const denyRef = deps.nonce();
644
+ await writeRecord({
645
+ decision: "deny",
646
+ reasonCode: "tenant-mismatch",
647
+ ref: denyRef,
648
+ requestHash,
649
+ inputs: {
650
+ ...resolved.inputs,
651
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
652
+ },
653
+ effectHash: null,
654
+ subject: call.name,
655
+ timestampMs,
656
+ });
657
+ return { kind: "done", result: denied("tenant-mismatch", denyRef) };
658
+ }
514
659
  return launchInner(dispatched, principal, allow.ref, scopedRef);
515
660
  }
516
661
  return { kind: "done", result: deferred(given) };
@@ -556,19 +701,20 @@ export function createProxy(deps) {
556
701
  if (dispatched.name === "spend") {
557
702
  const approvalRows = await approvals.listAll();
558
703
  spendCtx = {
559
- spentTodayMinor: (currency) => spentTodayMinorOf(approvalRows, timestampMs, currency, deps.policy.approvalTtlMs),
704
+ spentTodayMinor: (currency) => spentTodayMinorOf(approvalRows, timestampMs, currency, deps.policy.approvalTtlMs, deps.policy.dayOffsetMinutes ?? 0),
560
705
  };
561
706
  }
562
707
  const verdict = deps.policy.evaluate(dispatched, principal, spendCtx);
563
- let reasonCode = resolved.reasonCode ?? verdict.reasonCode;
564
- let decision = resolved.reasonCode ? "deny" : verdict.decision;
708
+ const admitted = applyInputValidity(resolved.reasonCode, verdict);
709
+ let reasonCode = admitted.reasonCode;
710
+ let decision = admitted.decision;
565
711
  const bound = rateBound(timestampMs, decision === "allow" || decision === "defer");
566
712
  if (bound) {
567
713
  decision = "deny";
568
714
  reasonCode = bound;
569
715
  }
570
716
  if (!bound &&
571
- decision === "allow" &&
717
+ (decision === "allow" || decision === "defer") &&
572
718
  deps.checkTenantMismatch &&
573
719
  (await deps.checkTenantMismatch(dispatched, principal))) {
574
720
  decision = "deny";
@@ -626,7 +772,9 @@ export function createProxy(deps) {
626
772
  return plan.result;
627
773
  if (plan.kind === "wait") {
628
774
  try {
629
- await plan.work;
775
+ const result = await plan.work;
776
+ if (isEffectUnrecorded(result))
777
+ return result;
630
778
  return allowedReplay(plan.replayRef);
631
779
  }
632
780
  catch {
@@ -640,9 +788,11 @@ export function createProxy(deps) {
640
788
  }
641
789
  finally {
642
790
  inFlight.delete(plan.key);
643
- // runInner has written its effect row by now — on the throw path too,
644
- // as `<name>:threw`. The outcome is on the ledger, so the mark goes.
645
- if (stateDir !== null)
791
+ // The mark stays when the effect row did not land: the tool threw and
792
+ // `:threw` could not be written, or the tool returned and its effect
793
+ // row could not be written. A retry then answers outcome-unknown
794
+ // instead of running again. A returned tool is not recorded as `:threw`.
795
+ if (plan.effectRecorded.ok && stateDir !== null)
646
796
  markEnded(stateDir, plan.key);
647
797
  }
648
798
  },