@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/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 =
|
|
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,
|
|
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) =>
|
|
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 =
|
|
460
|
-
this.effectsPath =
|
|
461
|
-
this.inputsPath =
|
|
462
|
-
this.copyDecisionsPath =
|
|
463
|
-
this.copyEffectsPath =
|
|
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 =
|
|
516
|
+
const path = this.pieceFile(piece.decisions);
|
|
513
517
|
const recs = piece.id === this.activeId ? activeDecisions : readJsonlSync(path);
|
|
514
|
-
const inputLists = inputsByRef(
|
|
515
|
-
const effectSet = primaryEffectRefs(
|
|
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(
|
|
642
|
-
mkdirSync(
|
|
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 =
|
|
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,
|
|
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(
|
|
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(
|
|
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":
|
|
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 (
|
|
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
|
|
205
|
-
? {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
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", {
|
|
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
|
-
|
|
564
|
-
let
|
|
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
|
-
//
|
|
644
|
-
//
|
|
645
|
-
|
|
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
|
},
|