@fidacy/mcp 0.1.11 → 0.1.13

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/CHANGELOG.md CHANGED
@@ -95,3 +95,26 @@ semantic versioning.
95
95
  - verify_mandate/get_audit_proof gain one informational closing line each;
96
96
  assess_action's missing-key error now offers the free-key path.
97
97
  - config.json gains created_at + nudge/decision state (preserved on read).
98
+
99
+ ## 0.1.12 — 2026-07-02
100
+
101
+ - **The BEC guarantee now survives restarts.** The local free-tier core kept the
102
+ duplicate-invoice set and the spent counter in memory only, so a re-presented
103
+ invoice was ALLOWED again after every process restart — exactly the short-lived
104
+ shell case (`npx` per session). The core now rehydrates both from the persisted
105
+ audit log at boot: prior ALLOW records rebuild the invoice claims (a paid
106
+ invoice stays paid, regardless of age) and re-sum `spent` within the current
107
+ mandate window and currency. One O(records) pass over the already-loaded chain.
108
+ - Audit records for ALLOWs now carry `amount`, `currency`, and the canonical
109
+ `invoiceRef` (rehydration fields). Additive: old logs stay valid, chain hashes
110
+ unchanged; pre-0.1.12 records simply carry no rehydratable state.
111
+ - Torn-tail recovery: a crash mid-append used to quarantine the WHOLE audit log
112
+ on next boot (losing all dedup state). A torn trailing line is now recognized,
113
+ the intact prefix is salvaged, and the tail truncated. Mid-file damage still
114
+ quarantines the file as before.
115
+
116
+ ## 0.1.13 — 2026-07-03
117
+
118
+ - MCP handshake now reports the real package version (was hardcoded "0.1.0"
119
+ through 0.1.12 — same class as the client_version telemetry bug, same fix:
120
+ injected from package.json at build time).
package/dist/core.js CHANGED
@@ -33,66 +33,9 @@ function sha256(input) {
33
33
 
34
34
  // ../firewall/dist/audit-store.js
35
35
  import fs from "node:fs";
36
- var FileAuditStore = class {
37
- path;
38
- chain = [];
39
- constructor(path) {
40
- this.path = path;
41
- this.load();
42
- }
43
- load() {
44
- if (!fs.existsSync(this.path))
45
- return;
46
- try {
47
- const raw = fs.readFileSync(this.path, "utf8");
48
- const parsed = [];
49
- for (const line of raw.split("\n")) {
50
- if (!line.trim())
51
- continue;
52
- parsed.push(JSON.parse(line));
53
- }
54
- this.chain = parsed;
55
- if (!this.intact())
56
- throw new Error("audit chain integrity broken");
57
- } catch (err) {
58
- this.chain = [];
59
- try {
60
- const quarantine = `${this.path}.corrupt-${Date.now()}`;
61
- fs.renameSync(this.path, quarantine);
62
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
63
- } catch {
64
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
65
- }
66
- }
67
- }
68
- head() {
69
- return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
70
- }
71
- append(decision) {
72
- const prevHash = this.head();
73
- const seq = this.chain.length;
74
- const ts = decision.ts;
75
- const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
76
- const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
77
- const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
78
- fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
79
- this.chain.push(record2);
80
- return record2;
81
- }
82
- find(decisionId) {
83
- return this.chain.find((r) => r.decisionId === decisionId);
84
- }
85
- intact() {
86
- let prev = "GENESIS";
87
- for (const r of this.chain) {
88
- const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
89
- if (expected !== r.hash || r.prevHash !== prev)
90
- return false;
91
- prev = r.hash;
92
- }
93
- return true;
94
- }
95
- };
36
+
37
+ // ../firewall/dist/core.js
38
+ import { randomUUID } from "node:crypto";
96
39
 
97
40
  // ../firewall/dist/evaluate.js
98
41
  function fold(s) {
@@ -169,7 +112,6 @@ function evaluate(mandate, req, spentSoFar) {
169
112
  }
170
113
 
171
114
  // ../firewall/dist/core.js
172
- import { randomUUID } from "node:crypto";
173
115
  function canonInvoice(ref) {
174
116
  if (typeof ref !== "string")
175
117
  return "";
@@ -217,6 +159,35 @@ var DevFidacyCore = class {
217
159
  this.mandate = opts.mandate;
218
160
  this.store = new FileAuditStore(opts.auditLogPath ?? "./fidacy-audit.log");
219
161
  this.onDecision = opts.onDecision;
162
+ this.rehydrate();
163
+ }
164
+ /**
165
+ * Rebuild the in-memory decision state from the persisted audit log, so a process
166
+ * restart cannot re-open a claimed invoice (BEC dedup) or reset the maxTotal
167
+ * counter. One O(records) pass at boot over the already-loaded chain:
168
+ * - claimedInvoices: every ALLOW that carried an invoiceRef, regardless of age —
169
+ * a paid invoice stays paid.
170
+ * - spent: ALLOW amounts whose timestamp falls inside the CURRENT mandate window
171
+ * and whose currency matches the mandate (the cap is defined per window/currency).
172
+ * Records from older versions lack the rehydration fields and are skipped.
173
+ */
174
+ rehydrate() {
175
+ const notBefore = Date.parse(this.mandate.window.notBefore);
176
+ const notAfter = Date.parse(this.mandate.window.notAfter);
177
+ for (const r of this.store.records()) {
178
+ if (r.status !== "ALLOW")
179
+ continue;
180
+ if (r.invoiceRef)
181
+ this.claimedInvoices.add(`${r.subject}|${r.invoiceRef}`);
182
+ if (typeof r.amount !== "number" || !Number.isFinite(r.amount))
183
+ continue;
184
+ if (r.currency !== this.mandate.allow.currency)
185
+ continue;
186
+ const t = Date.parse(r.ts);
187
+ if (!Number.isFinite(t) || t < notBefore || t > notAfter)
188
+ continue;
189
+ this.spent += r.amount;
190
+ }
220
191
  }
221
192
  async getMandate() {
222
193
  return this.mandate;
@@ -303,6 +274,96 @@ var HttpFidacyCore = class {
303
274
  }
304
275
  };
305
276
 
277
+ // ../firewall/dist/audit-store.js
278
+ var FileAuditStore = class {
279
+ path;
280
+ chain = [];
281
+ constructor(path) {
282
+ this.path = path;
283
+ this.load();
284
+ }
285
+ load() {
286
+ if (!fs.existsSync(this.path))
287
+ return;
288
+ try {
289
+ const raw = fs.readFileSync(this.path, "utf8");
290
+ const lines = raw.split("\n");
291
+ const parsed = [];
292
+ let torn = false;
293
+ for (let i = 0; i < lines.length; i++) {
294
+ if (!lines[i].trim())
295
+ continue;
296
+ try {
297
+ parsed.push(JSON.parse(lines[i]));
298
+ } catch {
299
+ if (lines.slice(i + 1).some((l) => l.trim()))
300
+ throw new Error(`unparseable record at line ${i + 1}`);
301
+ torn = true;
302
+ break;
303
+ }
304
+ }
305
+ this.chain = parsed;
306
+ if (!this.intact())
307
+ throw new Error("audit chain integrity broken");
308
+ if (torn) {
309
+ const clean = parsed.map((r) => JSON.stringify(r)).join("\n") + (parsed.length ? "\n" : "");
310
+ fs.writeFileSync(this.path, clean);
311
+ console.error(`[fidacy] audit log at ${this.path} had a torn trailing line (crash during append?); salvaged ${parsed.length} intact records and truncated the tail.`);
312
+ }
313
+ } catch (err) {
314
+ this.chain = [];
315
+ try {
316
+ const quarantine = `${this.path}.corrupt-${Date.now()}`;
317
+ fs.renameSync(this.path, quarantine);
318
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
319
+ } catch {
320
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
321
+ }
322
+ }
323
+ }
324
+ head() {
325
+ return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
326
+ }
327
+ append(decision) {
328
+ const prevHash = this.head();
329
+ const seq = this.chain.length;
330
+ const ts = decision.ts;
331
+ const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
332
+ const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
333
+ const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
334
+ if (decision.status === "ALLOW") {
335
+ const req = decision.request;
336
+ if (typeof req?.amount === "number" && Number.isFinite(req.amount))
337
+ record2.amount = req.amount;
338
+ if (typeof req?.currency === "string")
339
+ record2.currency = req.currency;
340
+ const invoice = canonInvoice(req?.invoiceRef);
341
+ if (invoice)
342
+ record2.invoiceRef = invoice;
343
+ }
344
+ fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
345
+ this.chain.push(record2);
346
+ return record2;
347
+ }
348
+ find(decisionId) {
349
+ return this.chain.find((r) => r.decisionId === decisionId);
350
+ }
351
+ /** The full loaded chain, oldest first. Read-only; used to rehydrate core state at boot. */
352
+ records() {
353
+ return this.chain;
354
+ }
355
+ intact() {
356
+ let prev = "GENESIS";
357
+ for (const r of this.chain) {
358
+ const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
359
+ if (expected !== r.hash || r.prevHash !== prev)
360
+ return false;
361
+ prev = r.hash;
362
+ }
363
+ return true;
364
+ }
365
+ };
366
+
306
367
  // src/config.ts
307
368
  import { homedir } from "node:os";
308
369
  import { join } from "node:path";
@@ -362,7 +423,7 @@ function resolveMandateRules(cfg) {
362
423
  }
363
424
 
364
425
  // src/telemetry.ts
365
- var CLIENT_VERSION = true ? "0.1.11" : "dev";
426
+ var CLIENT_VERSION = true ? "0.1.13" : "dev";
366
427
  var currentShell = "mcp";
367
428
  function telemetryEnabled() {
368
429
  const v = (process.env.FIDACY_DISABLE_TELEMETRY ?? "").trim().toLowerCase();
package/dist/index.js CHANGED
@@ -40,66 +40,9 @@ function sha256(input) {
40
40
 
41
41
  // ../firewall/dist/audit-store.js
42
42
  import fs from "node:fs";
43
- var FileAuditStore = class {
44
- path;
45
- chain = [];
46
- constructor(path) {
47
- this.path = path;
48
- this.load();
49
- }
50
- load() {
51
- if (!fs.existsSync(this.path))
52
- return;
53
- try {
54
- const raw = fs.readFileSync(this.path, "utf8");
55
- const parsed = [];
56
- for (const line of raw.split("\n")) {
57
- if (!line.trim())
58
- continue;
59
- parsed.push(JSON.parse(line));
60
- }
61
- this.chain = parsed;
62
- if (!this.intact())
63
- throw new Error("audit chain integrity broken");
64
- } catch (err) {
65
- this.chain = [];
66
- try {
67
- const quarantine = `${this.path}.corrupt-${Date.now()}`;
68
- fs.renameSync(this.path, quarantine);
69
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
70
- } catch {
71
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
72
- }
73
- }
74
- }
75
- head() {
76
- return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
77
- }
78
- append(decision) {
79
- const prevHash = this.head();
80
- const seq = this.chain.length;
81
- const ts = decision.ts;
82
- const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
83
- const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
84
- const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
85
- fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
86
- this.chain.push(record2);
87
- return record2;
88
- }
89
- find(decisionId) {
90
- return this.chain.find((r) => r.decisionId === decisionId);
91
- }
92
- intact() {
93
- let prev = "GENESIS";
94
- for (const r of this.chain) {
95
- const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
96
- if (expected !== r.hash || r.prevHash !== prev)
97
- return false;
98
- prev = r.hash;
99
- }
100
- return true;
101
- }
102
- };
43
+
44
+ // ../firewall/dist/core.js
45
+ import { randomUUID } from "node:crypto";
103
46
 
104
47
  // ../firewall/dist/evaluate.js
105
48
  function fold(s) {
@@ -176,7 +119,6 @@ function evaluate(mandate, req, spentSoFar) {
176
119
  }
177
120
 
178
121
  // ../firewall/dist/core.js
179
- import { randomUUID } from "node:crypto";
180
122
  function canonInvoice(ref) {
181
123
  if (typeof ref !== "string")
182
124
  return "";
@@ -224,6 +166,35 @@ var DevFidacyCore = class {
224
166
  this.mandate = opts.mandate;
225
167
  this.store = new FileAuditStore(opts.auditLogPath ?? "./fidacy-audit.log");
226
168
  this.onDecision = opts.onDecision;
169
+ this.rehydrate();
170
+ }
171
+ /**
172
+ * Rebuild the in-memory decision state from the persisted audit log, so a process
173
+ * restart cannot re-open a claimed invoice (BEC dedup) or reset the maxTotal
174
+ * counter. One O(records) pass at boot over the already-loaded chain:
175
+ * - claimedInvoices: every ALLOW that carried an invoiceRef, regardless of age —
176
+ * a paid invoice stays paid.
177
+ * - spent: ALLOW amounts whose timestamp falls inside the CURRENT mandate window
178
+ * and whose currency matches the mandate (the cap is defined per window/currency).
179
+ * Records from older versions lack the rehydration fields and are skipped.
180
+ */
181
+ rehydrate() {
182
+ const notBefore = Date.parse(this.mandate.window.notBefore);
183
+ const notAfter = Date.parse(this.mandate.window.notAfter);
184
+ for (const r of this.store.records()) {
185
+ if (r.status !== "ALLOW")
186
+ continue;
187
+ if (r.invoiceRef)
188
+ this.claimedInvoices.add(`${r.subject}|${r.invoiceRef}`);
189
+ if (typeof r.amount !== "number" || !Number.isFinite(r.amount))
190
+ continue;
191
+ if (r.currency !== this.mandate.allow.currency)
192
+ continue;
193
+ const t = Date.parse(r.ts);
194
+ if (!Number.isFinite(t) || t < notBefore || t > notAfter)
195
+ continue;
196
+ this.spent += r.amount;
197
+ }
227
198
  }
228
199
  async getMandate() {
229
200
  return this.mandate;
@@ -310,6 +281,96 @@ var HttpFidacyCore = class {
310
281
  }
311
282
  };
312
283
 
284
+ // ../firewall/dist/audit-store.js
285
+ var FileAuditStore = class {
286
+ path;
287
+ chain = [];
288
+ constructor(path) {
289
+ this.path = path;
290
+ this.load();
291
+ }
292
+ load() {
293
+ if (!fs.existsSync(this.path))
294
+ return;
295
+ try {
296
+ const raw = fs.readFileSync(this.path, "utf8");
297
+ const lines = raw.split("\n");
298
+ const parsed = [];
299
+ let torn = false;
300
+ for (let i = 0; i < lines.length; i++) {
301
+ if (!lines[i].trim())
302
+ continue;
303
+ try {
304
+ parsed.push(JSON.parse(lines[i]));
305
+ } catch {
306
+ if (lines.slice(i + 1).some((l) => l.trim()))
307
+ throw new Error(`unparseable record at line ${i + 1}`);
308
+ torn = true;
309
+ break;
310
+ }
311
+ }
312
+ this.chain = parsed;
313
+ if (!this.intact())
314
+ throw new Error("audit chain integrity broken");
315
+ if (torn) {
316
+ const clean = parsed.map((r) => JSON.stringify(r)).join("\n") + (parsed.length ? "\n" : "");
317
+ fs.writeFileSync(this.path, clean);
318
+ console.error(`[fidacy] audit log at ${this.path} had a torn trailing line (crash during append?); salvaged ${parsed.length} intact records and truncated the tail.`);
319
+ }
320
+ } catch (err) {
321
+ this.chain = [];
322
+ try {
323
+ const quarantine = `${this.path}.corrupt-${Date.now()}`;
324
+ fs.renameSync(this.path, quarantine);
325
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
326
+ } catch {
327
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
328
+ }
329
+ }
330
+ }
331
+ head() {
332
+ return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
333
+ }
334
+ append(decision) {
335
+ const prevHash = this.head();
336
+ const seq = this.chain.length;
337
+ const ts = decision.ts;
338
+ const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
339
+ const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
340
+ const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
341
+ if (decision.status === "ALLOW") {
342
+ const req = decision.request;
343
+ if (typeof req?.amount === "number" && Number.isFinite(req.amount))
344
+ record2.amount = req.amount;
345
+ if (typeof req?.currency === "string")
346
+ record2.currency = req.currency;
347
+ const invoice = canonInvoice(req?.invoiceRef);
348
+ if (invoice)
349
+ record2.invoiceRef = invoice;
350
+ }
351
+ fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
352
+ this.chain.push(record2);
353
+ return record2;
354
+ }
355
+ find(decisionId) {
356
+ return this.chain.find((r) => r.decisionId === decisionId);
357
+ }
358
+ /** The full loaded chain, oldest first. Read-only; used to rehydrate core state at boot. */
359
+ records() {
360
+ return this.chain;
361
+ }
362
+ intact() {
363
+ let prev = "GENESIS";
364
+ for (const r of this.chain) {
365
+ const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
366
+ if (expected !== r.hash || r.prevHash !== prev)
367
+ return false;
368
+ prev = r.hash;
369
+ }
370
+ return true;
371
+ }
372
+ };
373
+
313
374
  // src/config.ts
314
375
  import { randomUUID as randomUUID2 } from "node:crypto";
315
376
  import { homedir } from "node:os";
@@ -391,7 +452,7 @@ function resolveMandateRules(cfg) {
391
452
  }
392
453
 
393
454
  // src/telemetry.ts
394
- var CLIENT_VERSION = true ? "0.1.11" : "dev";
455
+ var CLIENT_VERSION = true ? "0.1.13" : "dev";
395
456
  var currentShell = "mcp";
396
457
  function telemetryEnabled() {
397
458
  const v = (process.env.FIDACY_DISABLE_TELEMETRY ?? "").trim().toLowerCase();
@@ -615,7 +676,7 @@ async function postOnce(fetchImpl, url, headers, payload, timeoutMs) {
615
676
  }
616
677
 
617
678
  // src/provision.ts
618
- var CLIENT_VERSION2 = true ? "0.1.11" : "dev";
679
+ var CLIENT_VERSION2 = true ? "0.1.13" : "dev";
619
680
  function provisionEnabled() {
620
681
  const v = (process.env.FIDACY_DISABLE_PROVISION ?? "").trim().toLowerCase();
621
682
  return !(v === "1" || v === "true" || v === "yes");
@@ -715,7 +776,8 @@ function weekOneBootNudge(upgradeToolName) {
715
776
  var state = ensureState();
716
777
  var core = makeCore();
717
778
  var subject = process.env.FIDACY_SUBJECT ?? "agent:demo";
718
- var server = new McpServer({ name: "fidacy", version: "0.1.0" });
779
+ var SERVER_VERSION = true ? "0.1.13" : "dev";
780
+ var server = new McpServer({ name: "fidacy", version: SERVER_VERSION });
719
781
  server.registerTool(
720
782
  "request_payment",
721
783
  {
package/dist/lib.js CHANGED
@@ -74,151 +74,9 @@ function verifyGrant(publicKeyPem2, grant, expected) {
74
74
 
75
75
  // ../firewall/dist/audit-store.js
76
76
  import fs from "node:fs";
77
- var FileAuditStore = class {
78
- path;
79
- chain = [];
80
- constructor(path) {
81
- this.path = path;
82
- this.load();
83
- }
84
- load() {
85
- if (!fs.existsSync(this.path))
86
- return;
87
- try {
88
- const raw = fs.readFileSync(this.path, "utf8");
89
- const parsed = [];
90
- for (const line of raw.split("\n")) {
91
- if (!line.trim())
92
- continue;
93
- parsed.push(JSON.parse(line));
94
- }
95
- this.chain = parsed;
96
- if (!this.intact())
97
- throw new Error("audit chain integrity broken");
98
- } catch (err) {
99
- this.chain = [];
100
- try {
101
- const quarantine = `${this.path}.corrupt-${Date.now()}`;
102
- fs.renameSync(this.path, quarantine);
103
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
104
- } catch {
105
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
106
- }
107
- }
108
- }
109
- head() {
110
- return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
111
- }
112
- append(decision) {
113
- const prevHash = this.head();
114
- const seq = this.chain.length;
115
- const ts = decision.ts;
116
- const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
117
- const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
118
- const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
119
- fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
120
- this.chain.push(record2);
121
- return record2;
122
- }
123
- find(decisionId) {
124
- return this.chain.find((r) => r.decisionId === decisionId);
125
- }
126
- intact() {
127
- let prev = "GENESIS";
128
- for (const r of this.chain) {
129
- const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
130
- if (expected !== r.hash || r.prevHash !== prev)
131
- return false;
132
- prev = r.hash;
133
- }
134
- return true;
135
- }
136
- };
137
77
 
138
- // ../firewall/dist/executor.js
139
- import { createPublicKey } from "node:crypto";
78
+ // ../firewall/dist/core.js
140
79
  import { randomUUID } from "node:crypto";
141
- var ReferenceRail = class {
142
- settlements = [];
143
- async execute(req) {
144
- const railRef = "ref_" + randomUUID();
145
- this.settlements.push({ ...req, railRef, at: (/* @__PURE__ */ new Date()).toISOString() });
146
- return { railRef };
147
- }
148
- };
149
- function httpRedeem(coreUrl, apiKey) {
150
- const base = String(coreUrl ?? "").replace(/\/+$/, "");
151
- const u = new URL(base);
152
- if (u.protocol !== "https:" && !(u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1"))) {
153
- throw new Error("core URL must be https:// (the API key is sent to it)");
154
- }
155
- return async (grant) => {
156
- const res = await fetch(`${base}/v1/grant/redeem`, {
157
- method: "POST",
158
- headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
159
- body: JSON.stringify({ grant })
160
- });
161
- if (res.status === 200)
162
- return true;
163
- if (res.status === 409)
164
- return false;
165
- throw new Error(`redeem -> ${res.status}`);
166
- };
167
- }
168
- var GrantEnforcingExecutor = class {
169
- rail;
170
- used = /* @__PURE__ */ new Set();
171
- pub;
172
- redeem;
173
- constructor(publicKeyPem2, rail, opts) {
174
- this.rail = rail;
175
- this.pub = createPublicKey(publicKeyPem2);
176
- this.redeem = opts?.redeem;
177
- }
178
- async execute(req, grant) {
179
- if (!grant)
180
- return { status: "REFUSED", reason: "missing_grant" };
181
- const dot = grant.indexOf(".");
182
- if (dot < 0)
183
- return { status: "REFUSED", reason: "malformed_grant" };
184
- const body = grant.slice(0, dot);
185
- const sig = grant.slice(dot + 1);
186
- if (!verify(this.pub, body, sig))
187
- return { status: "REFUSED", reason: "invalid_signature" };
188
- let p;
189
- try {
190
- p = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
191
- } catch {
192
- return { status: "REFUSED", reason: "undecodable_grant" };
193
- }
194
- if (Date.now() > p.exp)
195
- return { status: "REFUSED", reason: "grant_expired" };
196
- if (this.used.has(p.decisionId))
197
- return { status: "REFUSED", reason: "grant_replayed" };
198
- if (p.payee !== req.payee)
199
- return { status: "REFUSED", reason: "payee_mismatch" };
200
- if (p.amount !== req.amount)
201
- return { status: "REFUSED", reason: "amount_mismatch" };
202
- if (p.currency !== req.currency)
203
- return { status: "REFUSED", reason: "currency_mismatch" };
204
- if (req.invoiceRef != null && req.invoiceRef !== (p.invoiceRef ?? null)) {
205
- return { status: "REFUSED", reason: "invoice_mismatch" };
206
- }
207
- if (this.redeem) {
208
- let first;
209
- try {
210
- first = await this.redeem(grant);
211
- } catch {
212
- return { status: "REFUSED", reason: "redeem_unavailable" };
213
- }
214
- if (!first)
215
- return { status: "REFUSED", reason: "grant_replayed" };
216
- }
217
- this.used.add(p.decisionId);
218
- const { railRef } = await this.rail.execute(req);
219
- return { status: "EXECUTED", railRef, decisionId: p.decisionId };
220
- }
221
- };
222
80
 
223
81
  // ../firewall/dist/evaluate.js
224
82
  function fold(s) {
@@ -295,7 +153,6 @@ function evaluate(mandate, req, spentSoFar) {
295
153
  }
296
154
 
297
155
  // ../firewall/dist/core.js
298
- import { randomUUID as randomUUID2 } from "node:crypto";
299
156
  function canonInvoice(ref) {
300
157
  if (typeof ref !== "string")
301
158
  return "";
@@ -343,12 +200,41 @@ var DevFidacyCore = class {
343
200
  this.mandate = opts.mandate;
344
201
  this.store = new FileAuditStore(opts.auditLogPath ?? "./fidacy-audit.log");
345
202
  this.onDecision = opts.onDecision;
203
+ this.rehydrate();
204
+ }
205
+ /**
206
+ * Rebuild the in-memory decision state from the persisted audit log, so a process
207
+ * restart cannot re-open a claimed invoice (BEC dedup) or reset the maxTotal
208
+ * counter. One O(records) pass at boot over the already-loaded chain:
209
+ * - claimedInvoices: every ALLOW that carried an invoiceRef, regardless of age —
210
+ * a paid invoice stays paid.
211
+ * - spent: ALLOW amounts whose timestamp falls inside the CURRENT mandate window
212
+ * and whose currency matches the mandate (the cap is defined per window/currency).
213
+ * Records from older versions lack the rehydration fields and are skipped.
214
+ */
215
+ rehydrate() {
216
+ const notBefore = Date.parse(this.mandate.window.notBefore);
217
+ const notAfter = Date.parse(this.mandate.window.notAfter);
218
+ for (const r of this.store.records()) {
219
+ if (r.status !== "ALLOW")
220
+ continue;
221
+ if (r.invoiceRef)
222
+ this.claimedInvoices.add(`${r.subject}|${r.invoiceRef}`);
223
+ if (typeof r.amount !== "number" || !Number.isFinite(r.amount))
224
+ continue;
225
+ if (r.currency !== this.mandate.allow.currency)
226
+ continue;
227
+ const t = Date.parse(r.ts);
228
+ if (!Number.isFinite(t) || t < notBefore || t > notAfter)
229
+ continue;
230
+ this.spent += r.amount;
231
+ }
346
232
  }
347
233
  async getMandate() {
348
234
  return this.mandate;
349
235
  }
350
236
  async decide(req, subject) {
351
- const decisionId = randomUUID2();
237
+ const decisionId = randomUUID();
352
238
  const ts = (/* @__PURE__ */ new Date()).toISOString();
353
239
  const invalid = validateRequest(req);
354
240
  if (invalid) {
@@ -429,6 +315,181 @@ var HttpFidacyCore = class {
429
315
  }
430
316
  };
431
317
 
318
+ // ../firewall/dist/audit-store.js
319
+ var FileAuditStore = class {
320
+ path;
321
+ chain = [];
322
+ constructor(path) {
323
+ this.path = path;
324
+ this.load();
325
+ }
326
+ load() {
327
+ if (!fs.existsSync(this.path))
328
+ return;
329
+ try {
330
+ const raw = fs.readFileSync(this.path, "utf8");
331
+ const lines = raw.split("\n");
332
+ const parsed = [];
333
+ let torn = false;
334
+ for (let i = 0; i < lines.length; i++) {
335
+ if (!lines[i].trim())
336
+ continue;
337
+ try {
338
+ parsed.push(JSON.parse(lines[i]));
339
+ } catch {
340
+ if (lines.slice(i + 1).some((l) => l.trim()))
341
+ throw new Error(`unparseable record at line ${i + 1}`);
342
+ torn = true;
343
+ break;
344
+ }
345
+ }
346
+ this.chain = parsed;
347
+ if (!this.intact())
348
+ throw new Error("audit chain integrity broken");
349
+ if (torn) {
350
+ const clean = parsed.map((r) => JSON.stringify(r)).join("\n") + (parsed.length ? "\n" : "");
351
+ fs.writeFileSync(this.path, clean);
352
+ console.error(`[fidacy] audit log at ${this.path} had a torn trailing line (crash during append?); salvaged ${parsed.length} intact records and truncated the tail.`);
353
+ }
354
+ } catch (err) {
355
+ this.chain = [];
356
+ try {
357
+ const quarantine = `${this.path}.corrupt-${Date.now()}`;
358
+ fs.renameSync(this.path, quarantine);
359
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
360
+ } catch {
361
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
362
+ }
363
+ }
364
+ }
365
+ head() {
366
+ return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
367
+ }
368
+ append(decision) {
369
+ const prevHash = this.head();
370
+ const seq = this.chain.length;
371
+ const ts = decision.ts;
372
+ const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
373
+ const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
374
+ const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
375
+ if (decision.status === "ALLOW") {
376
+ const req = decision.request;
377
+ if (typeof req?.amount === "number" && Number.isFinite(req.amount))
378
+ record2.amount = req.amount;
379
+ if (typeof req?.currency === "string")
380
+ record2.currency = req.currency;
381
+ const invoice = canonInvoice(req?.invoiceRef);
382
+ if (invoice)
383
+ record2.invoiceRef = invoice;
384
+ }
385
+ fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
386
+ this.chain.push(record2);
387
+ return record2;
388
+ }
389
+ find(decisionId) {
390
+ return this.chain.find((r) => r.decisionId === decisionId);
391
+ }
392
+ /** The full loaded chain, oldest first. Read-only; used to rehydrate core state at boot. */
393
+ records() {
394
+ return this.chain;
395
+ }
396
+ intact() {
397
+ let prev = "GENESIS";
398
+ for (const r of this.chain) {
399
+ const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
400
+ if (expected !== r.hash || r.prevHash !== prev)
401
+ return false;
402
+ prev = r.hash;
403
+ }
404
+ return true;
405
+ }
406
+ };
407
+
408
+ // ../firewall/dist/executor.js
409
+ import { createPublicKey } from "node:crypto";
410
+ import { randomUUID as randomUUID2 } from "node:crypto";
411
+ var ReferenceRail = class {
412
+ settlements = [];
413
+ async execute(req) {
414
+ const railRef = "ref_" + randomUUID2();
415
+ this.settlements.push({ ...req, railRef, at: (/* @__PURE__ */ new Date()).toISOString() });
416
+ return { railRef };
417
+ }
418
+ };
419
+ function httpRedeem(coreUrl, apiKey) {
420
+ const base = String(coreUrl ?? "").replace(/\/+$/, "");
421
+ const u = new URL(base);
422
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1"))) {
423
+ throw new Error("core URL must be https:// (the API key is sent to it)");
424
+ }
425
+ return async (grant) => {
426
+ const res = await fetch(`${base}/v1/grant/redeem`, {
427
+ method: "POST",
428
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
429
+ body: JSON.stringify({ grant })
430
+ });
431
+ if (res.status === 200)
432
+ return true;
433
+ if (res.status === 409)
434
+ return false;
435
+ throw new Error(`redeem -> ${res.status}`);
436
+ };
437
+ }
438
+ var GrantEnforcingExecutor = class {
439
+ rail;
440
+ used = /* @__PURE__ */ new Set();
441
+ pub;
442
+ redeem;
443
+ constructor(publicKeyPem2, rail, opts) {
444
+ this.rail = rail;
445
+ this.pub = createPublicKey(publicKeyPem2);
446
+ this.redeem = opts?.redeem;
447
+ }
448
+ async execute(req, grant) {
449
+ if (!grant)
450
+ return { status: "REFUSED", reason: "missing_grant" };
451
+ const dot = grant.indexOf(".");
452
+ if (dot < 0)
453
+ return { status: "REFUSED", reason: "malformed_grant" };
454
+ const body = grant.slice(0, dot);
455
+ const sig = grant.slice(dot + 1);
456
+ if (!verify(this.pub, body, sig))
457
+ return { status: "REFUSED", reason: "invalid_signature" };
458
+ let p;
459
+ try {
460
+ p = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
461
+ } catch {
462
+ return { status: "REFUSED", reason: "undecodable_grant" };
463
+ }
464
+ if (Date.now() > p.exp)
465
+ return { status: "REFUSED", reason: "grant_expired" };
466
+ if (this.used.has(p.decisionId))
467
+ return { status: "REFUSED", reason: "grant_replayed" };
468
+ if (p.payee !== req.payee)
469
+ return { status: "REFUSED", reason: "payee_mismatch" };
470
+ if (p.amount !== req.amount)
471
+ return { status: "REFUSED", reason: "amount_mismatch" };
472
+ if (p.currency !== req.currency)
473
+ return { status: "REFUSED", reason: "currency_mismatch" };
474
+ if (req.invoiceRef != null && req.invoiceRef !== (p.invoiceRef ?? null)) {
475
+ return { status: "REFUSED", reason: "invoice_mismatch" };
476
+ }
477
+ if (this.redeem) {
478
+ let first;
479
+ try {
480
+ first = await this.redeem(grant);
481
+ } catch {
482
+ return { status: "REFUSED", reason: "redeem_unavailable" };
483
+ }
484
+ if (!first)
485
+ return { status: "REFUSED", reason: "grant_replayed" };
486
+ }
487
+ this.used.add(p.decisionId);
488
+ const { railRef } = await this.rail.execute(req);
489
+ return { status: "EXECUTED", railRef, decisionId: p.decisionId };
490
+ }
491
+ };
492
+
432
493
  // src/config.ts
433
494
  import { randomUUID as randomUUID3 } from "node:crypto";
434
495
  import { homedir } from "node:os";
@@ -510,7 +571,7 @@ function resolveMandateRules(cfg) {
510
571
  }
511
572
 
512
573
  // src/telemetry.ts
513
- var CLIENT_VERSION = true ? "0.1.11" : "dev";
574
+ var CLIENT_VERSION = true ? "0.1.13" : "dev";
514
575
  var currentShell = "mcp";
515
576
  function setTelemetryShell(shell) {
516
577
  currentShell = shell;
@@ -633,7 +694,7 @@ function requestUpgrade() {
633
694
  }
634
695
 
635
696
  // src/provision.ts
636
- var CLIENT_VERSION2 = true ? "0.1.11" : "dev";
697
+ var CLIENT_VERSION2 = true ? "0.1.13" : "dev";
637
698
  function provisionEnabled() {
638
699
  const v = (process.env.FIDACY_DISABLE_PROVISION ?? "").trim().toLowerCase();
639
700
  return !(v === "1" || v === "true" || v === "yes");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fidacy/mcp",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Fidacy action firewall for AI agents. Mandate-gated payment authorization as an MCP server.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://fidacy.com",