@fidacy/mcp 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.5
4
+
5
+ TypeScript declarations ship in the package. Every entrypoint (., ./assess,
6
+ ./core, ./lib) now resolves its own .d.ts through the exports map, with the
7
+ internal engine types inlined under dist/firewall/ so the tarball typechecks
8
+ with zero extra installs. No runtime changes.
9
+
3
10
  ## 0.2.0
4
11
 
5
12
  Activation gate: anonymous installs now get 20 free firewall decisions, then
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ACTIVATION GATE — the anonymous trial that ends in a free API key.
3
+ *
4
+ * Doctrine: the local firewall is free forever, but ANONYMOUS is a trial, not a
5
+ * home. An install gets FREE_DECISIONS payment-gate decisions with no key; after
6
+ * that the firewall FAILS CLOSED (every request_payment is denied with
7
+ * `activation_required`) until the operator activates it with a free API key
8
+ * from the claim page. Fail-closed is the safe direction for a firewall — no
9
+ * money ever moves because a trial expired — and the deny message is the one
10
+ * surface an agent reliably relays to its human.
11
+ *
12
+ * Activation signal: FIDACY_ENGINE_API_KEY (env, or the plugin's engineApiKey
13
+ * config). Deliberately NOT config.json's auto-provisioned api_key — that one is
14
+ * granted silently in the background for attribution and proves nothing about a
15
+ * human ever visiting the dashboard. Setting the env var is exactly the wiring a
16
+ * real client needs anyway (it powers assess_action / anchor_artifact).
17
+ */
18
+ export declare const FREE_DECISIONS = 20;
19
+ /** True when the install carries an operator-set engine key (env or shell config). */
20
+ export declare function hasEngineKey(keyOverride?: string): boolean;
21
+ /**
22
+ * Payment-gate decisions taken so far on this install: the on-disk counter OR
23
+ * the in-process one, whichever is higher. The in-process floor is what keeps
24
+ * the gate fail-CLOSED when storage breaks: with an unreadable/corrupt
25
+ * config.json or a read-only disk the on-disk counter freezes at 0, and
26
+ * without the floor the trial would silently become unlimited.
27
+ */
28
+ export declare function decisionsUsed(): number;
29
+ /** Free anonymous decisions left before the gate closes. */
30
+ export declare function remainingFree(): number;
31
+ /**
32
+ * The gate check, to run BEFORE deciding a payment. Null ⇒ proceed normally.
33
+ * Non-null ⇒ do NOT decide; return the message as a DENY with violatedRule
34
+ * `activation_required`. Records upgrade_intent so every gate hit is measurable
35
+ * in the funnel (installs that reached the wall vs installs that claimed).
36
+ */
37
+ export declare function activationGate(keyOverride?: string): {
38
+ message: string;
39
+ } | null;
40
+ /**
41
+ * Countdown line appended to normal decision responses while unactivated. Fires
42
+ * on EVERY decision once 5 or fewer remain (a fair wall is announced, not
43
+ * sprung), and marks the final free decision explicitly. Null when keyed, when
44
+ * plenty of trial remains, or when the count is unknowable (no persisted config
45
+ * means the counter never advances, so no wall is coming either).
46
+ */
47
+ export declare function trialCountdownLine(keyOverride?: string): string | null;
48
+ /** One boot-banner sentence describing trial state; null when keyed. */
49
+ export declare function activationBootLine(): string | null;
@@ -0,0 +1,44 @@
1
+ export declare const ARTIFACT_KINDS: readonly ["contract", "invoice", "prescription", "claim", "document", "image", "audio", "video", "conversation", "custom"];
2
+ export type ArtifactKind = (typeof ARTIFACT_KINDS)[number];
3
+ export interface AnchorResult {
4
+ artifactId: string;
5
+ kind: string;
6
+ sha256: string;
7
+ subject: string;
8
+ label: string | null;
9
+ ts: string;
10
+ digest: string;
11
+ audit: {
12
+ seq: number;
13
+ hash: string;
14
+ };
15
+ anchor: {
16
+ status: string;
17
+ };
18
+ receipt: string;
19
+ [k: string]: unknown;
20
+ }
21
+ /** SHA-256 (hex) of a file, streamed from disk. The content never leaves the machine. */
22
+ export declare function hashFile(path: string): Promise<string>;
23
+ /** Register a locally-computed hash on the anchored audit chain. */
24
+ export declare function anchorArtifact(params: {
25
+ sha256: string;
26
+ kind: ArtifactKind;
27
+ label?: string;
28
+ subject?: string;
29
+ }, cfg: {
30
+ engineUrl: string;
31
+ apiKey: string;
32
+ }): Promise<AnchorResult>;
33
+ /** Look up whether a hash was anchored by this org, newest first. */
34
+ export declare function findArtifacts(sha256: string, cfg: {
35
+ engineUrl: string;
36
+ apiKey: string;
37
+ }): Promise<{
38
+ artifacts: Array<Record<string, unknown>>;
39
+ }>;
40
+ /** Fetch one artifact with its chain record and covering Bitcoin checkpoint. */
41
+ export declare function getArtifact(id: string, cfg: {
42
+ engineUrl: string;
43
+ apiKey: string;
44
+ }): Promise<Record<string, unknown>>;
@@ -0,0 +1,86 @@
1
+ export type AssessKind = "ap2_payment" | "message_send" | "voice_call" | "custom" | "claim_document";
2
+ export interface AssessParams {
3
+ /** Action kind. Defaults to `ap2_payment`. */
4
+ kind?: AssessKind;
5
+ /** The action/mandate object for this kind. */
6
+ mandate: unknown;
7
+ /** Optional explicit mandate type. The engine derives it otherwise. */
8
+ mandateType?: string;
9
+ /** Optional A2A correlation block. Adds the `A2A-Version` header when present. */
10
+ a2a?: {
11
+ task_id: string;
12
+ };
13
+ /** Optional spending mandate. Sent as snake_case `spending_mandate`. */
14
+ spendingMandate?: unknown;
15
+ /** Optional idempotency key. Sent as snake_case `idempotency_key`. Enables retry. */
16
+ idempotencyKey?: string;
17
+ }
18
+ export interface AssessResult {
19
+ decision: "approve" | "review" | "deny";
20
+ score: number;
21
+ assessmentId: string;
22
+ mandateId: string;
23
+ riskPayloadJws: string;
24
+ riskPayload: Record<string, unknown>;
25
+ signingKeyId: string;
26
+ signals: Record<string, unknown>;
27
+ mandate: Record<string, unknown>;
28
+ outcome: Record<string, unknown>;
29
+ billing?: Record<string, unknown>;
30
+ spend_guard?: Record<string, unknown>;
31
+ a2a?: Record<string, unknown>;
32
+ [k: string]: unknown;
33
+ }
34
+ export interface RejectionReason {
35
+ key: string;
36
+ category?: string;
37
+ message?: string;
38
+ description?: string;
39
+ }
40
+ /**
41
+ * Typed error from the engine. `message` is STATIC and never contains the key or
42
+ * request body. `type` is the stable engine error code (or a client code), and
43
+ * `status` is the HTTP status (0 for network/timeout).
44
+ */
45
+ export declare class AssessError extends Error {
46
+ readonly type: string;
47
+ readonly status: number;
48
+ readonly details?: unknown;
49
+ readonly rejection_reasons?: RejectionReason[];
50
+ constructor(opts: {
51
+ type: string;
52
+ status: number;
53
+ details?: unknown;
54
+ rejection_reasons?: RejectionReason[];
55
+ });
56
+ }
57
+ export interface AssessConfig {
58
+ /** Base URL of the engine, e.g. `https://api.fidacy.com`. Trailing slashes stripped. */
59
+ engineUrl: string;
60
+ /** API key (`fky_live_…`/`fky_test_…`) with scope `assess:write`. Never logged. */
61
+ apiKey: string;
62
+ /** Custom fetch (tests, edge runtimes). Default: `globalThis.fetch`. */
63
+ fetchImpl?: typeof fetch;
64
+ /** Per-request timeout in ms. Default 10000. */
65
+ timeoutMs?: number;
66
+ /** Max retries on transient failures (only when an idempotency key is set). Default 2. */
67
+ maxRetries?: number;
68
+ /** Backoff (ms) for attempt n. Default exponential base 200ms cap 2000ms. */
69
+ backoffMs?: (n: number) => number;
70
+ }
71
+ /**
72
+ * Validate the engine base URL BEFORE any key-bearing request is sent to it.
73
+ * Requires https:// (the only safe transport for a Bearer token), allowing
74
+ * http://localhost / 127.0.0.1 only for local development. A non-parseable,
75
+ * non-https, or non-local-http URL is rejected — this is the guard against a
76
+ * hostile FIDACY_ENGINE_URL exfiltrating the API key. Returns the trimmed origin+path.
77
+ */
78
+ export declare function requireSafeBaseUrl(raw: string): string;
79
+ /**
80
+ * Call the live engine's POST /v1/assess and return the signed trust verdict.
81
+ *
82
+ * Retries ONLY when an idempotency key was supplied, on 5xx/429/network/timeout,
83
+ * with small exponential backoff and a per-request AbortController timeout. This
84
+ * mirrors the @fidacy/sdk request() behavior. Self-contained; no sdk import.
85
+ */
86
+ export declare function assessAction(params: AssessParams, cfg: AssessConfig): Promise<AssessResult>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Local-first config for @fidacy/mcp (Zero-Friction Onboarding, Fase 1).
3
+ *
4
+ * The free tier decides ALLOW/DENY entirely on the client, with NO network on the
5
+ * critical path. This module resolves the on-disk state so the firewall can start
6
+ * protecting the instant the process boots:
7
+ *
8
+ * 1. read ~/.fidacy/config.json (0600) if present,
9
+ * 2. otherwise create a free-local config immediately (anon_id, tier=free, no key)
10
+ * WITHOUT blocking on the network — auto-provisioning (a server key) is a later
11
+ * phase and always happens in the background, never gating startup.
12
+ *
13
+ * Precedence for the local mandate/rules is: env vars > config.json > safe default
14
+ * (deny-unknown payee + a per-tx cap). See resolveMandateRules().
15
+ */
16
+ /** Optional user-authored rules stored in config.json under `mandate`. */
17
+ export interface MandateRules {
18
+ payees?: string[];
19
+ categories?: string[];
20
+ currency?: string;
21
+ perTxMax?: number;
22
+ maxTotal?: number;
23
+ }
24
+ export interface FidacyConfig {
25
+ /** Anonymous, non-PII install identifier. Also the telemetry key (later phase). */
26
+ anon_id: string;
27
+ /** Which layer decides. `free` = local-only; `paid` = server-backed (later). */
28
+ tier: "free" | "paid";
29
+ /** Server key. null on the free local tier until provisioning/upgrade sets it. */
30
+ api_key: string | null;
31
+ /** Optional local rules. Absent ⇒ safe default (deny-unknown payee + cap). */
32
+ mandate?: MandateRules;
33
+ /** First-run timestamp (ISO). Powers time-based nudges; absent on old installs. */
34
+ created_at?: string;
35
+ /** Upgrade micro-triggers already shown — each fires ONCE per install, ever. */
36
+ nudges?: Partial<Record<string, boolean>>;
37
+ /** Payment-gate decisions taken on this install (milestone nudges). */
38
+ decisions_count?: number;
39
+ }
40
+ /** Config dir: FIDACY_CONFIG_DIR override, else ~/.fidacy (XDG-friendly enough). */
41
+ export declare function configDir(): string;
42
+ export declare function configPath(): string;
43
+ /** Audit lives under the config dir by default; FIDACY_AUDIT_PATH overrides. */
44
+ export declare function auditLogPath(): string;
45
+ /** Read config.json. Never throws: returns null if absent OR malformed. */
46
+ export declare function readConfig(): FidacyConfig | null;
47
+ /** Persist config.json with restrictive perms (dir 0700, file 0600). Best-effort. */
48
+ export declare function writeConfig(cfg: FidacyConfig): void;
49
+ /**
50
+ * Resolve on-disk state at boot WITHOUT touching the network.
51
+ * Existing valid config ⇒ use it. Otherwise create a free-local config now
52
+ * (so protection is live immediately) and mark firstRun so the caller can kick
53
+ * off background auto-provisioning in a later phase.
54
+ */
55
+ export declare function ensureState(): {
56
+ config: FidacyConfig;
57
+ firstRun: boolean;
58
+ };
59
+ /**
60
+ * Resolve the effective local mandate rules with precedence env > config > default.
61
+ * DEFAULT IS DENY-UNKNOWN: an empty payee allowlist means every payee is denied
62
+ * offline out of the box (the BEC case), with a per-tx cap. Categories default to
63
+ * "*" so the only zero-config gates are payee-allowlist + caps. Users add trusted
64
+ * payees via config.json (or env) to allow real payments.
65
+ */
66
+ export declare function resolveMandateRules(cfg: FidacyConfig | null): Required<MandateRules>;
package/dist/core.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { FidacyCore, Mandate } from "./firewall/index.js";
2
+ import { DevFidacyCore } from "./firewall/index.js";
3
+ export { DevFidacyCore };
4
+ export type { Mandate };
5
+ /**
6
+ * Wire the shared engine to this shell: the local reference core (free tier) or the
7
+ * HTTP-backed core (paid). Telemetry is injected as a per-decision hook, so the
8
+ * engine stays fully decoupled from the shell's config/telemetry.
9
+ */
10
+ export declare function makeCore(): FidacyCore;
package/dist/core.js CHANGED
@@ -461,7 +461,7 @@ function resolveMandateRules(cfg) {
461
461
  }
462
462
 
463
463
  // src/telemetry.ts
464
- var CLIENT_VERSION = true ? "0.2.3" : "dev";
464
+ var CLIENT_VERSION = true ? "0.2.5" : "dev";
465
465
  function bandOf(amount) {
466
466
  if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) return void 0;
467
467
  if (amount < 10) return "lt10";
@@ -0,0 +1,13 @@
1
+ import { AuditRecord, Decision } from "./types.js";
2
+ export declare class FileAuditStore {
3
+ private path;
4
+ private chain;
5
+ constructor(path: string);
6
+ private load;
7
+ private head;
8
+ append(decision: Decision): AuditRecord;
9
+ find(decisionId: string): AuditRecord | undefined;
10
+ /** The full loaded chain, oldest first. Read-only; used to rehydrate core state at boot. */
11
+ records(): readonly AuditRecord[];
12
+ intact(): boolean;
13
+ }
@@ -0,0 +1,79 @@
1
+ import { Mandate, PaymentRequest, Decision, AuditProof, AuditRecord } from "./types.js";
2
+ /**
3
+ * Canonicalize an invoice reference so the "one payment per invoice" (BEC) guard
4
+ * cannot be bypassed by case, surrounding/zero-width whitespace, or Unicode form.
5
+ * Returns "" for absent/blank refs (no dedup possible without an identity).
6
+ */
7
+ export declare function canonInvoice(ref: unknown): string;
8
+ /** Reject structurally-abusive payment requests before any cap math. Returns a
9
+ * violation code, or null when the request is well-formed. */
10
+ export declare function validateRequest(req: PaymentRequest): string | null;
11
+ /** Enforce a trusted transport for a base URL that will carry a Bearer key: https
12
+ * only, or http://localhost for dev. Throws on anything else. */
13
+ export declare function requireHttpsBase(raw: string): string;
14
+ export interface FidacyCore {
15
+ getMandate(subject: string): Promise<Mandate>;
16
+ decide(req: PaymentRequest, subject: string): Promise<Decision>;
17
+ getProof(decisionId: string): Promise<AuditProof | null>;
18
+ publicKey(): string;
19
+ /**
20
+ * Recent decisions from the audit chain, oldest first, capped at `limit`.
21
+ * Read-only and never gated: an operator asking what their agents did is not
22
+ * a money-moving action, so it must answer even when the firewall would deny
23
+ * a payment. This is what turns the audit chain from a forensic artifact into
24
+ * something a human can ask about in plain language.
25
+ */
26
+ history(limit?: number): Promise<readonly AuditRecord[]>;
27
+ }
28
+ export interface DevFidacyCoreOptions {
29
+ mandate: Mandate;
30
+ auditLogPath?: string;
31
+ /** Called after each decision is recorded. Best-effort; must never throw. */
32
+ onDecision?: (decision: Decision) => void;
33
+ }
34
+ export declare class DevFidacyCore implements FidacyCore {
35
+ private priv;
36
+ private pubPem;
37
+ private mandate;
38
+ private store;
39
+ private spent;
40
+ private claimedInvoices;
41
+ private onDecision?;
42
+ constructor(opts: DevFidacyCoreOptions);
43
+ /**
44
+ * Rebuild the in-memory decision state from the persisted audit log, so a process
45
+ * restart cannot re-open a claimed invoice (BEC dedup) or reset the maxTotal
46
+ * counter. One O(records) pass at boot over the already-loaded chain:
47
+ * - claimedInvoices: every ALLOW that carried an invoiceRef, regardless of age —
48
+ * a paid invoice stays paid.
49
+ * - spent: ALLOW amounts whose timestamp falls inside the CURRENT mandate window
50
+ * and whose currency matches the mandate (the cap is defined per window/currency).
51
+ * Records from older versions lack the rehydration fields and are skipped.
52
+ */
53
+ private rehydrate;
54
+ /**
55
+ * Swap the active mandate (shell config hot-reload: edit config.json, the next
56
+ * call picks it up, no host restart). Decision state is REBUILT from the audit
57
+ * log under the new window/currency, so a reload can never reset the BEC
58
+ * invoice dedup or undercount spend: a paid invoice stays paid, spent is
59
+ * recomputed.
60
+ */
61
+ setMandate(m: Mandate): void;
62
+ getMandate(): Promise<Mandate>;
63
+ decide(req: PaymentRequest, subject: string): Promise<Decision>;
64
+ getProof(decisionId: string): Promise<AuditProof | null>;
65
+ history(limit?: number): Promise<readonly AuditRecord[]>;
66
+ publicKey(): string;
67
+ }
68
+ export declare class HttpFidacyCore implements FidacyCore {
69
+ private apiKey;
70
+ private subjectPub;
71
+ private baseUrl;
72
+ constructor(baseUrl: string, apiKey: string, subjectPub?: string);
73
+ private call;
74
+ getMandate(subject: string): Promise<Mandate>;
75
+ decide(req: PaymentRequest, subject: string): Promise<Decision>;
76
+ getProof(decisionId: string): Promise<AuditProof | null>;
77
+ history(limit?: number): Promise<readonly AuditRecord[]>;
78
+ publicKey(): string;
79
+ }
@@ -0,0 +1,10 @@
1
+ import { Mandate, PaymentRequest } from "./types.js";
2
+ /**
3
+ * Is `payee` a lookalike of one of the `allowed` payees (an impersonation attempt),
4
+ * without being an exact allow-listed match? Returns the impersonated approved
5
+ * payee, or null. Two signals: an identical homoglyph fold (the classic digit-swap),
6
+ * or a tiny edit distance on the fold (insert/delete/transpose a char). Gated on a
7
+ * min length so short, genuinely-distinct payees don't false-positive.
8
+ */
9
+ export declare function lookalikePayee(payee: string, allowed: string[]): string | null;
10
+ export declare function evaluate(mandate: Mandate, req: PaymentRequest, spentSoFar: number): string | null;
@@ -0,0 +1,49 @@
1
+ import { PaymentRequest } from "./types.js";
2
+ export interface Rail {
3
+ execute(req: PaymentRequest): Promise<{
4
+ railRef: string;
5
+ }>;
6
+ }
7
+ export declare class ReferenceRail implements Rail {
8
+ settlements: Array<PaymentRequest & {
9
+ railRef: string;
10
+ at: string;
11
+ }>;
12
+ execute(req: PaymentRequest): Promise<{
13
+ railRef: string;
14
+ }>;
15
+ }
16
+ export type ExecResult = {
17
+ status: "EXECUTED";
18
+ railRef: string;
19
+ decisionId: string;
20
+ } | {
21
+ status: "REFUSED";
22
+ reason: string;
23
+ };
24
+ /**
25
+ * Durable single-use claim. Returns true if this is the FIRST redemption of the
26
+ * grant's decisionId, false on any replay. `httpRedeem` binds it to the hosted
27
+ * core's POST /v1/grant/redeem so single-use survives a serverless multi-instance
28
+ * deployment (the in-memory Set does not — C2).
29
+ */
30
+ export type RedeemFn = (grant: string) => Promise<boolean>;
31
+ export declare function httpRedeem(coreUrl: string, apiKey: string): RedeemFn;
32
+ export declare class GrantEnforcingExecutor {
33
+ private rail;
34
+ private used;
35
+ private pub;
36
+ private redeem?;
37
+ constructor(publicKeyPem: string, rail: Rail, opts?: {
38
+ redeem?: RedeemFn;
39
+ /**
40
+ * Escape hatch for genuinely single-process deployments (a local CLI, a
41
+ * single long-lived worker) where the in-memory Set IS authoritative. Must
42
+ * be set EXPLICITLY to run without a durable `redeem` in production —
43
+ * otherwise the constructor throws, because on serverless/multi-instance the
44
+ * in-memory Set does not stop a replayed grant from double-settling.
45
+ */
46
+ allowUnsafeInMemorySingleUse?: boolean;
47
+ });
48
+ execute(req: PaymentRequest, grant: string | undefined): Promise<ExecResult>;
49
+ }
@@ -0,0 +1,20 @@
1
+ export interface GrantPayload {
2
+ decisionId: string;
3
+ subject: string;
4
+ payee: string;
5
+ amount: number;
6
+ currency: string;
7
+ exp: number;
8
+ invoiceRef?: string;
9
+ }
10
+ export interface GrantCheck {
11
+ valid: boolean;
12
+ reason?: string;
13
+ payload?: GrantPayload;
14
+ }
15
+ export declare function verifyGrant(publicKeyPem: string, grant: string, expected: {
16
+ payee: string;
17
+ amount: number;
18
+ currency: string;
19
+ invoiceRef?: string;
20
+ }): GrantCheck;
@@ -0,0 +1,8 @@
1
+ export * from "./types.js";
2
+ export * from "./util.js";
3
+ export * from "./signing.js";
4
+ export * from "./grant.js";
5
+ export * from "./audit-store.js";
6
+ export * from "./executor.js";
7
+ export * from "./evaluate.js";
8
+ export * from "./core.js";
@@ -0,0 +1,10 @@
1
+ import { KeyObject } from "node:crypto";
2
+ export declare function loadOrGenerateKeyPair(): {
3
+ privateKey: KeyObject;
4
+ publicKey: KeyObject;
5
+ ephemeral: boolean;
6
+ };
7
+ export declare function publicKeyPem(publicKey: KeyObject): string;
8
+ export declare function sign(privateKey: KeyObject, message: string): string;
9
+ export declare function verify(publicKey: KeyObject, message: string, signatureB64url: string): boolean;
10
+ export declare function sha256(input: string): string;
@@ -0,0 +1,58 @@
1
+ export type Mandate = {
2
+ id: string;
3
+ subject: string;
4
+ version: string;
5
+ allow: {
6
+ payees: string[];
7
+ categories: string[];
8
+ currency: string;
9
+ maxTotal: number;
10
+ perTxMax: number;
11
+ };
12
+ window: {
13
+ notBefore: string;
14
+ notAfter: string;
15
+ };
16
+ revoked: boolean;
17
+ };
18
+ export type PaymentRequest = {
19
+ payee: string;
20
+ amount: number;
21
+ currency: string;
22
+ purpose: string;
23
+ category: string;
24
+ idempotencyKey: string;
25
+ invoiceRef?: string;
26
+ };
27
+ export type DecisionStatus = "ALLOW" | "DENY";
28
+ export type Decision = {
29
+ decisionId: string;
30
+ status: DecisionStatus;
31
+ subject: string;
32
+ mandateId: string;
33
+ request: PaymentRequest;
34
+ violatedRule?: string;
35
+ grant?: string;
36
+ ts: string;
37
+ };
38
+ export type AuditRecord = {
39
+ seq: number;
40
+ decisionId: string;
41
+ status: DecisionStatus;
42
+ subject: string;
43
+ digest: string;
44
+ prevHash: string;
45
+ hash: string;
46
+ ts: string;
47
+ amount?: number;
48
+ currency?: string;
49
+ invoiceRef?: string;
50
+ purpose?: string;
51
+ payee?: string;
52
+ violatedRule?: string;
53
+ };
54
+ export type AuditProof = {
55
+ record: AuditRecord;
56
+ chainIntact: boolean;
57
+ verifiedAgainstPublicKey: string;
58
+ };
@@ -0,0 +1 @@
1
+ export declare function stableStringify(obj: unknown): string;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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);
@@ -358,10 +358,10 @@ var FileAuditStore = class {
358
358
  append(decision) {
359
359
  const prevHash = this.head();
360
360
  const seq = this.chain.length;
361
- const ts = decision.ts;
361
+ const ts2 = decision.ts;
362
362
  const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
363
- const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
364
- 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 };
365
365
  if (typeof decision.request?.purpose === "string" && decision.request.purpose.trim()) {
366
366
  record2.purpose = decision.request.purpose.slice(0, 500);
367
367
  }
@@ -490,7 +490,7 @@ function resolveMandateRules(cfg) {
490
490
  }
491
491
 
492
492
  // src/telemetry.ts
493
- var CLIENT_VERSION = true ? "0.2.3" : "dev";
493
+ var CLIENT_VERSION = true ? "0.2.5" : "dev";
494
494
  function bandOf(amount) {
495
495
  if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) return void 0;
496
496
  if (amount < 10) return "lt10";
@@ -834,7 +834,7 @@ async function findArtifacts(sha2562, cfg) {
834
834
  }
835
835
 
836
836
  // src/provision.ts
837
- var CLIENT_VERSION2 = true ? "0.2.3" : "dev";
837
+ var CLIENT_VERSION2 = true ? "0.2.5" : "dev";
838
838
  function provisionEnabled() {
839
839
  const v = (process.env.FIDACY_DISABLE_PROVISION ?? "").trim().toLowerCase();
840
840
  return !(v === "1" || v === "true" || v === "yes");
@@ -1118,11 +1118,94 @@ function explainRule(rule, payee) {
1118
1118
  return `The mandate rule "${rule}" was violated.`;
1119
1119
  }
1120
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
+
1121
1204
  // src/index.ts
1122
1205
  var state = ensureState();
1123
1206
  var core = makeCore();
1124
1207
  var subject = process.env.FIDACY_SUBJECT ?? "agent:demo";
1125
- var SERVER_VERSION = true ? "0.2.3" : "dev";
1208
+ var SERVER_VERSION = true ? "0.2.5" : "dev";
1126
1209
  var bootClaim = claimUrl();
1127
1210
  var server = new McpServer(
1128
1211
  { name: "fidacy", version: SERVER_VERSION },
@@ -1163,9 +1246,16 @@ server.registerTool(
1163
1246
  return { content: [{ type: "text", text: gate.message }], structuredContent: out2 };
1164
1247
  }
1165
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
+ }
1166
1255
  const d = await core.decide(req, subject);
1167
1256
  const out = { status: d.status, decisionId: d.decisionId, grant: d.grant, violatedRule: d.violatedRule };
1168
- 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:
1169
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.` : ""}`;
1170
1260
  const nudge = decisionNudge(d.status, d.violatedRule, "upgrade");
1171
1261
  const countdown = trialCountdownLine();
@@ -1284,6 +1374,26 @@ Proof: entry #${record2.seq} in the hash-chained audit, chain currently ${proof.
1284
1374
  return { content: [{ type: "text", text: head + why + said + integrity }], structuredContent: payload };
1285
1375
  }
1286
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
+ );
1287
1397
  server.registerTool(
1288
1398
  "assess_action",
1289
1399
  {
package/dist/lib.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * from "./firewall/index.js";
2
+ export { makeCore } from "./core.js";
3
+ export { ensureState, readConfig } from "./config.js";
4
+ export { requestUpgrade, upgradeUrl } from "./upgrade.js";
5
+ export { autoProvision } from "./provision.js";
6
+ export { recordInstall, recordAgentActive, setTelemetryShell } from "./telemetry.js";
7
+ export { decisionNudge, weekOneBootNudge, nudgeOnce, installAgeDays, claimUrl, noKeyCta } from "./nudges.js";
8
+ export { FREE_DECISIONS, activationGate, activationBootLine, trialCountdownLine, hasEngineKey, remainingFree } from "./activation.js";
9
+ export { summarize, renderSummary, toRows, renderRows, explainRule, withinDays } from "./reporting.js";
10
+ export type { SpendSummary, DecisionRow } from "./reporting.js";
11
+ export { assessPattern, sweep, renderAlertLine, SENTINEL } from "./sentinel.js";
12
+ export type { SentinelAlert } from "./sentinel.js";
13
+ export type { TelemetryShell } from "./telemetry.js";
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);
@@ -389,10 +389,10 @@ var FileAuditStore = class {
389
389
  append(decision) {
390
390
  const prevHash = this.head();
391
391
  const seq = this.chain.length;
392
- const ts = decision.ts;
392
+ const ts2 = decision.ts;
393
393
  const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
394
- const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
395
- 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 };
396
396
  if (typeof decision.request?.purpose === "string" && decision.request.purpose.trim()) {
397
397
  record2.purpose = decision.request.purpose.slice(0, 500);
398
398
  }
@@ -613,7 +613,7 @@ function resolveMandateRules(cfg) {
613
613
  }
614
614
 
615
615
  // src/telemetry.ts
616
- var CLIENT_VERSION = true ? "0.2.3" : "dev";
616
+ var CLIENT_VERSION = true ? "0.2.5" : "dev";
617
617
  function bandOf(amount) {
618
618
  if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) return void 0;
619
619
  if (amount < 10) return "lt10";
@@ -791,7 +791,7 @@ function requestUpgrade() {
791
791
  }
792
792
 
793
793
  // src/provision.ts
794
- var CLIENT_VERSION2 = true ? "0.2.3" : "dev";
794
+ var CLIENT_VERSION2 = true ? "0.2.5" : "dev";
795
795
  function provisionEnabled() {
796
796
  const v = (process.env.FIDACY_DISABLE_PROVISION ?? "").trim().toLowerCase();
797
797
  return !(v === "1" || v === "true" || v === "yes");
@@ -1053,6 +1053,89 @@ function explainRule(rule, payee) {
1053
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
1054
  return `The mandate rule "${rule}" was violated.`;
1055
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
+ }
1056
1139
  export {
1057
1140
  DevFidacyCore,
1058
1141
  FREE_DECISIONS,
@@ -1060,8 +1143,10 @@ export {
1060
1143
  GrantEnforcingExecutor,
1061
1144
  HttpFidacyCore,
1062
1145
  ReferenceRail,
1146
+ SENTINEL,
1063
1147
  activationBootLine,
1064
1148
  activationGate,
1149
+ assessPattern,
1065
1150
  autoProvision,
1066
1151
  canonInvoice,
1067
1152
  claimUrl,
@@ -1082,6 +1167,7 @@ export {
1082
1167
  recordAgentActive,
1083
1168
  recordInstall,
1084
1169
  remainingFree,
1170
+ renderAlertLine,
1085
1171
  renderRows,
1086
1172
  renderSummary,
1087
1173
  requestUpgrade,
@@ -1091,6 +1177,7 @@ export {
1091
1177
  sign,
1092
1178
  stableStringify,
1093
1179
  summarize,
1180
+ sweep,
1094
1181
  toRows,
1095
1182
  trialCountdownLine,
1096
1183
  upgradeUrl,
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Micro-triggers (upgrade funnel, elegant by doctrine):
3
+ *
4
+ * 1. A trigger fires ONLY at a value-proven moment — right after the firewall
5
+ * did something for the user (a block, a milestone, a proof).
6
+ * 2. Each dynamic trigger fires ONCE per install, ever (flags persisted in
7
+ * ~/.fidacy/config.json). Elegance = never nag twice; the free tier stays
8
+ * fully usable in silence.
9
+ * 3. Every trigger routes through the shell's upgrade tool, so intent is
10
+ * recorded (upgrade_intent telemetry) and the anon binding travels along.
11
+ *
12
+ * All writes are best-effort: a read-only disk silences nudges, never the firewall.
13
+ */
14
+ export type NudgeKey = "first_deny" | "first_allow" | "bec_catch" | "milestone_10" | "week_1" | "proof_teaser" | "hosted_wall";
15
+ /**
16
+ * The claim URL for THIS install: fidacy.com/claim?ref=<anon_id> shows the
17
+ * operator what the firewall already blocked here and starts a free account
18
+ * that inherits this history. Directed at the HUMAN reading the transcript —
19
+ * the tool-call path (upgrade) measured agents, and agents never buy.
20
+ * Null when there is no persisted config (no anon id ⇒ nothing to claim).
21
+ */
22
+ export declare function claimUrl(): string | null;
23
+ /** True the FIRST time this key is claimed for this install; false forever after. */
24
+ export declare function nudgeOnce(key: NudgeKey): boolean;
25
+ /** Decisions counted in this process (storage-independent). */
26
+ export declare function sessionDecisionCount(): number;
27
+ /** Increment and return the per-install decision counter (payment gate calls). */
28
+ export declare function bumpDecisionCount(): number;
29
+ /** Days since first run (0 when unknown — installs that predate created_at). */
30
+ export declare function installAgeDays(): number;
31
+ /**
32
+ * The one nudge line for a payment-gate decision, or null for silence.
33
+ * Call once per decision; pass the shell's upgrade tool name for correct copy.
34
+ */
35
+ export declare function decisionNudge(status: "ALLOW" | "DENY", violatedRule: string | undefined, upgradeToolName: string): string | null;
36
+ /**
37
+ * The CTA for hitting the free-tier wall on a HOSTED value action — a server-
38
+ * signed verdict (assess_action) or a Bitcoin-anchored proof (anchor_artifact).
39
+ * This is Option C: the local firewall stays free forever, but the moment the
40
+ * agent reaches for the verifiable/anchored proof (the highest-value feature) and
41
+ * the free allowance is spent, the operator gets an elegant route to a free
42
+ * account instead of a raw `402 payment_required`. Rich copy once per install
43
+ * (nudge doctrine: never nag twice), a terse line every time after — the wall is
44
+ * a direct answer to the agent's request, so it always says SOMETHING actionable.
45
+ */
46
+ export declare function hostedWallCta(feature: "verdict" | "anchor"): string;
47
+ /** The no-key CTA for a hosted tool: route the human to a free account. */
48
+ export declare function noKeyCta(): string;
49
+ /** One-time boot line after a week of real protection; null otherwise. */
50
+ export declare function weekOneBootNudge(upgradeToolName: string): string | null;
@@ -0,0 +1,6 @@
1
+ export declare function provisionEnabled(): boolean;
2
+ /**
3
+ * Best-effort background provisioning. Returns the outcome (useful for tests/logs);
4
+ * callers should NOT await it on the startup path — fire and forget.
5
+ */
6
+ export declare function autoProvision(): Promise<"provisioned" | "skipped" | "rate_limited" | "failed">;
@@ -0,0 +1,59 @@
1
+ import type { AuditRecord } from "./firewall/index.js";
2
+ /**
3
+ * OPERATOR REPORTING — the read half of the firewall.
4
+ *
5
+ * The write tools (request_payment et al.) answer the agent. These answer the
6
+ * HUMAN, in the same conversation the agent already lives in: "what did my
7
+ * agents spend this week", "what got blocked and why", "explain this one".
8
+ * Before this, an operator only ever heard from Fidacy at the moment something
9
+ * was denied, which is the worst possible time to meet a product.
10
+ *
11
+ * Everything here is derived from the local hash-chained audit (or the hosted
12
+ * chain in http mode), so it is agent-agnostic by construction: any MCP host,
13
+ * any agent framework, no dashboard, no account required.
14
+ *
15
+ * Pure functions, no I/O: the shell fetches records and passes them in.
16
+ */
17
+ /** A record is "in window" when its ISO ts is within `days` of now. */
18
+ export declare function withinDays(records: readonly AuditRecord[], days: number, now?: number): AuditRecord[];
19
+ export type SpendSummary = {
20
+ window_days: number;
21
+ decisions: number;
22
+ allowed: number;
23
+ denied: number;
24
+ /** Totals per ISO currency. Records from older versions may lack an amount. */
25
+ allowed_totals: Record<string, number>;
26
+ blocked_totals: Record<string, number>;
27
+ /** Denial counts per violated rule, most frequent first. */
28
+ denied_by_rule: Record<string, number>;
29
+ /** Distinct payees actually paid in the window. */
30
+ payees_paid: string[];
31
+ /** Records in the window whose amount was never persisted (pre-0.2.3 writes). */
32
+ amount_unknown: number;
33
+ first_ts: string | null;
34
+ last_ts: string | null;
35
+ };
36
+ export declare function summarize(records: readonly AuditRecord[], days: number, now?: number): SpendSummary;
37
+ /** The summary as a sentence an operator reads without decoding JSON. */
38
+ export declare function renderSummary(s: SpendSummary): string;
39
+ export interface DecisionRow {
40
+ seq: number;
41
+ decisionId: string;
42
+ status: string;
43
+ ts: string;
44
+ payee?: string;
45
+ amount?: number;
46
+ currency?: string;
47
+ purpose?: string;
48
+ violatedRule?: string;
49
+ invoiceRef?: string;
50
+ }
51
+ /** Newest first, which is the order a human asking "what just happened" wants. */
52
+ export declare function toRows(records: readonly AuditRecord[], status: "ALLOW" | "DENY" | "all", limit: number): DecisionRow[];
53
+ export declare function renderRows(rows: DecisionRow[]): string;
54
+ /**
55
+ * Plain-language reading of one decision. The point is that an operator gets the
56
+ * "why" without reading a rule name: a violated rule is a policy sentence, and
57
+ * the mandate that produced it is the thing they can actually change.
58
+ */
59
+ export declare function explainRule(rule: string | undefined, payee?: string): string;
@@ -0,0 +1,69 @@
1
+ import type { AuditRecord } from "./firewall/index.js";
2
+ /**
3
+ * SENTINEL — predictive alerts before the mistake.
4
+ *
5
+ * The mandate answers "is this allowed". Sentinel answers the question that
6
+ * arrives one step later in every real incident: "it was allowed, but is it
7
+ * NORMAL for this agent?" A prompt-injected agent's first move is usually a
8
+ * payment that clears every rule while breaking every pattern: a payee it has
9
+ * never paid, an amount far above its history, a burst of requests, or the
10
+ * signature move our own Model Watch benchmark measured, retrying what was
11
+ * just denied.
12
+ *
13
+ * Doctrine:
14
+ * - DETERMINISTIC and explainable. Every alert names its rule and its
15
+ * numbers. No model, no score, no black box: this ships in the same
16
+ * product whose whole thesis is that judgments must be re-derivable.
17
+ * - ADVISORY. Sentinel never changes a decision; the mandate remains the only
18
+ * gate. An alert on an ALLOW is a heads-up that arrives BEFORE settlement,
19
+ * which is the last moment a human can still act cheaply.
20
+ * - LOCAL. Pure functions over the same hash-chained audit the shell already
21
+ * holds. Works offline, both shells, no account, agent-agnostic.
22
+ */
23
+ export type SentinelAlert = {
24
+ rule: "first_payee" | "amount_spike" | "velocity_burst" | "cap_creep" | "retry_after_deny";
25
+ severity: "notice" | "warning";
26
+ message: string;
27
+ };
28
+ /** Tunables, exported so shells and tests can show/pin them. */
29
+ export declare const SENTINEL: {
30
+ /** amount >= SPIKE_FACTOR x the historical max ALLOW to the same payee. */
31
+ readonly SPIKE_FACTOR: 3;
32
+ /** minimum prior ALLOWs to a payee before a spike is judged (else first_payee covers it). */
33
+ readonly SPIKE_MIN_HISTORY: 2;
34
+ /** decisions within BURST_WINDOW_MS to trip velocity_burst. */
35
+ readonly BURST_COUNT: 5;
36
+ readonly BURST_WINDOW_MS: number;
37
+ /** fraction of perTxMax that counts as riding the ceiling. */
38
+ readonly CAP_CREEP_RATIO: 0.8;
39
+ /** a DENY for the same payee within this window makes a new attempt a retry. */
40
+ readonly RETRY_WINDOW_MS: number;
41
+ };
42
+ /**
43
+ * Judge one incoming request against this agent's own recorded pattern.
44
+ * `records` is the chain BEFORE this request (order preserved, oldest first).
45
+ */
46
+ export declare function assessPattern(records: readonly AuditRecord[], req: {
47
+ payee: string;
48
+ amount?: number;
49
+ }, opts?: {
50
+ perTxMax?: number;
51
+ now?: number;
52
+ }): SentinelAlert[];
53
+ /**
54
+ * Retrospective sweep for the `sentinel_alerts` tool: replay the chain and
55
+ * report which decisions tripped which patterns, newest first. Deterministic:
56
+ * each decision is judged only against the records before it.
57
+ */
58
+ export declare function sweep(records: readonly AuditRecord[], opts?: {
59
+ perTxMax?: number;
60
+ limit?: number;
61
+ }): {
62
+ decisionId: string;
63
+ ts: string;
64
+ status: string;
65
+ payee?: string;
66
+ alerts: SentinelAlert[];
67
+ }[];
68
+ /** One compact line for decision responses; empty string when nothing fired. */
69
+ export declare function renderAlertLine(alerts: SentinelAlert[]): string;
@@ -0,0 +1,27 @@
1
+ export type TelemetryEventType = "install" | "agent_active" | "decision" | "upgrade_intent";
2
+ export type DecisionResult = "allow" | "deny_cap" | "deny_payee" | "deny_scope" | "deny_lookalike" | "deny_duplicate" | "deny_window" | "deny_invalid";
3
+ /** Which shell this engine is embedded in. An ENUM mirrored by the ingest allowlist
4
+ * and the DB CHECK — never a free-form string, so it can't become a PII channel. */
5
+ export type TelemetryShell = "mcp" | "openclaw-plugin" | "crabtrap" | "sdk";
6
+ /** Order-of-magnitude bucket of the requested amount (mandate currency). An
7
+ * ENUM: the amount itself NEVER leaves the install, only its magnitude class,
8
+ * which is what turns the frequency corpus into a loss-distribution shape. */
9
+ export type AmountBand = "lt10" | "10_99" | "100_999" | "1k_10k" | "10k_100k" | "gte100k";
10
+ /** How far a denied amount sat above the per-transaction cap. Dimensionless. */
11
+ export type CapRatioBand = "1_2x" | "2_5x" | "5_10x" | "gt10x";
12
+ export declare function bandOf(amount: unknown): AmountBand | undefined;
13
+ export declare function capRatioOf(amount: unknown, perTxMax: unknown): CapRatioBand | undefined;
14
+ /** Set by non-MCP shells (e.g. the native OpenClaw plugin) BEFORE the first
15
+ * recorded event, so traction can be attributed to the acquisition channel. */
16
+ export declare function setTelemetryShell(shell: TelemetryShell): void;
17
+ export declare function telemetryEnabled(): boolean;
18
+ /** Map a firewall violatedRule to the coarse, PII-free decision result enum. */
19
+ export declare function resultOf(status: "ALLOW" | "DENY", violatedRule?: string): DecisionResult;
20
+ /** Buffer an event (sync, never blocks, never throws). Auto-flushes shortly after. */
21
+ export declare function record(type: TelemetryEventType, result?: DecisionResult, band?: AmountBand, capRatio?: CapRatioBand): void;
22
+ export declare const recordInstall: () => void;
23
+ export declare const recordAgentActive: () => void;
24
+ export declare const recordDecision: (status: "ALLOW" | "DENY", violatedRule?: string, amount?: number, perTxMax?: number) => void;
25
+ export declare const recordUpgradeIntent: () => void;
26
+ /** Send the buffered batch fire-and-forget. Swallows all errors. Best-effort. */
27
+ export declare function flush(): Promise<void>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Upgrade (Zero-Friction Onboarding, Fase 4 / Componente 5).
3
+ *
4
+ * The free tier is local-first with an anonymous identity. Upgrading opens the
5
+ * real-account flow (e-mail/billing) in the dashboard; the anon_id travels in the
6
+ * link so the engine can bind past usage to the new tenant on completion. The MCP
7
+ * side is deliberately thin: record the funnel signal and hand back the link — no
8
+ * account/billing logic lives in the shell.
9
+ */
10
+ export declare function upgradeUrl(): string;
11
+ /** Emit the upgrade_intent telemetry signal (top of the conversion funnel) and
12
+ * return the URL to open. Best-effort telemetry; never throws. */
13
+ export declare function requestUpgrade(): {
14
+ url: string;
15
+ anonId: string;
16
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fidacy/mcp",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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",
@@ -21,11 +21,24 @@
21
21
  "fidacy-mcp": "dist/index.js"
22
22
  },
23
23
  "main": "dist/index.js",
24
+ "types": "dist/index.d.ts",
24
25
  "exports": {
25
- ".": "./dist/index.js",
26
- "./assess": "./dist/assess.js",
27
- "./core": "./dist/core.js",
28
- "./lib": "./dist/lib.js"
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./assess": {
31
+ "types": "./dist/assess.d.ts",
32
+ "default": "./dist/assess.js"
33
+ },
34
+ "./core": {
35
+ "types": "./dist/core.d.ts",
36
+ "default": "./dist/core.js"
37
+ },
38
+ "./lib": {
39
+ "types": "./dist/lib.d.ts",
40
+ "default": "./dist/lib.js"
41
+ }
29
42
  },
30
43
  "files": [
31
44
  "dist",