mythos-sentinel 0.1.0
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/LICENSE +21 -0
- package/README.md +362 -0
- package/action.yml +43 -0
- package/assets/banner.png +0 -0
- package/bin/mythos-sentinel-mcp.js +7 -0
- package/bin/mythos-sentinel.js +8 -0
- package/docs/ARCHITECTURE.md +55 -0
- package/docs/BASE_X402.md +33 -0
- package/docs/BAZAAR_ADAPTER.md +41 -0
- package/docs/DASHBOARD.md +22 -0
- package/docs/FALLBACK_ROUTING.md +37 -0
- package/docs/MCP.md +70 -0
- package/docs/PASSIVE_SCORING.md +33 -0
- package/docs/ROUTESCORE.md +101 -0
- package/docs/RUNTIME_MCP_PROXY.md +90 -0
- package/docs/SPEND_FIREWALL.md +50 -0
- package/docs/TELEMETRY.md +74 -0
- package/docs/THREAT_MODEL.md +28 -0
- package/docs/X402_RECEIPTS.md +54 -0
- package/examples/base/mythos.policy.json +142 -0
- package/examples/claude_desktop/mcp.json +8 -0
- package/examples/codex/AGENTS.md +31 -0
- package/examples/cursor/mcp.json +8 -0
- package/examples/github/verify.yml +29 -0
- package/examples/routescore/services.yml +19 -0
- package/examples/skill/mythos.skill.json +20 -0
- package/package.json +79 -0
- package/schemas/agent-receipt.schema.json +17 -0
- package/schemas/policy.schema.json +322 -0
- package/schemas/sentinel-report.schema.json +14 -0
- package/schemas/skill.manifest.schema.json +42 -0
- package/src/cli.js +570 -0
- package/src/core/fs.js +88 -0
- package/src/core/path-utils.js +54 -0
- package/src/core/policy.js +326 -0
- package/src/core/receipt.js +52 -0
- package/src/core/routescore.js +576 -0
- package/src/core/snapshot.js +35 -0
- package/src/core/telemetry.js +214 -0
- package/src/core/x402-receipts.js +303 -0
- package/src/index.js +19 -0
- package/src/mcp/proxy.js +493 -0
- package/src/mcp/server.js +226 -0
- package/src/report/format.js +53 -0
- package/src/report/sarif.js +50 -0
- package/src/scanner/rules.js +185 -0
- package/src/scanner/scan.js +118 -0
- package/src/ui/server.js +346 -0
- package/src/ui/static/app.js +210 -0
- package/src/ui/static/index.html +342 -0
- package/src/ui/static/styles.css +904 -0
- package/src/version.js +2 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ensureDir, exists } from './fs.js';
|
|
4
|
+
import { normalizeDomain, domainMatches } from './policy.js';
|
|
5
|
+
import { seedX402Services } from './routescore.js';
|
|
6
|
+
|
|
7
|
+
export const TELEMETRY_VERSION = '0.10';
|
|
8
|
+
export const DEFAULT_TELEMETRY_DIR = '.mythos/telemetry';
|
|
9
|
+
export const DEFAULT_EVENTS_FILE = 'events.jsonl';
|
|
10
|
+
|
|
11
|
+
export function telemetryEnabled(policy = {}) {
|
|
12
|
+
return Boolean(policy?.routeScore?.telemetry?.enabled || policy?.telemetry?.enabled);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function telemetryPrivacy(policy = {}) {
|
|
16
|
+
const configured = policy?.routeScore?.telemetry || policy?.telemetry || {};
|
|
17
|
+
return {
|
|
18
|
+
enabled: telemetryEnabled(policy),
|
|
19
|
+
anonymous: configured.anonymous !== false,
|
|
20
|
+
localOnly: configured.localOnly !== false,
|
|
21
|
+
collectPrompts: false,
|
|
22
|
+
collectResponses: false,
|
|
23
|
+
collectWalletBalances: false,
|
|
24
|
+
storePath: configured.storePath || `${DEFAULT_TELEMETRY_DIR}/${DEFAULT_EVENTS_FILE}`,
|
|
25
|
+
note: 'Local opt-in telemetry stores only sanitized endpoint reliability metadata. Prompts, responses, secrets, private files, and wallet balances are never collected.'
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function telemetryPath(rootDir = process.cwd(), policy = {}) {
|
|
30
|
+
const configured = policy?.routeScore?.telemetry?.storePath || policy?.telemetry?.storePath || `${DEFAULT_TELEMETRY_DIR}/${DEFAULT_EVENTS_FILE}`;
|
|
31
|
+
return path.isAbsolute(configured) ? configured : path.join(rootDir, configured);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function setTelemetryEnabled({ rootDir = process.cwd(), policy, enabled }) {
|
|
35
|
+
if (!policy || typeof policy !== 'object') throw new Error('Policy object is required.');
|
|
36
|
+
policy.routeScore ||= {};
|
|
37
|
+
policy.routeScore.telemetry ||= {};
|
|
38
|
+
policy.routeScore.telemetry.enabled = Boolean(enabled);
|
|
39
|
+
policy.routeScore.telemetry.anonymous = true;
|
|
40
|
+
policy.routeScore.telemetry.localOnly = true;
|
|
41
|
+
policy.routeScore.telemetry.collectPrompts = false;
|
|
42
|
+
policy.routeScore.telemetry.collectResponses = false;
|
|
43
|
+
policy.routeScore.telemetry.collectWalletBalances = false;
|
|
44
|
+
policy.routeScore.telemetry.storePath ||= `${DEFAULT_TELEMETRY_DIR}/${DEFAULT_EVENTS_FILE}`;
|
|
45
|
+
if (enabled) await ensureDir(path.dirname(telemetryPath(rootDir, policy)));
|
|
46
|
+
return policy;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function appendTelemetryEvent({ rootDir = process.cwd(), policy = {}, event = {} } = {}) {
|
|
50
|
+
if (!telemetryEnabled(policy)) {
|
|
51
|
+
return { ok: true, stored: false, reason: 'telemetry_disabled', privacy: telemetryPrivacy(policy) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const sanitized = sanitizeTelemetryEvent(event);
|
|
55
|
+
const file = telemetryPath(rootDir, policy);
|
|
56
|
+
await ensureDir(path.dirname(file));
|
|
57
|
+
await fs.appendFile(file, `${JSON.stringify(sanitized)}\n`, 'utf8');
|
|
58
|
+
return { ok: true, stored: true, path: file, event: sanitized, privacy: telemetryPrivacy(policy) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function readTelemetryEvents({ rootDir = process.cwd(), policy = {}, limit = 1000 } = {}) {
|
|
62
|
+
const file = telemetryPath(rootDir, policy);
|
|
63
|
+
if (!(await exists(file))) return [];
|
|
64
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
65
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
66
|
+
const selected = Number.isFinite(Number(limit)) && Number(limit) > 0 ? lines.slice(-Number(limit)) : lines;
|
|
67
|
+
const events = [];
|
|
68
|
+
for (const line of selected) {
|
|
69
|
+
try { events.push(JSON.parse(line)); } catch { /* ignore malformed local telemetry line */ }
|
|
70
|
+
}
|
|
71
|
+
return events;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function telemetrySummary({ rootDir = process.cwd(), policy = {}, services = seedX402Services, limit = 5000 } = {}) {
|
|
75
|
+
const events = await readTelemetryEvents({ rootDir, policy, limit });
|
|
76
|
+
return summarizeTelemetryEvents(events, services);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function summarizeTelemetryEvents(events = [], services = seedX402Services) {
|
|
80
|
+
const byKey = new Map();
|
|
81
|
+
for (const event of events) {
|
|
82
|
+
const key = event.serviceId || serviceIdForDomain(event.domain, services) || event.domain || 'unknown';
|
|
83
|
+
if (!byKey.has(key)) byKey.set(key, emptyAggregate(key));
|
|
84
|
+
const aggregate = byKey.get(key);
|
|
85
|
+
aggregate.samples += 1;
|
|
86
|
+
aggregate.successes += event.ok === true ? 1 : 0;
|
|
87
|
+
aggregate.failures += event.ok === false ? 1 : 0;
|
|
88
|
+
aggregate.schemaOk += event.schemaOk === true ? 1 : 0;
|
|
89
|
+
aggregate.schemaSamples += event.schemaOk === null || event.schemaOk === undefined ? 0 : 1;
|
|
90
|
+
aggregate.priceMatched += event.priceMatchedQuote === false ? 0 : 1;
|
|
91
|
+
aggregate.priceSamples += event.priceMatchedQuote === null || event.priceMatchedQuote === undefined ? 0 : 1;
|
|
92
|
+
aggregate.totalAmountUSDC += Number.isFinite(Number(event.amountUSDC)) ? Number(event.amountUSDC) : 0;
|
|
93
|
+
if (Number.isFinite(Number(event.latencyMs))) aggregate.latencies.push(Number(event.latencyMs));
|
|
94
|
+
if (event.domain && !aggregate.domains.includes(event.domain)) aggregate.domains.push(event.domain);
|
|
95
|
+
if (event.category && !aggregate.categories.includes(event.category)) aggregate.categories.push(event.category);
|
|
96
|
+
if (event.observedAt) aggregate.lastObservedAt = maxIso(aggregate.lastObservedAt, event.observedAt);
|
|
97
|
+
if (event.ok === false) {
|
|
98
|
+
aggregate.recentFailureCount += 1;
|
|
99
|
+
if (event.errorType) aggregate.errorTypes[event.errorType] = (aggregate.errorTypes[event.errorType] || 0) + 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const aggregates = [...byKey.values()].map(finalizeAggregate);
|
|
104
|
+
const telemetry = {};
|
|
105
|
+
for (const aggregate of aggregates) {
|
|
106
|
+
telemetry[aggregate.serviceId || aggregate.key] = {
|
|
107
|
+
successRate: aggregate.successRate,
|
|
108
|
+
schemaSuccessRate: aggregate.schemaSuccessRate,
|
|
109
|
+
medianLatencyMs: aggregate.medianLatencyMs,
|
|
110
|
+
samples: aggregate.samples,
|
|
111
|
+
recentFailureCount: aggregate.recentFailureCount,
|
|
112
|
+
priceMatchedQuote: aggregate.priceMatchedQuote,
|
|
113
|
+
lastObservedAt: aggregate.lastObservedAt,
|
|
114
|
+
amountUSDC: aggregate.totalAmountUSDC
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
eventCount: events.length,
|
|
121
|
+
generatedAt: new Date().toISOString(),
|
|
122
|
+
aggregates,
|
|
123
|
+
telemetry,
|
|
124
|
+
privacy: {
|
|
125
|
+
anonymous: true,
|
|
126
|
+
localOnly: true,
|
|
127
|
+
excludes: ['prompts', 'responses', 'secrets', 'private file contents', 'wallet balances']
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function sanitizeTelemetryEvent(event = {}) {
|
|
133
|
+
const domain = normalizeDomain(event.domain || domainFromUrl(event.endpoint) || domainFromUrl(event.url) || 'unknown.local');
|
|
134
|
+
const serviceId = event.serviceId || serviceIdForDomain(domain, seedX402Services) || null;
|
|
135
|
+
return {
|
|
136
|
+
version: TELEMETRY_VERSION,
|
|
137
|
+
source: String(event.source || 'runtime').slice(0, 64),
|
|
138
|
+
mode: String(event.mode || 'local').slice(0, 64),
|
|
139
|
+
serviceId,
|
|
140
|
+
domain,
|
|
141
|
+
category: event.category ? String(event.category).slice(0, 64) : null,
|
|
142
|
+
upstream: event.upstream ? String(event.upstream).slice(0, 128) : null,
|
|
143
|
+
tool: event.tool ? String(event.tool).slice(0, 128) : null,
|
|
144
|
+
decision: event.decision ? String(event.decision).slice(0, 64) : null,
|
|
145
|
+
ok: event.ok === undefined ? null : Boolean(event.ok),
|
|
146
|
+
latencyMs: Number.isFinite(Number(event.latencyMs)) ? Math.max(0, Math.round(Number(event.latencyMs))) : null,
|
|
147
|
+
amountUSDC: Number.isFinite(Number(event.amountUSDC)) ? Math.max(0, Number(event.amountUSDC)) : 0,
|
|
148
|
+
schemaOk: event.schemaOk === undefined ? null : Boolean(event.schemaOk),
|
|
149
|
+
priceMatchedQuote: event.priceMatchedQuote === undefined ? null : Boolean(event.priceMatchedQuote),
|
|
150
|
+
errorType: event.errorType ? String(event.errorType).slice(0, 96) : null,
|
|
151
|
+
observedAt: event.observedAt || new Date().toISOString(),
|
|
152
|
+
privacy: 'sanitized endpoint metadata only; no prompts/responses/secrets/wallet balances'
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function serviceIdForDomain(domain, services = seedX402Services) {
|
|
157
|
+
const normalized = normalizeDomain(domain);
|
|
158
|
+
const match = services.find((service) => domainMatches(normalized, service.domain));
|
|
159
|
+
return match?.id || null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function emptyAggregate(key) {
|
|
163
|
+
return {
|
|
164
|
+
key,
|
|
165
|
+
serviceId: key,
|
|
166
|
+
domains: [],
|
|
167
|
+
categories: [],
|
|
168
|
+
samples: 0,
|
|
169
|
+
successes: 0,
|
|
170
|
+
failures: 0,
|
|
171
|
+
schemaOk: 0,
|
|
172
|
+
schemaSamples: 0,
|
|
173
|
+
priceMatched: 0,
|
|
174
|
+
priceSamples: 0,
|
|
175
|
+
totalAmountUSDC: 0,
|
|
176
|
+
latencies: [],
|
|
177
|
+
recentFailureCount: 0,
|
|
178
|
+
errorTypes: {},
|
|
179
|
+
lastObservedAt: null
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function finalizeAggregate(aggregate) {
|
|
184
|
+
const medianLatencyMs = median(aggregate.latencies);
|
|
185
|
+
const successRate = aggregate.samples ? aggregate.successes / aggregate.samples : 0;
|
|
186
|
+
const schemaSuccessRate = aggregate.schemaSamples ? aggregate.schemaOk / aggregate.schemaSamples : 1;
|
|
187
|
+
const priceMatchedQuote = aggregate.priceSamples ? aggregate.priceMatched / aggregate.priceSamples >= 0.95 : true;
|
|
188
|
+
return {
|
|
189
|
+
...aggregate,
|
|
190
|
+
successRate,
|
|
191
|
+
schemaSuccessRate,
|
|
192
|
+
priceMatchedQuote,
|
|
193
|
+
medianLatencyMs,
|
|
194
|
+
averageAmountUSDC: aggregate.samples ? aggregate.totalAmountUSDC / aggregate.samples : 0,
|
|
195
|
+
errorTypes: aggregate.errorTypes
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function median(values) {
|
|
200
|
+
if (!values.length) return null;
|
|
201
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
202
|
+
const mid = Math.floor(sorted.length / 2);
|
|
203
|
+
return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function maxIso(a, b) {
|
|
207
|
+
if (!a) return b;
|
|
208
|
+
return new Date(a).getTime() >= new Date(b).getTime() ? a : b;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function domainFromUrl(value) {
|
|
212
|
+
if (!value || typeof value !== 'string') return null;
|
|
213
|
+
try { return new URL(value).hostname; } catch { return null; }
|
|
214
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { ensureDir, exists } from './fs.js';
|
|
5
|
+
import { normalizeDomain } from './policy.js';
|
|
6
|
+
import { appendTelemetryEvent } from './telemetry.js';
|
|
7
|
+
|
|
8
|
+
export const X402_RECEIPTS_VERSION = '0.10';
|
|
9
|
+
export const DEFAULT_X402_RECEIPTS_DIR = '.mythos/x402';
|
|
10
|
+
export const DEFAULT_X402_RECEIPTS_FILE = 'receipts.jsonl';
|
|
11
|
+
|
|
12
|
+
const HEADER_KEYS = [
|
|
13
|
+
'x-payment-response',
|
|
14
|
+
'x-payment',
|
|
15
|
+
'x402-receipt',
|
|
16
|
+
'x402-payment-response',
|
|
17
|
+
'payment-response',
|
|
18
|
+
'payment'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function x402ReceiptsPath(rootDir = process.cwd()) {
|
|
22
|
+
return path.join(rootDir, DEFAULT_X402_RECEIPTS_DIR, DEFAULT_X402_RECEIPTS_FILE);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function ingestX402Receipt(input, { rootDir = process.cwd(), policy = {}, source = 'manual', store = true } = {}) {
|
|
26
|
+
const receipt = normalizeX402Receipt(input, { source });
|
|
27
|
+
if (store) {
|
|
28
|
+
const file = x402ReceiptsPath(rootDir);
|
|
29
|
+
await ensureDir(path.dirname(file));
|
|
30
|
+
await fs.appendFile(file, `${JSON.stringify(receipt)}\n`, 'utf8');
|
|
31
|
+
receipt.storePath = file;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const telemetry = await appendTelemetryEvent({
|
|
35
|
+
rootDir,
|
|
36
|
+
policy,
|
|
37
|
+
event: receiptToTelemetryEvent(receipt)
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
ok: true,
|
|
42
|
+
stored: Boolean(store),
|
|
43
|
+
receipt,
|
|
44
|
+
telemetry: { stored: telemetry.stored, reason: telemetry.reason || null }
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function ingestX402ReceiptFile(filePath, { rootDir = process.cwd(), policy = {}, source = 'file' } = {}) {
|
|
49
|
+
const raw = await fs.readFile(path.resolve(rootDir, filePath), 'utf8');
|
|
50
|
+
return ingestX402Receipt(parseReceiptInput(raw), { rootDir, policy, source });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readX402Receipts({ rootDir = process.cwd(), limit = 500 } = {}) {
|
|
54
|
+
const file = x402ReceiptsPath(rootDir);
|
|
55
|
+
if (!(await exists(file))) return [];
|
|
56
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
57
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
58
|
+
const selected = Number.isFinite(Number(limit)) && Number(limit) > 0 ? lines.slice(-Number(limit)) : lines;
|
|
59
|
+
const receipts = [];
|
|
60
|
+
for (const line of selected) {
|
|
61
|
+
try { receipts.push(JSON.parse(line)); } catch { /* ignore malformed local receipt line */ }
|
|
62
|
+
}
|
|
63
|
+
return receipts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function summarizeX402Receipts({ rootDir = process.cwd(), limit = 5000 } = {}) {
|
|
67
|
+
return summarizeReceiptList(await readX402Receipts({ rootDir, limit }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function summarizeReceiptList(receipts = []) {
|
|
71
|
+
const byDomain = new Map();
|
|
72
|
+
let settled = 0;
|
|
73
|
+
let failed = 0;
|
|
74
|
+
let pending = 0;
|
|
75
|
+
let totalAmountUSDC = 0;
|
|
76
|
+
|
|
77
|
+
for (const receipt of receipts) {
|
|
78
|
+
const domain = receipt.domain || 'unknown.local';
|
|
79
|
+
if (!byDomain.has(domain)) byDomain.set(domain, { domain, count: 0, settled: 0, failed: 0, pending: 0, totalAmountUSDC: 0, networks: new Set(), assets: new Set(), lastObservedAt: null });
|
|
80
|
+
const item = byDomain.get(domain);
|
|
81
|
+
item.count += 1;
|
|
82
|
+
item.totalAmountUSDC += Number(receipt.amountUSDC || 0);
|
|
83
|
+
item.networks.add(receipt.network || 'unknown');
|
|
84
|
+
item.assets.add(receipt.asset || 'unknown');
|
|
85
|
+
item.lastObservedAt = maxIso(item.lastObservedAt, receipt.observedAt);
|
|
86
|
+
totalAmountUSDC += Number(receipt.amountUSDC || 0);
|
|
87
|
+
if (receipt.settlementStatus === 'settled') { settled += 1; item.settled += 1; }
|
|
88
|
+
else if (receipt.settlementStatus === 'failed') { failed += 1; item.failed += 1; }
|
|
89
|
+
else { pending += 1; item.pending += 1; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
receiptCount: receipts.length,
|
|
95
|
+
settled,
|
|
96
|
+
failed,
|
|
97
|
+
pending,
|
|
98
|
+
totalAmountUSDC,
|
|
99
|
+
generatedAt: new Date().toISOString(),
|
|
100
|
+
domains: [...byDomain.values()].map((item) => ({
|
|
101
|
+
...item,
|
|
102
|
+
totalAmountUSDC: Number(item.totalAmountUSDC.toFixed(8)),
|
|
103
|
+
networks: [...item.networks],
|
|
104
|
+
assets: [...item.assets]
|
|
105
|
+
})).sort((a, b) => b.count - a.count || a.domain.localeCompare(b.domain)),
|
|
106
|
+
privacy: {
|
|
107
|
+
localOnly: true,
|
|
108
|
+
excludes: ['prompts', 'responses', 'private request bodies', 'secrets', 'private keys', 'wallet balances']
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function normalizeX402Receipt(input, { source = 'manual' } = {}) {
|
|
114
|
+
const payload = parseReceiptInput(input);
|
|
115
|
+
const extracted = extractReceiptPayload(payload);
|
|
116
|
+
const endpoint = firstString(
|
|
117
|
+
extracted.endpoint,
|
|
118
|
+
extracted.resource,
|
|
119
|
+
extracted.url,
|
|
120
|
+
extracted.target,
|
|
121
|
+
extracted.request?.url,
|
|
122
|
+
extracted.response?.url,
|
|
123
|
+
extracted.payment?.resource,
|
|
124
|
+
extracted.x402?.resource
|
|
125
|
+
);
|
|
126
|
+
const domain = normalizeDomain(firstString(extracted.domain, domainFromUrl(endpoint), extracted.host, extracted.hostname, extracted.service?.domain));
|
|
127
|
+
const amountUSDC = normalizeAmountUSDC(extracted);
|
|
128
|
+
const settlementStatus = normalizeSettlementStatus(extracted);
|
|
129
|
+
const receipt = {
|
|
130
|
+
version: X402_RECEIPTS_VERSION,
|
|
131
|
+
receiptId: '',
|
|
132
|
+
source,
|
|
133
|
+
domain: domain || 'unknown.local',
|
|
134
|
+
endpoint: endpoint || null,
|
|
135
|
+
resource: firstString(extracted.resource, extracted.payment?.resource, endpoint),
|
|
136
|
+
amountUSDC,
|
|
137
|
+
asset: firstString(extracted.asset, extracted.token, extracted.currency, extracted.payment?.asset, extracted.accepts?.[0]?.asset, 'USDC'),
|
|
138
|
+
network: normalizeNetwork(firstString(extracted.network, extracted.chain, extracted.chainId, extracted.payment?.network, extracted.accepts?.[0]?.network, 'base')),
|
|
139
|
+
payer: sanitizeAddress(firstString(extracted.payer, extracted.from, extracted.account, extracted.wallet, extracted.payment?.from)),
|
|
140
|
+
payTo: sanitizeAddress(firstString(extracted.payTo, extracted.to, extracted.receiver, extracted.recipient, extracted.payment?.to)),
|
|
141
|
+
facilitator: firstString(extracted.facilitator, extracted.facilitatorUrl, extracted.x402?.facilitator) || null,
|
|
142
|
+
transactionHash: firstString(extracted.transactionHash, extracted.txHash, extracted.tx, extracted.hash, extracted.settlement?.txHash, extracted.payment?.txHash) || null,
|
|
143
|
+
settlementStatus,
|
|
144
|
+
settled: settlementStatus === 'settled',
|
|
145
|
+
settledAt: firstString(extracted.settledAt, extracted.completedAt, extracted.settlement?.settledAt) || null,
|
|
146
|
+
scheme: firstString(extracted.scheme, extracted.payment?.scheme, 'x402') || 'x402',
|
|
147
|
+
paymentVersion: firstString(extracted.x402Version, extracted.paymentVersion, extracted.version) || null,
|
|
148
|
+
latencyMs: numberOrNull(extracted.latencyMs, extracted.durationMs, extracted.timing?.latencyMs),
|
|
149
|
+
observedAt: firstString(extracted.observedAt, extracted.timestamp, extracted.createdAt, new Date().toISOString()),
|
|
150
|
+
metadata: sanitizeMetadata(extracted)
|
|
151
|
+
};
|
|
152
|
+
receipt.receiptId = receiptId(receipt);
|
|
153
|
+
return receipt;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function parseReceiptInput(input) {
|
|
157
|
+
if (input === undefined || input === null || input === '') return {};
|
|
158
|
+
if (typeof input === 'object' && !Buffer.isBuffer(input)) return input;
|
|
159
|
+
const raw = Buffer.isBuffer(input) ? input.toString('utf8') : String(input).trim();
|
|
160
|
+
if (!raw) return {};
|
|
161
|
+
try { return JSON.parse(raw); } catch { /* continue */ }
|
|
162
|
+
const decoded = tryDecodePaymentBlob(raw);
|
|
163
|
+
if (decoded) return decoded;
|
|
164
|
+
return { raw };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function extractReceiptFromHeaders(headers = {}) {
|
|
168
|
+
const normalized = {};
|
|
169
|
+
if (headers instanceof Headers) {
|
|
170
|
+
for (const [key, value] of headers.entries()) normalized[key.toLowerCase()] = value;
|
|
171
|
+
} else {
|
|
172
|
+
for (const [key, value] of Object.entries(headers || {})) normalized[String(key).toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
173
|
+
}
|
|
174
|
+
for (const key of HEADER_KEYS) {
|
|
175
|
+
if (!normalized[key]) continue;
|
|
176
|
+
return parseReceiptInput(normalized[key]);
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function receiptToTelemetryEvent(receipt = {}) {
|
|
182
|
+
return {
|
|
183
|
+
source: 'x402_receipt',
|
|
184
|
+
mode: 'receipt_ingestion',
|
|
185
|
+
domain: receipt.domain,
|
|
186
|
+
endpoint: receipt.endpoint,
|
|
187
|
+
decision: receipt.settlementStatus,
|
|
188
|
+
ok: receipt.settlementStatus === 'settled' ? true : receipt.settlementStatus === 'failed' ? false : null,
|
|
189
|
+
latencyMs: receipt.latencyMs,
|
|
190
|
+
amountUSDC: receipt.amountUSDC,
|
|
191
|
+
schemaOk: true,
|
|
192
|
+
priceMatchedQuote: true,
|
|
193
|
+
errorType: receipt.settlementStatus === 'failed' ? 'x402_settlement_failed' : null,
|
|
194
|
+
observedAt: receipt.observedAt
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function extractReceiptPayload(payload) {
|
|
199
|
+
if (payload?.headers) {
|
|
200
|
+
const headerPayload = extractReceiptFromHeaders(payload.headers);
|
|
201
|
+
if (headerPayload) return { ...payload, ...headerPayload };
|
|
202
|
+
}
|
|
203
|
+
if (payload?.receipt) return { ...payload, ...payload.receipt };
|
|
204
|
+
if (payload?.paymentReceipt) return { ...payload, ...payload.paymentReceipt };
|
|
205
|
+
if (payload?.paymentResponse) return { ...payload, ...parseReceiptInput(payload.paymentResponse) };
|
|
206
|
+
if (payload?.raw) {
|
|
207
|
+
const decoded = tryDecodePaymentBlob(payload.raw);
|
|
208
|
+
if (decoded) return { ...payload, ...decoded };
|
|
209
|
+
}
|
|
210
|
+
return payload || {};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function tryDecodePaymentBlob(value) {
|
|
214
|
+
const raw = String(value || '').trim();
|
|
215
|
+
const candidates = [raw];
|
|
216
|
+
if (/^[A-Za-z0-9+/=_-]+$/.test(raw) && raw.length > 12) {
|
|
217
|
+
candidates.push(Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
|
218
|
+
}
|
|
219
|
+
for (const candidate of candidates) {
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(candidate);
|
|
222
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
223
|
+
} catch { /* ignore */ }
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizeAmountUSDC(payload = {}) {
|
|
229
|
+
const amount = firstNumber(payload.amountUSDC, payload.amountUsd, payload.usdc, payload.priceUSDC, payload.price, payload.amount, payload.payment?.amount, payload.settlement?.amount, payload.accepts?.[0]?.amount);
|
|
230
|
+
if (amount === null) return 0;
|
|
231
|
+
const asset = String(firstString(payload.asset, payload.token, payload.currency, payload.accepts?.[0]?.asset, 'USDC')).toLowerCase();
|
|
232
|
+
if (amount >= 1000 && /usdc|usd/.test(asset)) return Number((amount / 1_000_000).toFixed(8));
|
|
233
|
+
return Number(amount.toFixed ? amount.toFixed(8) : Number(amount).toFixed(8));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeSettlementStatus(payload = {}) {
|
|
237
|
+
const raw = String(firstString(payload.settlementStatus, payload.status, payload.state, payload.payment?.status, payload.settlement?.status) || '').toLowerCase();
|
|
238
|
+
if (payload.settled === true || payload.success === true || ['settled', 'success', 'succeeded', 'paid', 'confirmed', 'complete', 'completed'].includes(raw)) return 'settled';
|
|
239
|
+
if (payload.settled === false || payload.success === false || ['failed', 'reverted', 'declined', 'error'].includes(raw)) return 'failed';
|
|
240
|
+
if (['pending', 'submitted', 'processing'].includes(raw)) return 'pending';
|
|
241
|
+
return payload.transactionHash || payload.txHash || payload.hash ? 'settled' : 'unknown';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sanitizeMetadata(payload = {}) {
|
|
245
|
+
const allowed = {};
|
|
246
|
+
const allowKeys = ['requestId', 'paymentId', 'chainId', 'type', 'method', 'statusCode', 'httpStatus', 'searchMethod'];
|
|
247
|
+
for (const key of allowKeys) {
|
|
248
|
+
if (payload[key] !== undefined) allowed[key] = payload[key];
|
|
249
|
+
}
|
|
250
|
+
return allowed;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function receiptId(receipt) {
|
|
254
|
+
const stable = [receipt.domain, receipt.endpoint, receipt.amountUSDC, receipt.asset, receipt.network, receipt.transactionHash, receipt.observedAt].join('|');
|
|
255
|
+
return `x402_${crypto.createHash('sha256').update(stable).digest('hex').slice(0, 24)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function firstString(...values) {
|
|
259
|
+
for (const value of values) {
|
|
260
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
261
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function firstNumber(...values) {
|
|
267
|
+
for (const value of values) {
|
|
268
|
+
if (value === undefined || value === null || value === '') continue;
|
|
269
|
+
const number = Number(value);
|
|
270
|
+
if (Number.isFinite(number)) return number;
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function numberOrNull(...values) {
|
|
276
|
+
const number = firstNumber(...values);
|
|
277
|
+
return number === null ? null : Math.max(0, Math.round(number));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sanitizeAddress(value) {
|
|
281
|
+
const raw = firstString(value);
|
|
282
|
+
if (!raw) return null;
|
|
283
|
+
if (/^0x[a-fA-F0-9]{40}$/.test(raw)) return raw;
|
|
284
|
+
return raw.slice(0, 96);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function domainFromUrl(value) {
|
|
288
|
+
if (!value || typeof value !== 'string') return null;
|
|
289
|
+
try { return new URL(value).hostname; } catch { return null; }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function normalizeNetwork(network) {
|
|
293
|
+
const raw = String(network || '').toLowerCase();
|
|
294
|
+
if (raw === 'eip155:8453' || raw === '8453') return 'base';
|
|
295
|
+
if (raw === 'eip155:84532' || raw === '84532') return 'base-sepolia';
|
|
296
|
+
if (raw === '1' || raw === 'eip155:1') return 'ethereum';
|
|
297
|
+
return raw || 'base';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function maxIso(a, b) {
|
|
301
|
+
if (!a) return b;
|
|
302
|
+
return new Date(a).getTime() >= new Date(b).getTime() ? a : b;
|
|
303
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { scanPath, scanText } from './scanner/scan.js';
|
|
2
|
+
export {
|
|
3
|
+
loadPolicy,
|
|
4
|
+
defaultPolicy,
|
|
5
|
+
evaluateFindings,
|
|
6
|
+
checkPayment,
|
|
7
|
+
checkCommand,
|
|
8
|
+
checkFilesystemAccess,
|
|
9
|
+
checkNetwork
|
|
10
|
+
} from './core/policy.js';
|
|
11
|
+
export { createSnapshot, diffSnapshots } from './core/snapshot.js';
|
|
12
|
+
export { createReceipt, verifyReceipt } from './core/receipt.js';
|
|
13
|
+
export { executeFallbackRoute, fetchBazaarResources, fetchBazaarSearch, importServicesFile, listServiceCategories, loadRouteScoreServices, recommendService, routeService, saveCustomServices, seedX402Services, scoreService, serviceForDomain, passiveTelemetryEvent } from './core/routescore.js';
|
|
14
|
+
export { appendTelemetryEvent, readTelemetryEvents, telemetrySummary, telemetryEnabled, telemetryPrivacy, setTelemetryEnabled } from './core/telemetry.js';
|
|
15
|
+
export { runMcpServer, handleMessage } from './mcp/server.js';
|
|
16
|
+
export { VERSION } from './version.js';
|
|
17
|
+
export { runMcpProxy, McpProxy, evaluateToolCall, classifyToolCall } from './mcp/proxy.js';
|
|
18
|
+
|
|
19
|
+
export { ingestX402Receipt, ingestX402ReceiptFile, normalizeX402Receipt, readX402Receipts, summarizeX402Receipts, receiptToTelemetryEvent } from './core/x402-receipts.js';
|