@fidacy/mcp 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core.js +21 -1
- package/dist/index.js +332 -12
- package/dist/lib.js +237 -11
- package/package.json +1 -1
package/dist/core.js
CHANGED
|
@@ -251,6 +251,10 @@ var DevFidacyCore = class {
|
|
|
251
251
|
return null;
|
|
252
252
|
return { record: record2, chainIntact: this.store.intact(), verifiedAgainstPublicKey: this.pubPem };
|
|
253
253
|
}
|
|
254
|
+
async history(limit = 50) {
|
|
255
|
+
const all = this.store.records();
|
|
256
|
+
return limit > 0 && all.length > limit ? all.slice(-limit) : all;
|
|
257
|
+
}
|
|
254
258
|
publicKey() {
|
|
255
259
|
return this.pubPem;
|
|
256
260
|
}
|
|
@@ -285,6 +289,10 @@ var HttpFidacyCore = class {
|
|
|
285
289
|
async getProof(decisionId) {
|
|
286
290
|
return this.call("/v1/audit/proof", { decisionId });
|
|
287
291
|
}
|
|
292
|
+
async history(limit = 50) {
|
|
293
|
+
const res = await this.call("/v1/audit/list", { limit });
|
|
294
|
+
return res.records ?? [];
|
|
295
|
+
}
|
|
288
296
|
publicKey() {
|
|
289
297
|
return this.subjectPub;
|
|
290
298
|
}
|
|
@@ -350,6 +358,9 @@ var FileAuditStore = class {
|
|
|
350
358
|
if (typeof decision.request?.purpose === "string" && decision.request.purpose.trim()) {
|
|
351
359
|
record2.purpose = decision.request.purpose.slice(0, 500);
|
|
352
360
|
}
|
|
361
|
+
if (typeof decision.request?.payee === "string" && decision.request.payee.trim()) {
|
|
362
|
+
record2.payee = decision.request.payee.slice(0, 200);
|
|
363
|
+
}
|
|
353
364
|
if (decision.status === "ALLOW") {
|
|
354
365
|
const req = decision.request;
|
|
355
366
|
if (typeof req?.amount === "number" && Number.isFinite(req.amount))
|
|
@@ -359,6 +370,14 @@ var FileAuditStore = class {
|
|
|
359
370
|
const invoice = canonInvoice(req?.invoiceRef);
|
|
360
371
|
if (invoice)
|
|
361
372
|
record2.invoiceRef = invoice;
|
|
373
|
+
} else {
|
|
374
|
+
if (decision.violatedRule)
|
|
375
|
+
record2.violatedRule = decision.violatedRule;
|
|
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;
|
|
362
381
|
}
|
|
363
382
|
fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
|
|
364
383
|
this.chain.push(record2);
|
|
@@ -442,7 +461,7 @@ function resolveMandateRules(cfg) {
|
|
|
442
461
|
}
|
|
443
462
|
|
|
444
463
|
// src/telemetry.ts
|
|
445
|
-
var CLIENT_VERSION = true ? "0.2.
|
|
464
|
+
var CLIENT_VERSION = true ? "0.2.4" : "dev";
|
|
446
465
|
function bandOf(amount) {
|
|
447
466
|
if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) return void 0;
|
|
448
467
|
if (amount < 10) return "lt10";
|
|
@@ -596,6 +615,7 @@ function makeCore() {
|
|
|
596
615
|
return dev.decide(req, subject);
|
|
597
616
|
},
|
|
598
617
|
getProof: (decisionId) => dev.getProof(decisionId),
|
|
618
|
+
history: (limit) => dev.history(limit),
|
|
599
619
|
publicKey: () => dev.publicKey()
|
|
600
620
|
};
|
|
601
621
|
}
|
package/dist/index.js
CHANGED
|
@@ -217,17 +217,17 @@ var DevFidacyCore = class {
|
|
|
217
217
|
}
|
|
218
218
|
async decide(req, subject2) {
|
|
219
219
|
const decisionId = randomUUID();
|
|
220
|
-
const
|
|
220
|
+
const ts2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
221
221
|
const invalid = validateRequest(req);
|
|
222
222
|
if (invalid) {
|
|
223
|
-
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: invalid, ts };
|
|
223
|
+
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: invalid, ts: ts2 };
|
|
224
224
|
this.store.append(decision2);
|
|
225
225
|
this.onDecision?.(decision2);
|
|
226
226
|
return decision2;
|
|
227
227
|
}
|
|
228
228
|
const violated = evaluate(this.mandate, req, this.spent);
|
|
229
229
|
if (violated) {
|
|
230
|
-
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: violated, ts };
|
|
230
|
+
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: violated, ts: ts2 };
|
|
231
231
|
this.store.append(decision2);
|
|
232
232
|
this.onDecision?.(decision2);
|
|
233
233
|
return decision2;
|
|
@@ -236,7 +236,7 @@ var DevFidacyCore = class {
|
|
|
236
236
|
if (invoice) {
|
|
237
237
|
const k = `${subject2}|${invoice}`;
|
|
238
238
|
if (this.claimedInvoices.has(k)) {
|
|
239
|
-
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: `duplicate_invoice:${invoice}`, ts };
|
|
239
|
+
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: `duplicate_invoice:${invoice}`, ts: ts2 };
|
|
240
240
|
this.store.append(decision2);
|
|
241
241
|
this.onDecision?.(decision2);
|
|
242
242
|
return decision2;
|
|
@@ -246,7 +246,7 @@ var DevFidacyCore = class {
|
|
|
246
246
|
const grantPayload = { decisionId, subject: subject2, payee: req.payee, amount: req.amount, currency: req.currency, exp: Date.now() + 12e4, ...req.invoiceRef ? { invoiceRef: req.invoiceRef } : {} };
|
|
247
247
|
const grantBody = Buffer.from(stableStringify(grantPayload), "utf8").toString("base64url");
|
|
248
248
|
const grant = `${grantBody}.${sign(this.priv, grantBody)}`;
|
|
249
|
-
const decision = { decisionId, status: "ALLOW", subject: subject2, mandateId: this.mandate.id, request: req, grant, ts };
|
|
249
|
+
const decision = { decisionId, status: "ALLOW", subject: subject2, mandateId: this.mandate.id, request: req, grant, ts: ts2 };
|
|
250
250
|
this.spent += req.amount;
|
|
251
251
|
this.store.append(decision);
|
|
252
252
|
this.onDecision?.(decision);
|
|
@@ -258,6 +258,10 @@ var DevFidacyCore = class {
|
|
|
258
258
|
return null;
|
|
259
259
|
return { record: record2, chainIntact: this.store.intact(), verifiedAgainstPublicKey: this.pubPem };
|
|
260
260
|
}
|
|
261
|
+
async history(limit = 50) {
|
|
262
|
+
const all = this.store.records();
|
|
263
|
+
return limit > 0 && all.length > limit ? all.slice(-limit) : all;
|
|
264
|
+
}
|
|
261
265
|
publicKey() {
|
|
262
266
|
return this.pubPem;
|
|
263
267
|
}
|
|
@@ -292,6 +296,10 @@ var HttpFidacyCore = class {
|
|
|
292
296
|
async getProof(decisionId) {
|
|
293
297
|
return this.call("/v1/audit/proof", { decisionId });
|
|
294
298
|
}
|
|
299
|
+
async history(limit = 50) {
|
|
300
|
+
const res = await this.call("/v1/audit/list", { limit });
|
|
301
|
+
return res.records ?? [];
|
|
302
|
+
}
|
|
295
303
|
publicKey() {
|
|
296
304
|
return this.subjectPub;
|
|
297
305
|
}
|
|
@@ -350,13 +358,16 @@ var FileAuditStore = class {
|
|
|
350
358
|
append(decision) {
|
|
351
359
|
const prevHash = this.head();
|
|
352
360
|
const seq = this.chain.length;
|
|
353
|
-
const
|
|
361
|
+
const ts2 = decision.ts;
|
|
354
362
|
const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
|
|
355
|
-
const hash = sha256(`${prevHash}|${digest}|${seq}|${
|
|
356
|
-
const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
|
|
363
|
+
const hash = sha256(`${prevHash}|${digest}|${seq}|${ts2}`);
|
|
364
|
+
const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts: ts2 };
|
|
357
365
|
if (typeof decision.request?.purpose === "string" && decision.request.purpose.trim()) {
|
|
358
366
|
record2.purpose = decision.request.purpose.slice(0, 500);
|
|
359
367
|
}
|
|
368
|
+
if (typeof decision.request?.payee === "string" && decision.request.payee.trim()) {
|
|
369
|
+
record2.payee = decision.request.payee.slice(0, 200);
|
|
370
|
+
}
|
|
360
371
|
if (decision.status === "ALLOW") {
|
|
361
372
|
const req = decision.request;
|
|
362
373
|
if (typeof req?.amount === "number" && Number.isFinite(req.amount))
|
|
@@ -366,6 +377,14 @@ var FileAuditStore = class {
|
|
|
366
377
|
const invoice = canonInvoice(req?.invoiceRef);
|
|
367
378
|
if (invoice)
|
|
368
379
|
record2.invoiceRef = invoice;
|
|
380
|
+
} else {
|
|
381
|
+
if (decision.violatedRule)
|
|
382
|
+
record2.violatedRule = decision.violatedRule;
|
|
383
|
+
const req = decision.request;
|
|
384
|
+
if (typeof req?.amount === "number" && Number.isFinite(req.amount))
|
|
385
|
+
record2.amount = req.amount;
|
|
386
|
+
if (typeof req?.currency === "string")
|
|
387
|
+
record2.currency = req.currency;
|
|
369
388
|
}
|
|
370
389
|
fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
|
|
371
390
|
this.chain.push(record2);
|
|
@@ -471,7 +490,7 @@ function resolveMandateRules(cfg) {
|
|
|
471
490
|
}
|
|
472
491
|
|
|
473
492
|
// src/telemetry.ts
|
|
474
|
-
var CLIENT_VERSION = true ? "0.2.
|
|
493
|
+
var CLIENT_VERSION = true ? "0.2.4" : "dev";
|
|
475
494
|
function bandOf(amount) {
|
|
476
495
|
if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) return void 0;
|
|
477
496
|
if (amount < 10) return "lt10";
|
|
@@ -628,6 +647,7 @@ function makeCore() {
|
|
|
628
647
|
return dev.decide(req, subject2);
|
|
629
648
|
},
|
|
630
649
|
getProof: (decisionId) => dev.getProof(decisionId),
|
|
650
|
+
history: (limit) => dev.history(limit),
|
|
631
651
|
publicKey: () => dev.publicKey()
|
|
632
652
|
};
|
|
633
653
|
}
|
|
@@ -814,7 +834,7 @@ async function findArtifacts(sha2562, cfg) {
|
|
|
814
834
|
}
|
|
815
835
|
|
|
816
836
|
// src/provision.ts
|
|
817
|
-
var CLIENT_VERSION2 = true ? "0.2.
|
|
837
|
+
var CLIENT_VERSION2 = true ? "0.2.4" : "dev";
|
|
818
838
|
function provisionEnabled() {
|
|
819
839
|
const v = (process.env.FIDACY_DISABLE_PROVISION ?? "").trim().toLowerCase();
|
|
820
840
|
return !(v === "1" || v === "true" || v === "yes");
|
|
@@ -985,11 +1005,207 @@ function activationBootLine() {
|
|
|
985
1005
|
return ` Anonymous trial: ${used}/${FREE_DECISIONS} free decisions used; after that the firewall requires a free API key.`;
|
|
986
1006
|
}
|
|
987
1007
|
|
|
1008
|
+
// src/reporting.ts
|
|
1009
|
+
function withinDays(records, days, now = Date.now()) {
|
|
1010
|
+
if (!Number.isFinite(days) || days <= 0) return [...records];
|
|
1011
|
+
const cutoff = now - days * 864e5;
|
|
1012
|
+
return records.filter((r) => {
|
|
1013
|
+
const t = Date.parse(r.ts);
|
|
1014
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
var addTo = (bucket, key, value) => {
|
|
1018
|
+
bucket[key] = (bucket[key] ?? 0) + value;
|
|
1019
|
+
};
|
|
1020
|
+
function summarize(records, days, now = Date.now()) {
|
|
1021
|
+
const rows = withinDays(records, days, now);
|
|
1022
|
+
const out = {
|
|
1023
|
+
window_days: days,
|
|
1024
|
+
decisions: rows.length,
|
|
1025
|
+
allowed: 0,
|
|
1026
|
+
denied: 0,
|
|
1027
|
+
allowed_totals: {},
|
|
1028
|
+
blocked_totals: {},
|
|
1029
|
+
denied_by_rule: {},
|
|
1030
|
+
payees_paid: [],
|
|
1031
|
+
amount_unknown: 0,
|
|
1032
|
+
first_ts: rows.length ? rows[0].ts : null,
|
|
1033
|
+
last_ts: rows.length ? rows[rows.length - 1].ts : null
|
|
1034
|
+
};
|
|
1035
|
+
const paid = /* @__PURE__ */ new Set();
|
|
1036
|
+
for (const r of rows) {
|
|
1037
|
+
const known = typeof r.amount === "number" && Number.isFinite(r.amount);
|
|
1038
|
+
if (!known) out.amount_unknown += 1;
|
|
1039
|
+
const ccy = r.currency ?? "unknown";
|
|
1040
|
+
if (r.status === "ALLOW") {
|
|
1041
|
+
out.allowed += 1;
|
|
1042
|
+
if (known) addTo(out.allowed_totals, ccy, r.amount);
|
|
1043
|
+
if (r.payee) paid.add(r.payee);
|
|
1044
|
+
} else {
|
|
1045
|
+
out.denied += 1;
|
|
1046
|
+
if (known) addTo(out.blocked_totals, ccy, r.amount);
|
|
1047
|
+
addTo(out.denied_by_rule, r.violatedRule ?? "not_recorded", 1);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
out.payees_paid = [...paid].sort();
|
|
1051
|
+
return out;
|
|
1052
|
+
}
|
|
1053
|
+
var money = (totals) => {
|
|
1054
|
+
const parts = Object.entries(totals).map(([ccy, v]) => `${v.toLocaleString("en-US")} ${ccy}`);
|
|
1055
|
+
return parts.length ? parts.join(", ") : "0";
|
|
1056
|
+
};
|
|
1057
|
+
function renderSummary(s) {
|
|
1058
|
+
if (s.decisions === 0) {
|
|
1059
|
+
return `No agent payment decisions in the last ${s.window_days} day(s). The firewall is active and has nothing to report, which is the expected state until an agent attempts a payment.`;
|
|
1060
|
+
}
|
|
1061
|
+
const lines = [
|
|
1062
|
+
`Last ${s.window_days} day(s): ${s.decisions} payment decision(s), ${s.allowed} allowed and ${s.denied} blocked before execution.`,
|
|
1063
|
+
`Paid: ${money(s.allowed_totals)}${s.payees_paid.length ? ` across ${s.payees_paid.length} payee(s): ${s.payees_paid.slice(0, 8).join(", ")}${s.payees_paid.length > 8 ? ", \u2026" : ""}` : ""}.`
|
|
1064
|
+
];
|
|
1065
|
+
if (s.denied > 0) {
|
|
1066
|
+
const ranked = Object.entries(s.denied_by_rule).sort((a, b) => b[1] - a[1]);
|
|
1067
|
+
lines.push(`Blocked: ${money(s.blocked_totals)} in attempted value. By rule: ${ranked.map(([rule, n]) => `${rule} (${n})`).join(", ")}.`);
|
|
1068
|
+
}
|
|
1069
|
+
if (s.amount_unknown > 0) {
|
|
1070
|
+
lines.push(`${s.amount_unknown} record(s) in this window predate amount logging, so the totals above are a floor, not the exact figure.`);
|
|
1071
|
+
}
|
|
1072
|
+
return lines.join("\n");
|
|
1073
|
+
}
|
|
1074
|
+
function toRows(records, status, limit) {
|
|
1075
|
+
const filtered = status === "all" ? [...records] : records.filter((r) => r.status === status);
|
|
1076
|
+
return filtered.slice(-Math.max(1, limit)).reverse().map((r) => ({
|
|
1077
|
+
seq: r.seq,
|
|
1078
|
+
decisionId: r.decisionId,
|
|
1079
|
+
status: r.status,
|
|
1080
|
+
ts: r.ts,
|
|
1081
|
+
payee: r.payee,
|
|
1082
|
+
amount: r.amount,
|
|
1083
|
+
currency: r.currency,
|
|
1084
|
+
purpose: r.purpose,
|
|
1085
|
+
violatedRule: r.violatedRule,
|
|
1086
|
+
invoiceRef: r.invoiceRef
|
|
1087
|
+
}));
|
|
1088
|
+
}
|
|
1089
|
+
function renderRows(rows) {
|
|
1090
|
+
if (!rows.length) return "No decisions match that filter yet.";
|
|
1091
|
+
return rows.map((r) => {
|
|
1092
|
+
const amount = typeof r.amount === "number" ? `${r.amount.toLocaleString("en-US")} ${r.currency ?? ""}`.trim() : "amount not recorded";
|
|
1093
|
+
const who = r.payee ?? "payee not recorded";
|
|
1094
|
+
const why = r.status === "DENY" ? ` blocked by ${r.violatedRule ?? "a rule not recorded on this older entry"}.` : "";
|
|
1095
|
+
const said = r.purpose ? ` Agent's stated reason: "${r.purpose}".` : "";
|
|
1096
|
+
return `#${r.seq} ${r.ts} ${r.status} ${amount} to ${who}.${why}${said} (decision ${r.decisionId})`;
|
|
1097
|
+
}).join("\n");
|
|
1098
|
+
}
|
|
1099
|
+
function explainRule(rule, payee) {
|
|
1100
|
+
if (!rule) return "This entry predates readable rule logging, so the specific rule is not on the record. The decision payload is still covered by the chain digest.";
|
|
1101
|
+
if (rule.startsWith("payee_not_in_allowlist"))
|
|
1102
|
+
return `The payee${payee ? ` "${payee}"` : ""} is not on the mandate's allowlist. This is the deny-by-default rule doing its job: an agent can only pay counterparties a human put on the list, which is what stops a swapped or lookalike vendor.`;
|
|
1103
|
+
if (rule.startsWith("category_not_allowed"))
|
|
1104
|
+
return "The stated purpose category is not one the mandate permits for this agent.";
|
|
1105
|
+
if (rule.startsWith("currency_not_allowed"))
|
|
1106
|
+
return "The payment currency is not the mandate's currency.";
|
|
1107
|
+
if (rule.startsWith("per_tx") || rule.includes("perTxMax"))
|
|
1108
|
+
return "The amount exceeds the per-transaction ceiling in the mandate.";
|
|
1109
|
+
if (rule.includes("maxTotal") || rule.startsWith("budget"))
|
|
1110
|
+
return "The payment would push cumulative spend past the mandate's total budget for the window.";
|
|
1111
|
+
if (rule.startsWith("invoice") || rule.includes("duplicate"))
|
|
1112
|
+
return "This invoice was already paid once. One payment per invoice is enforced regardless of amount, which is the control that stops a re-presented invoice at a higher figure.";
|
|
1113
|
+
if (rule.startsWith("window") || rule.includes("expired"))
|
|
1114
|
+
return "The mandate was outside its validity window at the moment of the request.";
|
|
1115
|
+
if (rule.startsWith("revoked")) return "The mandate had been revoked.";
|
|
1116
|
+
if (rule.startsWith("activation_required"))
|
|
1117
|
+
return "The anonymous trial had been used up, so the firewall failed closed. No money can move until the install is activated with a free API key.";
|
|
1118
|
+
return `The mandate rule "${rule}" was violated.`;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// src/sentinel.ts
|
|
1122
|
+
var SENTINEL = {
|
|
1123
|
+
/** amount >= SPIKE_FACTOR x the historical max ALLOW to the same payee. */
|
|
1124
|
+
SPIKE_FACTOR: 3,
|
|
1125
|
+
/** minimum prior ALLOWs to a payee before a spike is judged (else first_payee covers it). */
|
|
1126
|
+
SPIKE_MIN_HISTORY: 2,
|
|
1127
|
+
/** decisions within BURST_WINDOW_MS to trip velocity_burst. */
|
|
1128
|
+
BURST_COUNT: 5,
|
|
1129
|
+
BURST_WINDOW_MS: 10 * 6e4,
|
|
1130
|
+
/** fraction of perTxMax that counts as riding the ceiling. */
|
|
1131
|
+
CAP_CREEP_RATIO: 0.8,
|
|
1132
|
+
/** a DENY for the same payee within this window makes a new attempt a retry. */
|
|
1133
|
+
RETRY_WINDOW_MS: 30 * 6e4
|
|
1134
|
+
};
|
|
1135
|
+
var ts = (r) => {
|
|
1136
|
+
const t = Date.parse(r.ts);
|
|
1137
|
+
return Number.isFinite(t) ? t : 0;
|
|
1138
|
+
};
|
|
1139
|
+
function assessPattern(records, req, opts = {}) {
|
|
1140
|
+
const now = opts.now ?? Date.now();
|
|
1141
|
+
const alerts = [];
|
|
1142
|
+
const toPayee = records.filter((r) => r.payee === req.payee);
|
|
1143
|
+
const allowedToPayee = toPayee.filter((r) => r.status === "ALLOW");
|
|
1144
|
+
if (req.payee && allowedToPayee.length === 0) {
|
|
1145
|
+
alerts.push({
|
|
1146
|
+
rule: "first_payee",
|
|
1147
|
+
severity: "warning",
|
|
1148
|
+
message: `First payment ever from this agent to "${req.payee}". A swapped or lookalike vendor always looks like a first payee; worth a human glance before anything settles.`
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
if (allowedToPayee.length >= SENTINEL.SPIKE_MIN_HISTORY && typeof req.amount === "number") {
|
|
1152
|
+
const maxSeen = Math.max(...allowedToPayee.map((r) => typeof r.amount === "number" ? r.amount : 0));
|
|
1153
|
+
if (maxSeen > 0 && req.amount >= maxSeen * SENTINEL.SPIKE_FACTOR) {
|
|
1154
|
+
alerts.push({
|
|
1155
|
+
rule: "amount_spike",
|
|
1156
|
+
severity: "warning",
|
|
1157
|
+
message: `${req.amount.toLocaleString("en-US")} is ${(req.amount / maxSeen).toFixed(1)}x the largest amount this agent has ever paid "${req.payee}" (${maxSeen.toLocaleString("en-US")}). Inflated re-presentations of a known vendor look exactly like this.`
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const recent = records.filter((r) => now - ts(r) <= SENTINEL.BURST_WINDOW_MS);
|
|
1162
|
+
if (recent.length + 1 >= SENTINEL.BURST_COUNT) {
|
|
1163
|
+
alerts.push({
|
|
1164
|
+
rule: "velocity_burst",
|
|
1165
|
+
severity: "warning",
|
|
1166
|
+
message: `${recent.length + 1} payment decisions inside ${Math.round(SENTINEL.BURST_WINDOW_MS / 6e4)} minutes. Runaway loops and drain attempts announce themselves as bursts before they announce themselves as losses.`
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
if (typeof opts.perTxMax === "number" && opts.perTxMax > 0 && typeof req.amount === "number") {
|
|
1170
|
+
if (req.amount >= opts.perTxMax * SENTINEL.CAP_CREEP_RATIO && req.amount <= opts.perTxMax) {
|
|
1171
|
+
alerts.push({
|
|
1172
|
+
rule: "cap_creep",
|
|
1173
|
+
severity: "notice",
|
|
1174
|
+
message: `${req.amount.toLocaleString("en-US")} is ${Math.round(req.amount / opts.perTxMax * 100)}% of the per-transaction ceiling. An attacker probing a mandate pays just under the cap; a human authorizing a real bill usually doesn't land this close.`
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const recentDeny = toPayee.find((r) => r.status === "DENY" && now - ts(r) <= SENTINEL.RETRY_WINDOW_MS);
|
|
1179
|
+
if (recentDeny) {
|
|
1180
|
+
alerts.push({
|
|
1181
|
+
rule: "retry_after_deny",
|
|
1182
|
+
severity: "warning",
|
|
1183
|
+
message: `This agent was DENIED a payment to "${req.payee}" ${Math.max(1, Math.round((now - ts(recentDeny)) / 6e4))} minute(s) ago (${recentDeny.violatedRule ?? "rule on record"}) and is now trying again. Our public Model Watch benchmark measures exactly this behavior; in an autonomous agent it is the pattern that precedes a loss.`
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
return alerts;
|
|
1187
|
+
}
|
|
1188
|
+
function sweep(records, opts = {}) {
|
|
1189
|
+
const out = [];
|
|
1190
|
+
for (let i = 0; i < records.length; i++) {
|
|
1191
|
+
const r = records[i];
|
|
1192
|
+
if (!r.payee) continue;
|
|
1193
|
+
const alerts = assessPattern(records.slice(0, i), { payee: r.payee, amount: r.amount }, { perTxMax: opts.perTxMax, now: ts(r) });
|
|
1194
|
+
if (alerts.length) out.push({ decisionId: r.decisionId, ts: r.ts, status: r.status, payee: r.payee, alerts });
|
|
1195
|
+
}
|
|
1196
|
+
return out.slice(-(opts.limit ?? 20)).reverse();
|
|
1197
|
+
}
|
|
1198
|
+
function renderAlertLine(alerts) {
|
|
1199
|
+
if (!alerts.length) return "";
|
|
1200
|
+
const worst = alerts.some((a) => a.severity === "warning") ? "SENTINEL WARNING" : "SENTINEL NOTICE";
|
|
1201
|
+
return ` [${worst}: ${alerts.map((a) => a.message).join(" Also: ")}]`;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
988
1204
|
// src/index.ts
|
|
989
1205
|
var state = ensureState();
|
|
990
1206
|
var core = makeCore();
|
|
991
1207
|
var subject = process.env.FIDACY_SUBJECT ?? "agent:demo";
|
|
992
|
-
var SERVER_VERSION = true ? "0.2.
|
|
1208
|
+
var SERVER_VERSION = true ? "0.2.4" : "dev";
|
|
993
1209
|
var bootClaim = claimUrl();
|
|
994
1210
|
var server = new McpServer(
|
|
995
1211
|
{ name: "fidacy", version: SERVER_VERSION },
|
|
@@ -1030,9 +1246,16 @@ server.registerTool(
|
|
|
1030
1246
|
return { content: [{ type: "text", text: gate.message }], structuredContent: out2 };
|
|
1031
1247
|
}
|
|
1032
1248
|
const req = args;
|
|
1249
|
+
let sentinelAlerts = [];
|
|
1250
|
+
try {
|
|
1251
|
+
const [before, mandate] = await Promise.all([core.history(5e3), core.getMandate(subject)]);
|
|
1252
|
+
sentinelAlerts = assessPattern(before, { payee: req.payee, amount: req.amount }, { perTxMax: mandate?.allow?.perTxMax });
|
|
1253
|
+
} catch {
|
|
1254
|
+
}
|
|
1033
1255
|
const d = await core.decide(req, subject);
|
|
1034
1256
|
const out = { status: d.status, decisionId: d.decisionId, grant: d.grant, violatedRule: d.violatedRule };
|
|
1035
|
-
const
|
|
1257
|
+
const sentinelLine = d.status === "ALLOW" ? renderAlertLine(sentinelAlerts) : "";
|
|
1258
|
+
const base = d.status === "ALLOW" ? `ALLOW (decision ${d.decisionId})${req.invoiceRef ? ` for invoice ${req.invoiceRef}` : ""}.${sentinelLine} To settle, call execute_payment with the SAME payee, amount, currency, and idempotencyKey, and set "grant" to EXACTLY this signed value:
|
|
1036
1259
|
${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.${d.violatedRule?.startsWith("payee_not_in_allowlist") ? ` If the user trusts this payee, add "${req.payee}" to mandate.payees in ~/.fidacy/config.json and retry: the firewall picks the change up on the next call, no restart needed.` : d.violatedRule?.startsWith("category_not_allowed") ? ` If the user considers "${req.category}" a legitimate purpose for this agent, add it to mandate.categories in ~/.fidacy/config.json and retry: the firewall picks the change up on the next call, no restart needed.` : d.violatedRule?.startsWith("currency_not_allowed") ? ` The mandate is single-currency and set to another one (mandate.currency in ~/.fidacy/config.json, default USD). If this agent legitimately pays in ${req.currency}, update it there and retry: hot-reloaded, no restart.` : ""}`;
|
|
1037
1260
|
const nudge = decisionNudge(d.status, d.violatedRule, "upgrade");
|
|
1038
1261
|
const countdown = trialCountdownLine();
|
|
@@ -1074,6 +1297,103 @@ server.registerTool(
|
|
|
1074
1297
|
return { content: [{ type: "text", text }], structuredContent: proof };
|
|
1075
1298
|
}
|
|
1076
1299
|
);
|
|
1300
|
+
server.registerTool(
|
|
1301
|
+
"spend_summary",
|
|
1302
|
+
{
|
|
1303
|
+
title: "Spend Summary (what my agents spent and what was blocked)",
|
|
1304
|
+
description: "Operator report over a time window: how many payment decisions were made, how much was paid and to whom, how much was blocked before executing, and which mandate rules did the blocking. Read-only, works offline against the local audit chain. Use this to answer questions like 'what did my agents spend this week' or 'has anything been blocked'.",
|
|
1305
|
+
inputSchema: {
|
|
1306
|
+
days: z.number().int().positive().max(365).optional().describe("Window in days, counting back from now. Default 7.")
|
|
1307
|
+
},
|
|
1308
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
1309
|
+
},
|
|
1310
|
+
async ({ days }) => {
|
|
1311
|
+
const window = days ?? 7;
|
|
1312
|
+
const records = await core.history(5e3);
|
|
1313
|
+
const summary = summarize(records, window);
|
|
1314
|
+
const claim = process.env.FIDACY_ENGINE_API_KEY ? null : claimUrl();
|
|
1315
|
+
const claimLine = claim ? `
|
|
1316
|
+
|
|
1317
|
+
For your operator: this report is built from the local chain on this machine. A free account keeps it across machines and anchors it to Bitcoin, and this install's history migrates: ${claim}` : "";
|
|
1318
|
+
return {
|
|
1319
|
+
content: [{ type: "text", text: renderSummary(summary) + claimLine }],
|
|
1320
|
+
structuredContent: summary
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
);
|
|
1324
|
+
server.registerTool(
|
|
1325
|
+
"list_decisions",
|
|
1326
|
+
{
|
|
1327
|
+
title: "List Recent Payment Decisions",
|
|
1328
|
+
description: "List recent agent payment decisions, newest first, with payee, amount, the agent's stated reason, and for blocked ones the rule that stopped it. Filter by status to answer 'what got blocked' directly. Read-only, works offline.",
|
|
1329
|
+
inputSchema: {
|
|
1330
|
+
limit: z.number().int().positive().max(200).optional().describe("How many decisions to return. Default 20."),
|
|
1331
|
+
status: z.enum(["ALLOW", "DENY", "all"]).optional().describe("Filter by outcome. Default all.")
|
|
1332
|
+
},
|
|
1333
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
1334
|
+
},
|
|
1335
|
+
async ({ limit, status }) => {
|
|
1336
|
+
const records = await core.history(5e3);
|
|
1337
|
+
const rows = toRows(records, status ?? "all", limit ?? 20);
|
|
1338
|
+
return {
|
|
1339
|
+
content: [{ type: "text", text: renderRows(rows) }],
|
|
1340
|
+
structuredContent: { count: rows.length, decisions: rows }
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
);
|
|
1344
|
+
server.registerTool(
|
|
1345
|
+
"explain_decision",
|
|
1346
|
+
{
|
|
1347
|
+
title: "Explain a Decision (why was this allowed or blocked)",
|
|
1348
|
+
description: "Explain one decision in plain language: what was requested, what the mandate did about it, why, and the tamper-evident proof for it. Use when an operator asks why a specific payment was blocked.",
|
|
1349
|
+
inputSchema: { decisionId: z.string().describe("Decision id from request_payment, list_decisions or spend_summary") },
|
|
1350
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
1351
|
+
},
|
|
1352
|
+
async ({ decisionId }) => {
|
|
1353
|
+
const records = await core.history(5e3);
|
|
1354
|
+
const record2 = records.find((r) => r.decisionId === decisionId);
|
|
1355
|
+
if (!record2) {
|
|
1356
|
+
return {
|
|
1357
|
+
content: [{ type: "text", text: `No decision ${decisionId} on this chain. Run list_decisions to see the ids that exist here.` }],
|
|
1358
|
+
isError: true
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
const proof = await core.getProof(decisionId);
|
|
1362
|
+
const amount = typeof record2.amount === "number" ? `${record2.amount.toLocaleString("en-US")} ${record2.currency ?? ""}`.trim() : "an amount that was not recorded";
|
|
1363
|
+
const head = record2.status === "ALLOW" ? `Decision ${decisionId} ALLOWED a payment of ${amount}${record2.payee ? ` to ${record2.payee}` : ""} at ${record2.ts}. It was inside every rule of the active mandate, and a signed grant was issued so a downstream executor could settle it.` : `Decision ${decisionId} BLOCKED a payment of ${amount}${record2.payee ? ` to ${record2.payee}` : ""} at ${record2.ts}. No money moved and no grant was issued.`;
|
|
1364
|
+
const why = record2.status === "DENY" ? `
|
|
1365
|
+
|
|
1366
|
+
Why: ${explainRule(record2.violatedRule, record2.payee)}` : "";
|
|
1367
|
+
const said = record2.purpose ? `
|
|
1368
|
+
|
|
1369
|
+
The agent's stated reason at the time: "${record2.purpose}".` : "";
|
|
1370
|
+
const integrity = proof ? `
|
|
1371
|
+
|
|
1372
|
+
Proof: entry #${record2.seq} in the hash-chained audit, chain currently ${proof.chainIntact ? "intact" : "BROKEN (records were altered or removed)"}. Call get_audit_proof for the full portable proof.` : "";
|
|
1373
|
+
const payload = { record: record2, explanation: explainRule(record2.violatedRule, record2.payee), chainIntact: proof?.chainIntact ?? null };
|
|
1374
|
+
return { content: [{ type: "text", text: head + why + said + integrity }], structuredContent: payload };
|
|
1375
|
+
}
|
|
1376
|
+
);
|
|
1377
|
+
server.registerTool(
|
|
1378
|
+
"sentinel_alerts",
|
|
1379
|
+
{
|
|
1380
|
+
title: "Sentinel Alerts (allowed, but outside this agent's pattern)",
|
|
1381
|
+
description: "Predictive pattern alerts from the local audit chain: first-ever payee, amount spikes vs this agent's own history, velocity bursts, payments riding the mandate ceiling, and retries after a denial (the behavior our Model Watch benchmark measures). Deterministic and explainable, no model. Use to answer 'has anything unusual happened' before it becomes a loss.",
|
|
1382
|
+
inputSchema: {
|
|
1383
|
+
limit: z.number().int().positive().max(100).optional().describe("How many flagged decisions to return. Default 20.")
|
|
1384
|
+
},
|
|
1385
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
1386
|
+
},
|
|
1387
|
+
async ({ limit }) => {
|
|
1388
|
+
const [records, mandate] = await Promise.all([core.history(5e3), core.getMandate(subject).catch(() => null)]);
|
|
1389
|
+
const flagged = sweep(records, { perTxMax: mandate?.allow?.perTxMax, limit: limit ?? 20 });
|
|
1390
|
+
const text = flagged.length ? flagged.map((f) => `#${f.decisionId.slice(0, 8)} ${f.ts} ${f.status} to ${f.payee}: ${f.alerts.map((a) => `[${a.rule}] ${a.message}`).join(" ")}`).join("\n") : "No pattern alerts on this chain. Every decision so far sits inside this agent's own historical pattern.";
|
|
1391
|
+
return {
|
|
1392
|
+
content: [{ type: "text", text }],
|
|
1393
|
+
structuredContent: { count: flagged.length, thresholds: SENTINEL, flagged }
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
);
|
|
1077
1397
|
server.registerTool(
|
|
1078
1398
|
"assess_action",
|
|
1079
1399
|
{
|
package/dist/lib.js
CHANGED
|
@@ -248,17 +248,17 @@ var DevFidacyCore = class {
|
|
|
248
248
|
}
|
|
249
249
|
async decide(req, subject) {
|
|
250
250
|
const decisionId = randomUUID();
|
|
251
|
-
const
|
|
251
|
+
const ts2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
252
252
|
const invalid = validateRequest(req);
|
|
253
253
|
if (invalid) {
|
|
254
|
-
const decision2 = { decisionId, status: "DENY", subject, mandateId: this.mandate.id, request: req, violatedRule: invalid, ts };
|
|
254
|
+
const decision2 = { decisionId, status: "DENY", subject, mandateId: this.mandate.id, request: req, violatedRule: invalid, ts: ts2 };
|
|
255
255
|
this.store.append(decision2);
|
|
256
256
|
this.onDecision?.(decision2);
|
|
257
257
|
return decision2;
|
|
258
258
|
}
|
|
259
259
|
const violated = evaluate(this.mandate, req, this.spent);
|
|
260
260
|
if (violated) {
|
|
261
|
-
const decision2 = { decisionId, status: "DENY", subject, mandateId: this.mandate.id, request: req, violatedRule: violated, ts };
|
|
261
|
+
const decision2 = { decisionId, status: "DENY", subject, mandateId: this.mandate.id, request: req, violatedRule: violated, ts: ts2 };
|
|
262
262
|
this.store.append(decision2);
|
|
263
263
|
this.onDecision?.(decision2);
|
|
264
264
|
return decision2;
|
|
@@ -267,7 +267,7 @@ var DevFidacyCore = class {
|
|
|
267
267
|
if (invoice) {
|
|
268
268
|
const k = `${subject}|${invoice}`;
|
|
269
269
|
if (this.claimedInvoices.has(k)) {
|
|
270
|
-
const decision2 = { decisionId, status: "DENY", subject, mandateId: this.mandate.id, request: req, violatedRule: `duplicate_invoice:${invoice}`, ts };
|
|
270
|
+
const decision2 = { decisionId, status: "DENY", subject, mandateId: this.mandate.id, request: req, violatedRule: `duplicate_invoice:${invoice}`, ts: ts2 };
|
|
271
271
|
this.store.append(decision2);
|
|
272
272
|
this.onDecision?.(decision2);
|
|
273
273
|
return decision2;
|
|
@@ -277,7 +277,7 @@ var DevFidacyCore = class {
|
|
|
277
277
|
const grantPayload = { decisionId, subject, payee: req.payee, amount: req.amount, currency: req.currency, exp: Date.now() + 12e4, ...req.invoiceRef ? { invoiceRef: req.invoiceRef } : {} };
|
|
278
278
|
const grantBody = Buffer.from(stableStringify(grantPayload), "utf8").toString("base64url");
|
|
279
279
|
const grant = `${grantBody}.${sign(this.priv, grantBody)}`;
|
|
280
|
-
const decision = { decisionId, status: "ALLOW", subject, mandateId: this.mandate.id, request: req, grant, ts };
|
|
280
|
+
const decision = { decisionId, status: "ALLOW", subject, mandateId: this.mandate.id, request: req, grant, ts: ts2 };
|
|
281
281
|
this.spent += req.amount;
|
|
282
282
|
this.store.append(decision);
|
|
283
283
|
this.onDecision?.(decision);
|
|
@@ -289,6 +289,10 @@ var DevFidacyCore = class {
|
|
|
289
289
|
return null;
|
|
290
290
|
return { record: record2, chainIntact: this.store.intact(), verifiedAgainstPublicKey: this.pubPem };
|
|
291
291
|
}
|
|
292
|
+
async history(limit = 50) {
|
|
293
|
+
const all = this.store.records();
|
|
294
|
+
return limit > 0 && all.length > limit ? all.slice(-limit) : all;
|
|
295
|
+
}
|
|
292
296
|
publicKey() {
|
|
293
297
|
return this.pubPem;
|
|
294
298
|
}
|
|
@@ -323,6 +327,10 @@ var HttpFidacyCore = class {
|
|
|
323
327
|
async getProof(decisionId) {
|
|
324
328
|
return this.call("/v1/audit/proof", { decisionId });
|
|
325
329
|
}
|
|
330
|
+
async history(limit = 50) {
|
|
331
|
+
const res = await this.call("/v1/audit/list", { limit });
|
|
332
|
+
return res.records ?? [];
|
|
333
|
+
}
|
|
326
334
|
publicKey() {
|
|
327
335
|
return this.subjectPub;
|
|
328
336
|
}
|
|
@@ -381,13 +389,16 @@ var FileAuditStore = class {
|
|
|
381
389
|
append(decision) {
|
|
382
390
|
const prevHash = this.head();
|
|
383
391
|
const seq = this.chain.length;
|
|
384
|
-
const
|
|
392
|
+
const ts2 = decision.ts;
|
|
385
393
|
const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
|
|
386
|
-
const hash = sha256(`${prevHash}|${digest}|${seq}|${
|
|
387
|
-
const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
|
|
394
|
+
const hash = sha256(`${prevHash}|${digest}|${seq}|${ts2}`);
|
|
395
|
+
const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts: ts2 };
|
|
388
396
|
if (typeof decision.request?.purpose === "string" && decision.request.purpose.trim()) {
|
|
389
397
|
record2.purpose = decision.request.purpose.slice(0, 500);
|
|
390
398
|
}
|
|
399
|
+
if (typeof decision.request?.payee === "string" && decision.request.payee.trim()) {
|
|
400
|
+
record2.payee = decision.request.payee.slice(0, 200);
|
|
401
|
+
}
|
|
391
402
|
if (decision.status === "ALLOW") {
|
|
392
403
|
const req = decision.request;
|
|
393
404
|
if (typeof req?.amount === "number" && Number.isFinite(req.amount))
|
|
@@ -397,6 +408,14 @@ var FileAuditStore = class {
|
|
|
397
408
|
const invoice = canonInvoice(req?.invoiceRef);
|
|
398
409
|
if (invoice)
|
|
399
410
|
record2.invoiceRef = invoice;
|
|
411
|
+
} else {
|
|
412
|
+
if (decision.violatedRule)
|
|
413
|
+
record2.violatedRule = decision.violatedRule;
|
|
414
|
+
const req = decision.request;
|
|
415
|
+
if (typeof req?.amount === "number" && Number.isFinite(req.amount))
|
|
416
|
+
record2.amount = req.amount;
|
|
417
|
+
if (typeof req?.currency === "string")
|
|
418
|
+
record2.currency = req.currency;
|
|
400
419
|
}
|
|
401
420
|
fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
|
|
402
421
|
this.chain.push(record2);
|
|
@@ -594,7 +613,7 @@ function resolveMandateRules(cfg) {
|
|
|
594
613
|
}
|
|
595
614
|
|
|
596
615
|
// src/telemetry.ts
|
|
597
|
-
var CLIENT_VERSION = true ? "0.2.
|
|
616
|
+
var CLIENT_VERSION = true ? "0.2.4" : "dev";
|
|
598
617
|
function bandOf(amount) {
|
|
599
618
|
if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) return void 0;
|
|
600
619
|
if (amount < 10) return "lt10";
|
|
@@ -754,6 +773,7 @@ function makeCore() {
|
|
|
754
773
|
return dev.decide(req, subject);
|
|
755
774
|
},
|
|
756
775
|
getProof: (decisionId) => dev.getProof(decisionId),
|
|
776
|
+
history: (limit) => dev.history(limit),
|
|
757
777
|
publicKey: () => dev.publicKey()
|
|
758
778
|
};
|
|
759
779
|
}
|
|
@@ -771,7 +791,7 @@ function requestUpgrade() {
|
|
|
771
791
|
}
|
|
772
792
|
|
|
773
793
|
// src/provision.ts
|
|
774
|
-
var CLIENT_VERSION2 = true ? "0.2.
|
|
794
|
+
var CLIENT_VERSION2 = true ? "0.2.4" : "dev";
|
|
775
795
|
function provisionEnabled() {
|
|
776
796
|
const v = (process.env.FIDACY_DISABLE_PROVISION ?? "").trim().toLowerCase();
|
|
777
797
|
return !(v === "1" || v === "true" || v === "yes");
|
|
@@ -920,6 +940,202 @@ function activationBootLine() {
|
|
|
920
940
|
}
|
|
921
941
|
return ` Anonymous trial: ${used}/${FREE_DECISIONS} free decisions used; after that the firewall requires a free API key.`;
|
|
922
942
|
}
|
|
943
|
+
|
|
944
|
+
// src/reporting.ts
|
|
945
|
+
function withinDays(records, days, now = Date.now()) {
|
|
946
|
+
if (!Number.isFinite(days) || days <= 0) return [...records];
|
|
947
|
+
const cutoff = now - days * 864e5;
|
|
948
|
+
return records.filter((r) => {
|
|
949
|
+
const t = Date.parse(r.ts);
|
|
950
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
var addTo = (bucket, key, value) => {
|
|
954
|
+
bucket[key] = (bucket[key] ?? 0) + value;
|
|
955
|
+
};
|
|
956
|
+
function summarize(records, days, now = Date.now()) {
|
|
957
|
+
const rows = withinDays(records, days, now);
|
|
958
|
+
const out = {
|
|
959
|
+
window_days: days,
|
|
960
|
+
decisions: rows.length,
|
|
961
|
+
allowed: 0,
|
|
962
|
+
denied: 0,
|
|
963
|
+
allowed_totals: {},
|
|
964
|
+
blocked_totals: {},
|
|
965
|
+
denied_by_rule: {},
|
|
966
|
+
payees_paid: [],
|
|
967
|
+
amount_unknown: 0,
|
|
968
|
+
first_ts: rows.length ? rows[0].ts : null,
|
|
969
|
+
last_ts: rows.length ? rows[rows.length - 1].ts : null
|
|
970
|
+
};
|
|
971
|
+
const paid = /* @__PURE__ */ new Set();
|
|
972
|
+
for (const r of rows) {
|
|
973
|
+
const known = typeof r.amount === "number" && Number.isFinite(r.amount);
|
|
974
|
+
if (!known) out.amount_unknown += 1;
|
|
975
|
+
const ccy = r.currency ?? "unknown";
|
|
976
|
+
if (r.status === "ALLOW") {
|
|
977
|
+
out.allowed += 1;
|
|
978
|
+
if (known) addTo(out.allowed_totals, ccy, r.amount);
|
|
979
|
+
if (r.payee) paid.add(r.payee);
|
|
980
|
+
} else {
|
|
981
|
+
out.denied += 1;
|
|
982
|
+
if (known) addTo(out.blocked_totals, ccy, r.amount);
|
|
983
|
+
addTo(out.denied_by_rule, r.violatedRule ?? "not_recorded", 1);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
out.payees_paid = [...paid].sort();
|
|
987
|
+
return out;
|
|
988
|
+
}
|
|
989
|
+
var money = (totals) => {
|
|
990
|
+
const parts = Object.entries(totals).map(([ccy, v]) => `${v.toLocaleString("en-US")} ${ccy}`);
|
|
991
|
+
return parts.length ? parts.join(", ") : "0";
|
|
992
|
+
};
|
|
993
|
+
function renderSummary(s) {
|
|
994
|
+
if (s.decisions === 0) {
|
|
995
|
+
return `No agent payment decisions in the last ${s.window_days} day(s). The firewall is active and has nothing to report, which is the expected state until an agent attempts a payment.`;
|
|
996
|
+
}
|
|
997
|
+
const lines = [
|
|
998
|
+
`Last ${s.window_days} day(s): ${s.decisions} payment decision(s), ${s.allowed} allowed and ${s.denied} blocked before execution.`,
|
|
999
|
+
`Paid: ${money(s.allowed_totals)}${s.payees_paid.length ? ` across ${s.payees_paid.length} payee(s): ${s.payees_paid.slice(0, 8).join(", ")}${s.payees_paid.length > 8 ? ", \u2026" : ""}` : ""}.`
|
|
1000
|
+
];
|
|
1001
|
+
if (s.denied > 0) {
|
|
1002
|
+
const ranked = Object.entries(s.denied_by_rule).sort((a, b) => b[1] - a[1]);
|
|
1003
|
+
lines.push(`Blocked: ${money(s.blocked_totals)} in attempted value. By rule: ${ranked.map(([rule, n]) => `${rule} (${n})`).join(", ")}.`);
|
|
1004
|
+
}
|
|
1005
|
+
if (s.amount_unknown > 0) {
|
|
1006
|
+
lines.push(`${s.amount_unknown} record(s) in this window predate amount logging, so the totals above are a floor, not the exact figure.`);
|
|
1007
|
+
}
|
|
1008
|
+
return lines.join("\n");
|
|
1009
|
+
}
|
|
1010
|
+
function toRows(records, status, limit) {
|
|
1011
|
+
const filtered = status === "all" ? [...records] : records.filter((r) => r.status === status);
|
|
1012
|
+
return filtered.slice(-Math.max(1, limit)).reverse().map((r) => ({
|
|
1013
|
+
seq: r.seq,
|
|
1014
|
+
decisionId: r.decisionId,
|
|
1015
|
+
status: r.status,
|
|
1016
|
+
ts: r.ts,
|
|
1017
|
+
payee: r.payee,
|
|
1018
|
+
amount: r.amount,
|
|
1019
|
+
currency: r.currency,
|
|
1020
|
+
purpose: r.purpose,
|
|
1021
|
+
violatedRule: r.violatedRule,
|
|
1022
|
+
invoiceRef: r.invoiceRef
|
|
1023
|
+
}));
|
|
1024
|
+
}
|
|
1025
|
+
function renderRows(rows) {
|
|
1026
|
+
if (!rows.length) return "No decisions match that filter yet.";
|
|
1027
|
+
return rows.map((r) => {
|
|
1028
|
+
const amount = typeof r.amount === "number" ? `${r.amount.toLocaleString("en-US")} ${r.currency ?? ""}`.trim() : "amount not recorded";
|
|
1029
|
+
const who = r.payee ?? "payee not recorded";
|
|
1030
|
+
const why = r.status === "DENY" ? ` blocked by ${r.violatedRule ?? "a rule not recorded on this older entry"}.` : "";
|
|
1031
|
+
const said = r.purpose ? ` Agent's stated reason: "${r.purpose}".` : "";
|
|
1032
|
+
return `#${r.seq} ${r.ts} ${r.status} ${amount} to ${who}.${why}${said} (decision ${r.decisionId})`;
|
|
1033
|
+
}).join("\n");
|
|
1034
|
+
}
|
|
1035
|
+
function explainRule(rule, payee) {
|
|
1036
|
+
if (!rule) return "This entry predates readable rule logging, so the specific rule is not on the record. The decision payload is still covered by the chain digest.";
|
|
1037
|
+
if (rule.startsWith("payee_not_in_allowlist"))
|
|
1038
|
+
return `The payee${payee ? ` "${payee}"` : ""} is not on the mandate's allowlist. This is the deny-by-default rule doing its job: an agent can only pay counterparties a human put on the list, which is what stops a swapped or lookalike vendor.`;
|
|
1039
|
+
if (rule.startsWith("category_not_allowed"))
|
|
1040
|
+
return "The stated purpose category is not one the mandate permits for this agent.";
|
|
1041
|
+
if (rule.startsWith("currency_not_allowed"))
|
|
1042
|
+
return "The payment currency is not the mandate's currency.";
|
|
1043
|
+
if (rule.startsWith("per_tx") || rule.includes("perTxMax"))
|
|
1044
|
+
return "The amount exceeds the per-transaction ceiling in the mandate.";
|
|
1045
|
+
if (rule.includes("maxTotal") || rule.startsWith("budget"))
|
|
1046
|
+
return "The payment would push cumulative spend past the mandate's total budget for the window.";
|
|
1047
|
+
if (rule.startsWith("invoice") || rule.includes("duplicate"))
|
|
1048
|
+
return "This invoice was already paid once. One payment per invoice is enforced regardless of amount, which is the control that stops a re-presented invoice at a higher figure.";
|
|
1049
|
+
if (rule.startsWith("window") || rule.includes("expired"))
|
|
1050
|
+
return "The mandate was outside its validity window at the moment of the request.";
|
|
1051
|
+
if (rule.startsWith("revoked")) return "The mandate had been revoked.";
|
|
1052
|
+
if (rule.startsWith("activation_required"))
|
|
1053
|
+
return "The anonymous trial had been used up, so the firewall failed closed. No money can move until the install is activated with a free API key.";
|
|
1054
|
+
return `The mandate rule "${rule}" was violated.`;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// src/sentinel.ts
|
|
1058
|
+
var SENTINEL = {
|
|
1059
|
+
/** amount >= SPIKE_FACTOR x the historical max ALLOW to the same payee. */
|
|
1060
|
+
SPIKE_FACTOR: 3,
|
|
1061
|
+
/** minimum prior ALLOWs to a payee before a spike is judged (else first_payee covers it). */
|
|
1062
|
+
SPIKE_MIN_HISTORY: 2,
|
|
1063
|
+
/** decisions within BURST_WINDOW_MS to trip velocity_burst. */
|
|
1064
|
+
BURST_COUNT: 5,
|
|
1065
|
+
BURST_WINDOW_MS: 10 * 6e4,
|
|
1066
|
+
/** fraction of perTxMax that counts as riding the ceiling. */
|
|
1067
|
+
CAP_CREEP_RATIO: 0.8,
|
|
1068
|
+
/** a DENY for the same payee within this window makes a new attempt a retry. */
|
|
1069
|
+
RETRY_WINDOW_MS: 30 * 6e4
|
|
1070
|
+
};
|
|
1071
|
+
var ts = (r) => {
|
|
1072
|
+
const t = Date.parse(r.ts);
|
|
1073
|
+
return Number.isFinite(t) ? t : 0;
|
|
1074
|
+
};
|
|
1075
|
+
function assessPattern(records, req, opts = {}) {
|
|
1076
|
+
const now = opts.now ?? Date.now();
|
|
1077
|
+
const alerts = [];
|
|
1078
|
+
const toPayee = records.filter((r) => r.payee === req.payee);
|
|
1079
|
+
const allowedToPayee = toPayee.filter((r) => r.status === "ALLOW");
|
|
1080
|
+
if (req.payee && allowedToPayee.length === 0) {
|
|
1081
|
+
alerts.push({
|
|
1082
|
+
rule: "first_payee",
|
|
1083
|
+
severity: "warning",
|
|
1084
|
+
message: `First payment ever from this agent to "${req.payee}". A swapped or lookalike vendor always looks like a first payee; worth a human glance before anything settles.`
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
if (allowedToPayee.length >= SENTINEL.SPIKE_MIN_HISTORY && typeof req.amount === "number") {
|
|
1088
|
+
const maxSeen = Math.max(...allowedToPayee.map((r) => typeof r.amount === "number" ? r.amount : 0));
|
|
1089
|
+
if (maxSeen > 0 && req.amount >= maxSeen * SENTINEL.SPIKE_FACTOR) {
|
|
1090
|
+
alerts.push({
|
|
1091
|
+
rule: "amount_spike",
|
|
1092
|
+
severity: "warning",
|
|
1093
|
+
message: `${req.amount.toLocaleString("en-US")} is ${(req.amount / maxSeen).toFixed(1)}x the largest amount this agent has ever paid "${req.payee}" (${maxSeen.toLocaleString("en-US")}). Inflated re-presentations of a known vendor look exactly like this.`
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
const recent = records.filter((r) => now - ts(r) <= SENTINEL.BURST_WINDOW_MS);
|
|
1098
|
+
if (recent.length + 1 >= SENTINEL.BURST_COUNT) {
|
|
1099
|
+
alerts.push({
|
|
1100
|
+
rule: "velocity_burst",
|
|
1101
|
+
severity: "warning",
|
|
1102
|
+
message: `${recent.length + 1} payment decisions inside ${Math.round(SENTINEL.BURST_WINDOW_MS / 6e4)} minutes. Runaway loops and drain attempts announce themselves as bursts before they announce themselves as losses.`
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
if (typeof opts.perTxMax === "number" && opts.perTxMax > 0 && typeof req.amount === "number") {
|
|
1106
|
+
if (req.amount >= opts.perTxMax * SENTINEL.CAP_CREEP_RATIO && req.amount <= opts.perTxMax) {
|
|
1107
|
+
alerts.push({
|
|
1108
|
+
rule: "cap_creep",
|
|
1109
|
+
severity: "notice",
|
|
1110
|
+
message: `${req.amount.toLocaleString("en-US")} is ${Math.round(req.amount / opts.perTxMax * 100)}% of the per-transaction ceiling. An attacker probing a mandate pays just under the cap; a human authorizing a real bill usually doesn't land this close.`
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const recentDeny = toPayee.find((r) => r.status === "DENY" && now - ts(r) <= SENTINEL.RETRY_WINDOW_MS);
|
|
1115
|
+
if (recentDeny) {
|
|
1116
|
+
alerts.push({
|
|
1117
|
+
rule: "retry_after_deny",
|
|
1118
|
+
severity: "warning",
|
|
1119
|
+
message: `This agent was DENIED a payment to "${req.payee}" ${Math.max(1, Math.round((now - ts(recentDeny)) / 6e4))} minute(s) ago (${recentDeny.violatedRule ?? "rule on record"}) and is now trying again. Our public Model Watch benchmark measures exactly this behavior; in an autonomous agent it is the pattern that precedes a loss.`
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
return alerts;
|
|
1123
|
+
}
|
|
1124
|
+
function sweep(records, opts = {}) {
|
|
1125
|
+
const out = [];
|
|
1126
|
+
for (let i = 0; i < records.length; i++) {
|
|
1127
|
+
const r = records[i];
|
|
1128
|
+
if (!r.payee) continue;
|
|
1129
|
+
const alerts = assessPattern(records.slice(0, i), { payee: r.payee, amount: r.amount }, { perTxMax: opts.perTxMax, now: ts(r) });
|
|
1130
|
+
if (alerts.length) out.push({ decisionId: r.decisionId, ts: r.ts, status: r.status, payee: r.payee, alerts });
|
|
1131
|
+
}
|
|
1132
|
+
return out.slice(-(opts.limit ?? 20)).reverse();
|
|
1133
|
+
}
|
|
1134
|
+
function renderAlertLine(alerts) {
|
|
1135
|
+
if (!alerts.length) return "";
|
|
1136
|
+
const worst = alerts.some((a) => a.severity === "warning") ? "SENTINEL WARNING" : "SENTINEL NOTICE";
|
|
1137
|
+
return ` [${worst}: ${alerts.map((a) => a.message).join(" Also: ")}]`;
|
|
1138
|
+
}
|
|
923
1139
|
export {
|
|
924
1140
|
DevFidacyCore,
|
|
925
1141
|
FREE_DECISIONS,
|
|
@@ -927,14 +1143,17 @@ export {
|
|
|
927
1143
|
GrantEnforcingExecutor,
|
|
928
1144
|
HttpFidacyCore,
|
|
929
1145
|
ReferenceRail,
|
|
1146
|
+
SENTINEL,
|
|
930
1147
|
activationBootLine,
|
|
931
1148
|
activationGate,
|
|
1149
|
+
assessPattern,
|
|
932
1150
|
autoProvision,
|
|
933
1151
|
canonInvoice,
|
|
934
1152
|
claimUrl,
|
|
935
1153
|
decisionNudge,
|
|
936
1154
|
ensureState,
|
|
937
1155
|
evaluate,
|
|
1156
|
+
explainRule,
|
|
938
1157
|
hasEngineKey,
|
|
939
1158
|
httpRedeem,
|
|
940
1159
|
installAgeDays,
|
|
@@ -948,16 +1167,23 @@ export {
|
|
|
948
1167
|
recordAgentActive,
|
|
949
1168
|
recordInstall,
|
|
950
1169
|
remainingFree,
|
|
1170
|
+
renderAlertLine,
|
|
1171
|
+
renderRows,
|
|
1172
|
+
renderSummary,
|
|
951
1173
|
requestUpgrade,
|
|
952
1174
|
requireHttpsBase,
|
|
953
1175
|
setTelemetryShell,
|
|
954
1176
|
sha256,
|
|
955
1177
|
sign,
|
|
956
1178
|
stableStringify,
|
|
1179
|
+
summarize,
|
|
1180
|
+
sweep,
|
|
1181
|
+
toRows,
|
|
957
1182
|
trialCountdownLine,
|
|
958
1183
|
upgradeUrl,
|
|
959
1184
|
validateRequest,
|
|
960
1185
|
verify,
|
|
961
1186
|
verifyGrant,
|
|
962
|
-
weekOneBootNudge
|
|
1187
|
+
weekOneBootNudge,
|
|
1188
|
+
withinDays
|
|
963
1189
|
};
|
package/package.json
CHANGED