@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 +7 -0
- package/dist/activation.d.ts +49 -0
- package/dist/artifacts.d.ts +44 -0
- package/dist/assess.d.ts +86 -0
- package/dist/config.d.ts +66 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +1 -1
- package/dist/firewall/audit-store.d.ts +13 -0
- package/dist/firewall/core.d.ts +79 -0
- package/dist/firewall/evaluate.d.ts +10 -0
- package/dist/firewall/executor.d.ts +49 -0
- package/dist/firewall/grant.d.ts +20 -0
- package/dist/firewall/index.d.ts +8 -0
- package/dist/firewall/signing.d.ts +10 -0
- package/dist/firewall/types.d.ts +58 -0
- package/dist/firewall/util.d.ts +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +122 -12
- package/dist/lib.d.ts +13 -0
- package/dist/lib.js +97 -10
- package/dist/nudges.d.ts +50 -0
- package/dist/provision.d.ts +6 -0
- package/dist/reporting.d.ts +59 -0
- package/dist/sentinel.d.ts +69 -0
- package/dist/telemetry.d.ts +27 -0
- package/dist/upgrade.d.ts +16 -0
- package/package.json +18 -5
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>>;
|
package/dist/assess.d.ts
ADDED
|
@@ -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>;
|
package/dist/config.d.ts
ADDED
|
@@ -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.
|
|
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,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;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
CHANGED
|
@@ -217,17 +217,17 @@ var DevFidacyCore = class {
|
|
|
217
217
|
}
|
|
218
218
|
async decide(req, subject2) {
|
|
219
219
|
const decisionId = randomUUID();
|
|
220
|
-
const
|
|
220
|
+
const ts2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
221
221
|
const invalid = validateRequest(req);
|
|
222
222
|
if (invalid) {
|
|
223
|
-
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: invalid, ts };
|
|
223
|
+
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: invalid, ts: ts2 };
|
|
224
224
|
this.store.append(decision2);
|
|
225
225
|
this.onDecision?.(decision2);
|
|
226
226
|
return decision2;
|
|
227
227
|
}
|
|
228
228
|
const violated = evaluate(this.mandate, req, this.spent);
|
|
229
229
|
if (violated) {
|
|
230
|
-
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: violated, ts };
|
|
230
|
+
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: violated, ts: ts2 };
|
|
231
231
|
this.store.append(decision2);
|
|
232
232
|
this.onDecision?.(decision2);
|
|
233
233
|
return decision2;
|
|
@@ -236,7 +236,7 @@ var DevFidacyCore = class {
|
|
|
236
236
|
if (invoice) {
|
|
237
237
|
const k = `${subject2}|${invoice}`;
|
|
238
238
|
if (this.claimedInvoices.has(k)) {
|
|
239
|
-
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: `duplicate_invoice:${invoice}`, ts };
|
|
239
|
+
const decision2 = { decisionId, status: "DENY", subject: subject2, mandateId: this.mandate.id, request: req, violatedRule: `duplicate_invoice:${invoice}`, ts: ts2 };
|
|
240
240
|
this.store.append(decision2);
|
|
241
241
|
this.onDecision?.(decision2);
|
|
242
242
|
return decision2;
|
|
@@ -246,7 +246,7 @@ var DevFidacyCore = class {
|
|
|
246
246
|
const grantPayload = { decisionId, subject: subject2, payee: req.payee, amount: req.amount, currency: req.currency, exp: Date.now() + 12e4, ...req.invoiceRef ? { invoiceRef: req.invoiceRef } : {} };
|
|
247
247
|
const grantBody = Buffer.from(stableStringify(grantPayload), "utf8").toString("base64url");
|
|
248
248
|
const grant = `${grantBody}.${sign(this.priv, grantBody)}`;
|
|
249
|
-
const decision = { decisionId, status: "ALLOW", subject: subject2, mandateId: this.mandate.id, request: req, grant, ts };
|
|
249
|
+
const decision = { decisionId, status: "ALLOW", subject: subject2, mandateId: this.mandate.id, request: req, grant, ts: ts2 };
|
|
250
250
|
this.spent += req.amount;
|
|
251
251
|
this.store.append(decision);
|
|
252
252
|
this.onDecision?.(decision);
|
|
@@ -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
|
|
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}|${
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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}|${
|
|
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.
|
|
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.
|
|
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,
|
package/dist/nudges.d.ts
ADDED
|
@@ -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
|
+
"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
|
-
".":
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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",
|