@fidacy/mcp 0.1.10 → 0.1.12
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 +27 -0
- package/dist/core.js +129 -63
- package/dist/index.js +192 -71
- package/dist/lib.js +270 -150
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -85,3 +85,30 @@ semantic versioning.
|
|
|
85
85
|
- Onboarding: first-run `~/.fidacy/config.json` now writes the mandate template
|
|
86
86
|
explicitly (payees/categories/currency/caps) — adding a trusted payee is a
|
|
87
87
|
visible one-line edit; friendlier first-boot message (no more "dev mode" scare).
|
|
88
|
+
|
|
89
|
+
## 0.1.11 — 2026-07-02
|
|
90
|
+
|
|
91
|
+
- Upgrade micro-triggers, elegant by doctrine: fire only at value-proven moments
|
|
92
|
+
(first block, first duplicate-invoice/BEC catch, 10th gated action, one week
|
|
93
|
+
protected), each ONCE per install ever (persisted in config.json) — the free
|
|
94
|
+
tier stays fully usable in silence. The 0.1.10 every-DENY nudge is replaced.
|
|
95
|
+
- verify_mandate/get_audit_proof gain one informational closing line each;
|
|
96
|
+
assess_action's missing-key error now offers the free-key path.
|
|
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.
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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";
|
|
@@ -337,7 +398,12 @@ function readConfig() {
|
|
|
337
398
|
anon_id: raw.anon_id,
|
|
338
399
|
tier: raw.tier === "paid" ? "paid" : "free",
|
|
339
400
|
api_key: typeof raw.api_key === "string" ? raw.api_key : null,
|
|
340
|
-
mandate: raw.mandate
|
|
401
|
+
mandate: raw.mandate,
|
|
402
|
+
// Install-state passthrough: dropping these on a read→write cycle would
|
|
403
|
+
// reset every once-per-install nudge into an every-time nag.
|
|
404
|
+
created_at: typeof raw.created_at === "string" ? raw.created_at : void 0,
|
|
405
|
+
nudges: raw.nudges && typeof raw.nudges === "object" ? raw.nudges : void 0,
|
|
406
|
+
decisions_count: typeof raw.decisions_count === "number" ? raw.decisions_count : void 0
|
|
341
407
|
};
|
|
342
408
|
} catch {
|
|
343
409
|
return null;
|
|
@@ -357,7 +423,7 @@ function resolveMandateRules(cfg) {
|
|
|
357
423
|
}
|
|
358
424
|
|
|
359
425
|
// src/telemetry.ts
|
|
360
|
-
var CLIENT_VERSION = true ? "0.1.
|
|
426
|
+
var CLIENT_VERSION = true ? "0.1.12" : "dev";
|
|
361
427
|
var currentShell = "mcp";
|
|
362
428
|
function telemetryEnabled() {
|
|
363
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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";
|
|
@@ -345,7 +406,12 @@ function readConfig() {
|
|
|
345
406
|
anon_id: raw.anon_id,
|
|
346
407
|
tier: raw.tier === "paid" ? "paid" : "free",
|
|
347
408
|
api_key: typeof raw.api_key === "string" ? raw.api_key : null,
|
|
348
|
-
mandate: raw.mandate
|
|
409
|
+
mandate: raw.mandate,
|
|
410
|
+
// Install-state passthrough: dropping these on a read→write cycle would
|
|
411
|
+
// reset every once-per-install nudge into an every-time nag.
|
|
412
|
+
created_at: typeof raw.created_at === "string" ? raw.created_at : void 0,
|
|
413
|
+
nudges: raw.nudges && typeof raw.nudges === "object" ? raw.nudges : void 0,
|
|
414
|
+
decisions_count: typeof raw.decisions_count === "number" ? raw.decisions_count : void 0
|
|
349
415
|
};
|
|
350
416
|
} catch {
|
|
351
417
|
return null;
|
|
@@ -363,7 +429,8 @@ function ensureState() {
|
|
|
363
429
|
anon_id: randomUUID2(),
|
|
364
430
|
tier: "free",
|
|
365
431
|
api_key: null,
|
|
366
|
-
mandate: { payees: [], categories: ["*"], currency: "USD", perTxMax: 2500, maxTotal: 1e4 }
|
|
432
|
+
mandate: { payees: [], categories: ["*"], currency: "USD", perTxMax: 2500, maxTotal: 1e4 },
|
|
433
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
367
434
|
};
|
|
368
435
|
try {
|
|
369
436
|
writeConfig(config);
|
|
@@ -385,7 +452,7 @@ function resolveMandateRules(cfg) {
|
|
|
385
452
|
}
|
|
386
453
|
|
|
387
454
|
// src/telemetry.ts
|
|
388
|
-
var CLIENT_VERSION = true ? "0.1.
|
|
455
|
+
var CLIENT_VERSION = true ? "0.1.12" : "dev";
|
|
389
456
|
var currentShell = "mcp";
|
|
390
457
|
function telemetryEnabled() {
|
|
391
458
|
const v = (process.env.FIDACY_DISABLE_TELEMETRY ?? "").trim().toLowerCase();
|
|
@@ -609,7 +676,7 @@ async function postOnce(fetchImpl, url, headers, payload, timeoutMs) {
|
|
|
609
676
|
}
|
|
610
677
|
|
|
611
678
|
// src/provision.ts
|
|
612
|
-
var CLIENT_VERSION2 = true ? "0.1.
|
|
679
|
+
var CLIENT_VERSION2 = true ? "0.1.12" : "dev";
|
|
613
680
|
function provisionEnabled() {
|
|
614
681
|
const v = (process.env.FIDACY_DISABLE_PROVISION ?? "").trim().toLowerCase();
|
|
615
682
|
return !(v === "1" || v === "true" || v === "yes");
|
|
@@ -656,6 +723,55 @@ function requestUpgrade() {
|
|
|
656
723
|
return { url: upgradeUrl(), anonId: anonId2 };
|
|
657
724
|
}
|
|
658
725
|
|
|
726
|
+
// src/nudges.ts
|
|
727
|
+
function nudgeOnce(key) {
|
|
728
|
+
const cfg = readConfig();
|
|
729
|
+
if (!cfg) return false;
|
|
730
|
+
if (cfg.nudges?.[key]) return false;
|
|
731
|
+
cfg.nudges = { ...cfg.nudges ?? {}, [key]: true };
|
|
732
|
+
try {
|
|
733
|
+
writeConfig(cfg);
|
|
734
|
+
} catch {
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
function bumpDecisionCount() {
|
|
740
|
+
const cfg = readConfig();
|
|
741
|
+
if (!cfg) return 0;
|
|
742
|
+
const n = (cfg.decisions_count ?? 0) + 1;
|
|
743
|
+
cfg.decisions_count = n;
|
|
744
|
+
try {
|
|
745
|
+
writeConfig(cfg);
|
|
746
|
+
} catch {
|
|
747
|
+
}
|
|
748
|
+
return n;
|
|
749
|
+
}
|
|
750
|
+
function installAgeDays() {
|
|
751
|
+
const t = Date.parse(readConfig()?.created_at ?? "");
|
|
752
|
+
if (Number.isNaN(t)) return 0;
|
|
753
|
+
return Math.floor((Date.now() - t) / 864e5);
|
|
754
|
+
}
|
|
755
|
+
function decisionNudge(status, violatedRule, upgradeToolName) {
|
|
756
|
+
const count = bumpDecisionCount();
|
|
757
|
+
if (status === "DENY" && violatedRule?.startsWith("duplicate_invoice") && nudgeOnce("bec_catch")) {
|
|
758
|
+
return `That was a duplicate-invoice (BEC) catch \u2014 the #1 payment fraud. On a free account every block like this is server-signed and anchored to Bitcoin: court-grade evidence. Call the ${upgradeToolName} tool to start.`;
|
|
759
|
+
}
|
|
760
|
+
if (status === "DENY" && nudgeOnce("first_deny")) {
|
|
761
|
+
return `Fidacy just blocked this for you \u2014 for server-signed, anchored proof of every block (free account, local history migrates), call the ${upgradeToolName} tool.`;
|
|
762
|
+
}
|
|
763
|
+
if (count === 10 && nudgeOnce("milestone_10")) {
|
|
764
|
+
return `That's 10 actions gated on this install. For a signed, Bitcoin-anchored history of all of them (free account), call the ${upgradeToolName} tool.`;
|
|
765
|
+
}
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
function weekOneBootNudge(upgradeToolName) {
|
|
769
|
+
if (installAgeDays() >= 7 && nudgeOnce("week_1")) {
|
|
770
|
+
return ` One week protected \u2014 anchor your history to Bitcoin with a free account: the ${upgradeToolName} tool starts it.`;
|
|
771
|
+
}
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
|
|
659
775
|
// src/index.ts
|
|
660
776
|
var state = ensureState();
|
|
661
777
|
var core = makeCore();
|
|
@@ -689,8 +805,10 @@ server.registerTool(
|
|
|
689
805
|
const req = args;
|
|
690
806
|
const d = await core.decide(req, subject);
|
|
691
807
|
const out = { status: d.status, decisionId: d.decisionId, grant: d.grant, violatedRule: d.violatedRule };
|
|
692
|
-
const
|
|
693
|
-
${d.grant}` : `DENY (decision ${d.decisionId}). Rule violated: ${d.violatedRule}. No grant issued, this payment cannot proceed. The denial itself is recorded in the tamper-evident, hash-chained audit: call get_audit_proof with decisionId ${d.decisionId} for the proof of what was blocked
|
|
808
|
+
const base = d.status === "ALLOW" ? `ALLOW (decision ${d.decisionId})${req.invoiceRef ? ` for invoice ${req.invoiceRef}` : ""}. To settle, call execute_payment with the SAME payee, amount, currency, and idempotencyKey, and set "grant" to EXACTLY this signed value:
|
|
809
|
+
${d.grant}` : `DENY (decision ${d.decisionId}). Rule violated: ${d.violatedRule}. No grant issued, this payment cannot proceed. The denial itself is recorded in the tamper-evident, hash-chained audit: call get_audit_proof with decisionId ${d.decisionId} for the proof of what was blocked.`;
|
|
810
|
+
const nudge = decisionNudge(d.status, d.violatedRule, "upgrade");
|
|
811
|
+
const human = nudge ? `${base} ${nudge}` : base;
|
|
694
812
|
return { content: [{ type: "text", text: human }], structuredContent: out };
|
|
695
813
|
}
|
|
696
814
|
);
|
|
@@ -705,7 +823,8 @@ server.registerTool(
|
|
|
705
823
|
async () => {
|
|
706
824
|
const m = await core.getMandate(subject);
|
|
707
825
|
const payload = { mandate: m, fidacyPublicKey: core.publicKey() };
|
|
708
|
-
|
|
826
|
+
const text = JSON.stringify(payload, null, 2) + "\n\nThis mandate is local (free tier). Hosted mandates add team governance and server-signed verdicts \u2014 the upgrade tool starts a free account.";
|
|
827
|
+
return { content: [{ type: "text", text }], structuredContent: payload };
|
|
709
828
|
}
|
|
710
829
|
);
|
|
711
830
|
server.registerTool(
|
|
@@ -719,7 +838,8 @@ server.registerTool(
|
|
|
719
838
|
async ({ decisionId }) => {
|
|
720
839
|
const proof = await core.getProof(decisionId);
|
|
721
840
|
if (!proof) return { content: [{ type: "text", text: `No proof found for ${decisionId}` }], isError: true };
|
|
722
|
-
|
|
841
|
+
const text = JSON.stringify(proof, null, 2) + "\n\nThis proof lives on this machine. On a free hosted account the chain is anchored to Bitcoin \u2014 verifiable by anyone, forever, even against Fidacy (the upgrade tool starts it).";
|
|
842
|
+
return { content: [{ type: "text", text }], structuredContent: proof };
|
|
723
843
|
}
|
|
724
844
|
);
|
|
725
845
|
server.registerTool(
|
|
@@ -745,7 +865,7 @@ server.registerTool(
|
|
|
745
865
|
content: [
|
|
746
866
|
{
|
|
747
867
|
type: "text",
|
|
748
|
-
text: "assess_action
|
|
868
|
+
text: "assess_action needs an engine key \u2014 free in about a minute: call the upgrade tool (your usage history migrates), or set FIDACY_ENGINE_API_KEY if you already have one."
|
|
749
869
|
}
|
|
750
870
|
],
|
|
751
871
|
isError: true
|
|
@@ -805,7 +925,8 @@ async function main() {
|
|
|
805
925
|
void autoProvision();
|
|
806
926
|
const verdictHint = process.env.FIDACY_ENGINE_API_KEY ? "" : " Set FIDACY_ENGINE_API_KEY to enable verdicts (assess_action).";
|
|
807
927
|
const tierHint = state.firstRun ? " First run: free local tier active (deny-unknown payee + cap). Add trusted payees in ~/.fidacy/config.json." : ` Tier: ${state.config.tier} (local-first).`;
|
|
808
|
-
|
|
928
|
+
const weekNudge = weekOneBootNudge("upgrade") ?? "";
|
|
929
|
+
console.error("[fidacy] @fidacy/mcp ready on stdio: signed verdict + payment firewall." + tierHint + verdictHint + weekNudge);
|
|
809
930
|
}
|
|
810
931
|
main().catch((e) => {
|
|
811
932
|
console.error("[fidacy] fatal:", e);
|
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/
|
|
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 =
|
|
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";
|
|
@@ -464,7 +525,12 @@ function readConfig() {
|
|
|
464
525
|
anon_id: raw.anon_id,
|
|
465
526
|
tier: raw.tier === "paid" ? "paid" : "free",
|
|
466
527
|
api_key: typeof raw.api_key === "string" ? raw.api_key : null,
|
|
467
|
-
mandate: raw.mandate
|
|
528
|
+
mandate: raw.mandate,
|
|
529
|
+
// Install-state passthrough: dropping these on a read→write cycle would
|
|
530
|
+
// reset every once-per-install nudge into an every-time nag.
|
|
531
|
+
created_at: typeof raw.created_at === "string" ? raw.created_at : void 0,
|
|
532
|
+
nudges: raw.nudges && typeof raw.nudges === "object" ? raw.nudges : void 0,
|
|
533
|
+
decisions_count: typeof raw.decisions_count === "number" ? raw.decisions_count : void 0
|
|
468
534
|
};
|
|
469
535
|
} catch {
|
|
470
536
|
return null;
|
|
@@ -482,7 +548,8 @@ function ensureState() {
|
|
|
482
548
|
anon_id: randomUUID3(),
|
|
483
549
|
tier: "free",
|
|
484
550
|
api_key: null,
|
|
485
|
-
mandate: { payees: [], categories: ["*"], currency: "USD", perTxMax: 2500, maxTotal: 1e4 }
|
|
551
|
+
mandate: { payees: [], categories: ["*"], currency: "USD", perTxMax: 2500, maxTotal: 1e4 },
|
|
552
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
486
553
|
};
|
|
487
554
|
try {
|
|
488
555
|
writeConfig(config);
|
|
@@ -504,7 +571,7 @@ function resolveMandateRules(cfg) {
|
|
|
504
571
|
}
|
|
505
572
|
|
|
506
573
|
// src/telemetry.ts
|
|
507
|
-
var CLIENT_VERSION = true ? "0.1.
|
|
574
|
+
var CLIENT_VERSION = true ? "0.1.12" : "dev";
|
|
508
575
|
var currentShell = "mcp";
|
|
509
576
|
function setTelemetryShell(shell) {
|
|
510
577
|
currentShell = shell;
|
|
@@ -627,7 +694,7 @@ function requestUpgrade() {
|
|
|
627
694
|
}
|
|
628
695
|
|
|
629
696
|
// src/provision.ts
|
|
630
|
-
var CLIENT_VERSION2 = true ? "0.1.
|
|
697
|
+
var CLIENT_VERSION2 = true ? "0.1.12" : "dev";
|
|
631
698
|
function provisionEnabled() {
|
|
632
699
|
const v = (process.env.FIDACY_DISABLE_PROVISION ?? "").trim().toLowerCase();
|
|
633
700
|
return !(v === "1" || v === "true" || v === "yes");
|
|
@@ -661,6 +728,55 @@ async function autoProvision() {
|
|
|
661
728
|
return "failed";
|
|
662
729
|
}
|
|
663
730
|
}
|
|
731
|
+
|
|
732
|
+
// src/nudges.ts
|
|
733
|
+
function nudgeOnce(key) {
|
|
734
|
+
const cfg = readConfig();
|
|
735
|
+
if (!cfg) return false;
|
|
736
|
+
if (cfg.nudges?.[key]) return false;
|
|
737
|
+
cfg.nudges = { ...cfg.nudges ?? {}, [key]: true };
|
|
738
|
+
try {
|
|
739
|
+
writeConfig(cfg);
|
|
740
|
+
} catch {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
function bumpDecisionCount() {
|
|
746
|
+
const cfg = readConfig();
|
|
747
|
+
if (!cfg) return 0;
|
|
748
|
+
const n = (cfg.decisions_count ?? 0) + 1;
|
|
749
|
+
cfg.decisions_count = n;
|
|
750
|
+
try {
|
|
751
|
+
writeConfig(cfg);
|
|
752
|
+
} catch {
|
|
753
|
+
}
|
|
754
|
+
return n;
|
|
755
|
+
}
|
|
756
|
+
function installAgeDays() {
|
|
757
|
+
const t = Date.parse(readConfig()?.created_at ?? "");
|
|
758
|
+
if (Number.isNaN(t)) return 0;
|
|
759
|
+
return Math.floor((Date.now() - t) / 864e5);
|
|
760
|
+
}
|
|
761
|
+
function decisionNudge(status, violatedRule, upgradeToolName) {
|
|
762
|
+
const count = bumpDecisionCount();
|
|
763
|
+
if (status === "DENY" && violatedRule?.startsWith("duplicate_invoice") && nudgeOnce("bec_catch")) {
|
|
764
|
+
return `That was a duplicate-invoice (BEC) catch \u2014 the #1 payment fraud. On a free account every block like this is server-signed and anchored to Bitcoin: court-grade evidence. Call the ${upgradeToolName} tool to start.`;
|
|
765
|
+
}
|
|
766
|
+
if (status === "DENY" && nudgeOnce("first_deny")) {
|
|
767
|
+
return `Fidacy just blocked this for you \u2014 for server-signed, anchored proof of every block (free account, local history migrates), call the ${upgradeToolName} tool.`;
|
|
768
|
+
}
|
|
769
|
+
if (count === 10 && nudgeOnce("milestone_10")) {
|
|
770
|
+
return `That's 10 actions gated on this install. For a signed, Bitcoin-anchored history of all of them (free account), call the ${upgradeToolName} tool.`;
|
|
771
|
+
}
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
function weekOneBootNudge(upgradeToolName) {
|
|
775
|
+
if (installAgeDays() >= 7 && nudgeOnce("week_1")) {
|
|
776
|
+
return ` One week protected \u2014 anchor your history to Bitcoin with a free account: the ${upgradeToolName} tool starts it.`;
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
664
780
|
export {
|
|
665
781
|
DevFidacyCore,
|
|
666
782
|
FileAuditStore,
|
|
@@ -669,12 +785,15 @@ export {
|
|
|
669
785
|
ReferenceRail,
|
|
670
786
|
autoProvision,
|
|
671
787
|
canonInvoice,
|
|
788
|
+
decisionNudge,
|
|
672
789
|
ensureState,
|
|
673
790
|
evaluate,
|
|
674
791
|
httpRedeem,
|
|
792
|
+
installAgeDays,
|
|
675
793
|
loadOrGenerateKeyPair,
|
|
676
794
|
lookalikePayee,
|
|
677
795
|
makeCore,
|
|
796
|
+
nudgeOnce,
|
|
678
797
|
publicKeyPem,
|
|
679
798
|
readConfig,
|
|
680
799
|
recordAgentActive,
|
|
@@ -688,5 +807,6 @@ export {
|
|
|
688
807
|
upgradeUrl,
|
|
689
808
|
validateRequest,
|
|
690
809
|
verify,
|
|
691
|
-
verifyGrant
|
|
810
|
+
verifyGrant,
|
|
811
|
+
weekOneBootNudge
|
|
692
812
|
};
|
package/package.json
CHANGED