@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 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.2" : "dev";
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 ts = (/* @__PURE__ */ new Date()).toISOString();
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 ts = decision.ts;
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}|${ts}`);
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.2" : "dev";
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.2" : "dev";
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.2" : "dev";
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 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:
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 ts = (/* @__PURE__ */ new Date()).toISOString();
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 ts = decision.ts;
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}|${ts}`);
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.2" : "dev";
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.2" : "dev";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fidacy/mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Fidacy action firewall for AI agents. Mandate-gated payment authorization as an MCP server.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://fidacy.com",