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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/action.yml +43 -0
  4. package/assets/banner.png +0 -0
  5. package/bin/mythos-sentinel-mcp.js +7 -0
  6. package/bin/mythos-sentinel.js +8 -0
  7. package/docs/ARCHITECTURE.md +55 -0
  8. package/docs/BASE_X402.md +33 -0
  9. package/docs/BAZAAR_ADAPTER.md +41 -0
  10. package/docs/DASHBOARD.md +22 -0
  11. package/docs/FALLBACK_ROUTING.md +37 -0
  12. package/docs/MCP.md +70 -0
  13. package/docs/PASSIVE_SCORING.md +33 -0
  14. package/docs/ROUTESCORE.md +101 -0
  15. package/docs/RUNTIME_MCP_PROXY.md +90 -0
  16. package/docs/SPEND_FIREWALL.md +50 -0
  17. package/docs/TELEMETRY.md +74 -0
  18. package/docs/THREAT_MODEL.md +28 -0
  19. package/docs/X402_RECEIPTS.md +54 -0
  20. package/examples/base/mythos.policy.json +142 -0
  21. package/examples/claude_desktop/mcp.json +8 -0
  22. package/examples/codex/AGENTS.md +31 -0
  23. package/examples/cursor/mcp.json +8 -0
  24. package/examples/github/verify.yml +29 -0
  25. package/examples/routescore/services.yml +19 -0
  26. package/examples/skill/mythos.skill.json +20 -0
  27. package/package.json +79 -0
  28. package/schemas/agent-receipt.schema.json +17 -0
  29. package/schemas/policy.schema.json +322 -0
  30. package/schemas/sentinel-report.schema.json +14 -0
  31. package/schemas/skill.manifest.schema.json +42 -0
  32. package/src/cli.js +570 -0
  33. package/src/core/fs.js +88 -0
  34. package/src/core/path-utils.js +54 -0
  35. package/src/core/policy.js +326 -0
  36. package/src/core/receipt.js +52 -0
  37. package/src/core/routescore.js +576 -0
  38. package/src/core/snapshot.js +35 -0
  39. package/src/core/telemetry.js +214 -0
  40. package/src/core/x402-receipts.js +303 -0
  41. package/src/index.js +19 -0
  42. package/src/mcp/proxy.js +493 -0
  43. package/src/mcp/server.js +226 -0
  44. package/src/report/format.js +53 -0
  45. package/src/report/sarif.js +50 -0
  46. package/src/scanner/rules.js +185 -0
  47. package/src/scanner/scan.js +118 -0
  48. package/src/ui/server.js +346 -0
  49. package/src/ui/static/app.js +210 -0
  50. package/src/ui/static/index.html +342 -0
  51. package/src/ui/static/styles.css +904 -0
  52. package/src/version.js +2 -0
@@ -0,0 +1,346 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawn } from 'node:child_process';
7
+ import { VERSION, PRODUCT } from '../version.js';
8
+ import { exists, ensureDir, writeJson } from '../core/fs.js';
9
+ import { loadPolicy, defaultPolicy, checkPayment, checkCommand, checkFilesystemAccess, checkNetwork } from '../core/policy.js';
10
+ import { scanPath } from '../scanner/scan.js';
11
+ import { formatScanReport } from '../report/format.js';
12
+ import { loadRouteScoreServices, recommendService, serviceForDomain, scoreService, passiveTelemetryEvent } from '../core/routescore.js';
13
+ import { appendTelemetryEvent, readTelemetryEvents, setTelemetryEnabled, telemetryEnabled, telemetryPrivacy, telemetrySummary } from '../core/telemetry.js';
14
+ import { ingestX402Receipt, summarizeX402Receipts } from '../core/x402-receipts.js';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const STATIC_DIR = path.join(__dirname, 'static');
18
+
19
+ export async function startDashboard(options = {}) {
20
+ const cwd = path.resolve(options.cwd || process.cwd());
21
+ const host = normalizeDashboardHost(options.host || '127.0.0.1');
22
+ const port = normalizeDashboardPort(options.port || 4317);
23
+ const server = await createUiServer({ cwd, demo: Boolean(options.demo) });
24
+
25
+ await new Promise((resolve, reject) => {
26
+ server.once('error', reject);
27
+ server.listen(port, host, resolve);
28
+ });
29
+
30
+ const address = server.address();
31
+ const actualPort = typeof address === 'object' && address ? address.port : port;
32
+ const url = createDashboardUrl(host, actualPort);
33
+
34
+ console.log(`${PRODUCT} Dashboard ${VERSION}`);
35
+ console.log(`Workspace: ${cwd}`);
36
+ console.log(`Open: ${url}`);
37
+ console.log('Tip: in GitHub Codespaces, use the forwarded port link.');
38
+
39
+ if (options.open) openBrowser(url);
40
+
41
+ return { server, url, port: actualPort };
42
+ }
43
+
44
+ export async function createUiServer({ cwd = process.cwd(), demo = false } = {}) {
45
+ const root = path.resolve(cwd);
46
+
47
+ return http.createServer(async (req, res) => {
48
+ try {
49
+ const url = new URL(req.url, 'http://localhost');
50
+ if (req.method === 'GET' && url.pathname === '/') return serveFile(res, 'index.html', 'text/html; charset=utf-8');
51
+ if (req.method === 'GET' && url.pathname === '/assets/styles.css') return serveFile(res, 'styles.css', 'text/css; charset=utf-8');
52
+ if (req.method === 'GET' && url.pathname === '/assets/app.js') return serveFile(res, 'app.js', 'text/javascript; charset=utf-8');
53
+ if (url.pathname === '/api/status') return json(res, await getStatus(root, demo));
54
+ if (url.pathname === '/api/policy' && req.method === 'GET') return json(res, await loadPolicyForRoot(root));
55
+ if (url.pathname === '/api/policy' && req.method === 'POST') return json(res, await savePolicy(root, await bodyJson(req)));
56
+ if (url.pathname === '/api/init' && req.method === 'POST') return json(res, await initWorkspace(root, await bodyJson(req)));
57
+ if (url.pathname === '/api/scan' && req.method === 'POST') return json(res, await scanWorkspace(root, await bodyJson(req)));
58
+ if (url.pathname === '/api/check-payment' && req.method === 'POST') return json(res, await checkPaymentApi(root, await bodyJson(req)));
59
+ if (url.pathname === '/api/check-command' && req.method === 'POST') return json(res, await checkCommandApi(root, await bodyJson(req)));
60
+ if (url.pathname === '/api/check-file' && req.method === 'POST') return json(res, await checkFileApi(root, await bodyJson(req)));
61
+ if (url.pathname === '/api/check-network' && req.method === 'POST') return json(res, await checkNetworkApi(root, await bodyJson(req)));
62
+ if (url.pathname === '/api/demo/create' && req.method === 'POST') return json(res, await createDemoProject(root));
63
+ if (url.pathname === '/api/routescore/catalog') return json(res, await buildRouteScoreCatalog(root));
64
+ if (url.pathname === '/api/routescore/recommend' && req.method === 'POST') return json(res, await recommendRouteScore(await bodyJson(req), root));
65
+ if (url.pathname === '/api/routescore/event' && req.method === 'POST') return json(res, await routeScoreEvent(root, await bodyJson(req)));
66
+ if (url.pathname === '/api/x402/receipts/summary') return json(res, await summarizeX402Receipts({ rootDir: root }));
67
+ if (url.pathname === '/api/x402/receipts/ingest' && req.method === 'POST') return json(res, await ingestX402Receipt(await bodyJson(req), { rootDir: root, policy: await loadPolicyForRoot(root), source: 'dashboard' }));
68
+ if (url.pathname === '/api/telemetry/status') return json(res, await telemetryStatus(root));
69
+ if (url.pathname === '/api/telemetry/summary') return json(res, await telemetrySummaryApi(root));
70
+ if (url.pathname === '/api/telemetry/events') return json(res, await telemetryEventsApi(root, url));
71
+ if (url.pathname === '/api/telemetry/enable' && req.method === 'POST') return json(res, await setTelemetry(root, true));
72
+ if (url.pathname === '/api/telemetry/disable' && req.method === 'POST') return json(res, await setTelemetry(root, false));
73
+ if (url.pathname === '/api/configs') return json(res, buildConfigs());
74
+ return json(res, { error: 'Not found' }, 404);
75
+ } catch (error) {
76
+ return json(res, { error: error.message || String(error) }, 500);
77
+ }
78
+ });
79
+ }
80
+
81
+ async function getStatus(root, demo) {
82
+ const policyPath = path.join(root, 'mythos.policy.json');
83
+ return {
84
+ product: PRODUCT,
85
+ version: VERSION,
86
+ workspace: root,
87
+ platform: `${process.platform} ${process.arch}`,
88
+ node: process.version,
89
+ policyFound: await exists(policyPath),
90
+ demoMode: demo
91
+ };
92
+ }
93
+
94
+ async function loadPolicyForRoot(root) {
95
+ return loadPolicy(path.join(root, 'mythos.policy.json'));
96
+ }
97
+
98
+ async function savePolicy(root, body) {
99
+ if (!body || typeof body !== 'object') throw new Error('Policy body must be a JSON object.');
100
+ const policyPath = path.join(root, 'mythos.policy.json');
101
+ await writeJson(policyPath, body);
102
+ return { ok: true, path: policyPath, policy: body };
103
+ }
104
+
105
+ async function initWorkspace(root, body = {}) {
106
+ const policyPath = path.join(root, 'mythos.policy.json');
107
+ if ((await exists(policyPath)) && !body.force) return { ok: false, reason: 'policy_exists', message: 'mythos.policy.json already exists. Use force to overwrite.' };
108
+ const policy = structuredClone(defaultPolicy);
109
+ policy.project = path.basename(root);
110
+ if (body.base !== false) {
111
+ policy.version = '0.10';
112
+ policy.payments.x402.strategy = 'balanced';
113
+ policy.payments.x402.trustedDomains = ['api.coinbase.com', 'api.developer.coinbase.com', 'api.exa.ai', 'www.x402.org', 'x402.org'];
114
+ policy.payments.x402.allowedDomains = [];
115
+ policy.payments.x402.maxPerRequestUSDC = 0.25;
116
+ policy.payments.x402.maxDailyUSDC = 5;
117
+ policy.payments.x402.requireApprovalAboveUSDC = 0.25;
118
+ policy.network.allowedDomains.push('mainnet.base.org', 'base.org', 'api.exa.ai');
119
+ }
120
+ await writeJson(policyPath, policy);
121
+ await ensureDir(path.join(root, '.mythos/reports'));
122
+ await ensureDir(path.join(root, '.mythos/snapshots'));
123
+ return { ok: true, path: policyPath, policy };
124
+ }
125
+
126
+ async function scanWorkspace(root, body = {}) {
127
+ const target = resolveInside(root, body.target || '.');
128
+ const policy = await loadPolicyForRoot(root);
129
+ const report = await scanPath(target, { policy, failOn: body.failOn || 'none' });
130
+ let text = '';
131
+ try { text = formatScanReport(report); } catch { text = ''; }
132
+ return { ok: report.summary.ok, report, text };
133
+ }
134
+
135
+ async function checkPaymentApi(root, body = {}) {
136
+ const policy = await loadPolicyForRoot(root);
137
+ return checkPayment({
138
+ domain: body.domain,
139
+ amountUSDC: body.amount,
140
+ dailySpentUSDC: body.dailySpent || 0,
141
+ unknownDailySpentUSDC: body.unknownDailySpent || 0,
142
+ routeScore: body.routeScore,
143
+ category: body.category,
144
+ knownService: Boolean(body.knownService) || Boolean(serviceForDomain(body.domain, await loadRouteScoreServices({ rootDir: root })))
145
+ }, policy);
146
+ }
147
+
148
+ async function checkCommandApi(root, body = {}) {
149
+ const policy = await loadPolicyForRoot(root);
150
+ return checkCommand({ command: body.command }, policy);
151
+ }
152
+
153
+ async function checkFileApi(root, body = {}) {
154
+ const policy = await loadPolicyForRoot(root);
155
+ return checkFilesystemAccess({ filePath: body.path, operation: body.operation || 'read' }, policy);
156
+ }
157
+
158
+ async function checkNetworkApi(root, body = {}) {
159
+ const policy = await loadPolicyForRoot(root);
160
+ return checkNetwork({ domain: body.domain }, policy);
161
+ }
162
+
163
+ async function createDemoProject(root) {
164
+ const demoDir = path.join(root, '.mythos', 'demo-workspace');
165
+ await ensureDir(demoDir);
166
+ await fs.writeFile(path.join(demoDir, '.env'), `${'PRIVATE'}_${'KEY'}=demo_fake_key_do_not_use\n`, 'utf8');
167
+ await fs.writeFile(path.join(demoDir, 'agent.js'), 'console.log("demo agent requesting tools and x402 payments")\n', 'utf8');
168
+ await fs.writeFile(path.join(demoDir, 'skill.md'), 'Demo skill: ask Sentinel before shell, file, network, and payment actions.\n', 'utf8');
169
+ const policy = await loadPolicyForRoot(root);
170
+ const report = await scanPath(demoDir, { policy, failOn: 'none', ignore: [] });
171
+ return { ok: true, path: demoDir, report };
172
+ }
173
+
174
+ async function buildRouteScoreCatalog(root) {
175
+ const policy = await loadPolicyForRoot(root);
176
+ const telemetry = await telemetrySummary({ rootDir: root, policy });
177
+ const availableServices = await loadRouteScoreServices({ rootDir: root });
178
+ const services = availableServices.map((service) => scoreService(service, telemetry.telemetry[service.id] || {}));
179
+ const summary = {
180
+ services: services.length,
181
+ prefer: services.filter((service) => service.recommendation === 'prefer').length,
182
+ trial: services.filter((service) => service.recommendation === 'trial_only').length,
183
+ avoid: services.filter((service) => service.recommendation === 'avoid_or_approval').length
184
+ };
185
+ return { ok: true, summary: { ...summary, passiveSamples: services.reduce((sum, service) => sum + Number(service.telemetry?.samples || 0), 0), telemetryEvents: telemetry.eventCount, telemetryEnabled: telemetryEnabled(policy) }, services, updatedAt: new Date().toISOString() };
186
+ }
187
+
188
+ async function recommendRouteScore(body = {}, root = process.cwd()) {
189
+ const policy = await loadPolicyForRoot(root);
190
+ const summary = await telemetrySummary({ rootDir: root, policy });
191
+ const services = await loadRouteScoreServices({ rootDir: root });
192
+ return recommendService({ category: body.category, maxPriceUSDC: body.maxPriceUSDC, query: body.query, services, telemetry: summary.telemetry });
193
+ }
194
+
195
+ async function routeScoreEvent(root, body = {}) {
196
+ const event = passiveTelemetryEvent(body);
197
+ const policy = await loadPolicyForRoot(root);
198
+ const stored = await appendTelemetryEvent({ rootDir: root, policy, event });
199
+ return { ok: true, event: stored.event || event, stored: stored.stored, privacy: stored.privacy, message: stored.stored ? 'Telemetry event stored locally.' : 'Telemetry disabled; event normalized but not persisted.' };
200
+ }
201
+
202
+ async function telemetryStatus(root) {
203
+ const policy = await loadPolicyForRoot(root);
204
+ const summary = await telemetrySummary({ rootDir: root, policy });
205
+ return { ok: true, enabled: telemetryEnabled(policy), privacy: telemetryPrivacy(policy), eventCount: summary.eventCount, servicesObserved: summary.aggregates.length };
206
+ }
207
+
208
+ async function telemetrySummaryApi(root) {
209
+ const policy = await loadPolicyForRoot(root);
210
+ return telemetrySummary({ rootDir: root, policy });
211
+ }
212
+
213
+ async function telemetryEventsApi(root, url) {
214
+ const policy = await loadPolicyForRoot(root);
215
+ return { ok: true, events: await readTelemetryEvents({ rootDir: root, policy, limit: url.searchParams.get('limit') || 100 }) };
216
+ }
217
+
218
+ async function setTelemetry(root, enabled) {
219
+ const policyPath = path.join(root, 'mythos.policy.json');
220
+ const policy = await loadPolicyForRoot(root);
221
+ const updated = await setTelemetryEnabled({ rootDir: root, policy, enabled });
222
+ await writeJson(policyPath, updated);
223
+ return { ok: true, enabled, privacy: telemetryPrivacy(updated), policy: updated };
224
+ }
225
+
226
+ function buildConfigs() {
227
+ const proxy = {
228
+ mcpServers: {
229
+ 'mythos-sentinel-proxy': {
230
+ command: 'npx',
231
+ args: ['mythos-sentinel', 'proxy']
232
+ }
233
+ }
234
+ };
235
+ const direct = {
236
+ mcpServers: {
237
+ 'mythos-sentinel': { command: 'npx', args: ['mythos-sentinel', 'mcp'] }
238
+ }
239
+ };
240
+
241
+ return {
242
+ proxy: JSON.stringify(proxy, null, 2),
243
+ mcp: JSON.stringify(direct, null, 2),
244
+ proxyPolicy: JSON.stringify({
245
+ mcpProxy: {
246
+ enabled: true,
247
+ mode: 'enforce',
248
+ approvalMode: 'return_error',
249
+ toolNameStrategy: 'preserve_unless_collision',
250
+ upstreams: [
251
+ { id: 'exa', command: 'npx', args: ['-y', '@example/exa-mcp-server'] },
252
+ { id: 'browser', command: 'npx', args: ['-y', '@example/browser-mcp-server'] }
253
+ ]
254
+ }
255
+ }, null, 2),
256
+ codex: [
257
+ '# Mythos Sentinel agent rules',
258
+ 'Prefer connecting through mythos-sentinel proxy instead of direct tool servers when wallets, shell, files, or paid APIs are enabled.',
259
+ 'If proxy mode is unavailable, call sentinel_check_command, sentinel_check_file, sentinel_check_network, and sentinel_check_x402_payment before risky actions.',
260
+ 'For x402/Base payments, respect Sentinel decisions: allow continues, approval_required asks the human, block stops immediately.',
261
+ 'Never read .env, wallet keys, SSH keys, or seed phrases unless Sentinel explicitly allows it.'
262
+ ].join('\n'),
263
+ cursorRule: [
264
+ '---',
265
+ 'description: Mythos Sentinel runtime proxy and guardrails for agent actions',
266
+ 'alwaysApply: true',
267
+ '---',
268
+ 'Use mythos-sentinel proxy for tool access when available. Do not bypass it for x402 payments, shell, file, or network actions.',
269
+ 'If a Sentinel decision is approval_required, ask the human before continuing. If block, stop.'
270
+ ].join('\n')
271
+ };
272
+ }
273
+
274
+ function resolveInside(root, requested) {
275
+ const resolved = path.resolve(root, requested || '.');
276
+ if (!resolved.startsWith(root) && !resolved.startsWith(os.tmpdir())) throw new Error(`Refusing to access path outside workspace: ${requested}`);
277
+ return resolved;
278
+ }
279
+
280
+ async function serveFile(res, filename, contentType) {
281
+ const filePath = path.join(STATIC_DIR, filename);
282
+ const data = await fs.readFile(filePath);
283
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' });
284
+ res.end(data);
285
+ }
286
+
287
+ async function bodyJson(req) {
288
+ const chunks = [];
289
+ for await (const chunk of req) chunks.push(chunk);
290
+ const raw = Buffer.concat(chunks).toString('utf8');
291
+ if (!raw) return {};
292
+ return JSON.parse(raw);
293
+ }
294
+
295
+ function json(res, payload, status = 200) {
296
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
297
+ res.end(`${JSON.stringify(payload, null, 2)}\n`);
298
+ }
299
+
300
+ function normalizeDashboardHost(host) {
301
+ const value = String(host || '127.0.0.1').trim();
302
+
303
+ if (value === 'localhost' || value === '127.0.0.1' || value === '0.0.0.0' || value === '::1' || value === '[::1]') {
304
+ return value;
305
+ }
306
+
307
+ throw new Error(`Refusing unsafe dashboard host: ${value}`);
308
+ }
309
+
310
+ function normalizeDashboardPort(port) {
311
+ const value = Number(port);
312
+
313
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
314
+ throw new Error(`Invalid dashboard port: ${port}`);
315
+ }
316
+
317
+ return value;
318
+ }
319
+
320
+ function createDashboardUrl(host, port) {
321
+ const safeHost = host === '0.0.0.0' ? '127.0.0.1' : host;
322
+ return `http://${safeHost}:${normalizeDashboardPort(port)}`;
323
+ }
324
+
325
+ function openBrowser(url) {
326
+ const parsed = new URL(url);
327
+
328
+ if (parsed.protocol !== 'http:' || !['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)) {
329
+ throw new Error(`Refusing to open unsafe dashboard URL: ${url}`);
330
+ }
331
+
332
+ const launch = browserLaunchCommand(process.platform, parsed.toString());
333
+ const child = spawn(launch.command, launch.args, {
334
+ detached: true,
335
+ stdio: 'ignore',
336
+ shell: false
337
+ });
338
+
339
+ child.unref();
340
+ }
341
+
342
+ function browserLaunchCommand(platform, url) {
343
+ if (platform === 'darwin') return { command: 'open', args: [url] };
344
+ if (platform === 'win32') return { command: 'cmd', args: ['/c', 'start', '', url] };
345
+ return { command: 'xdg-open', args: [url] };
346
+ }
@@ -0,0 +1,210 @@
1
+ const $ = (id) => document.getElementById(id);
2
+ let policy = null;
3
+ let configs = null;
4
+ let routeCatalog = null;
5
+ let telemetrySummary = null;
6
+
7
+ async function api(path, body) {
8
+ const res = await fetch(path, body === undefined ? undefined : {
9
+ method: 'POST',
10
+ headers: { 'content-type': 'application/json' },
11
+ body: JSON.stringify(body)
12
+ });
13
+ const json = await res.json();
14
+ if (!res.ok) throw new Error(json.error || res.statusText);
15
+ return json;
16
+ }
17
+
18
+ function listToTextarea(values = []) { return values.join('\n'); }
19
+ function textareaToList(value = '') { return value.split(/\r?\n|,/).map((x) => x.trim()).filter(Boolean); }
20
+
21
+ async function boot() {
22
+ const status = await api('/api/status');
23
+ $('policyStatus').textContent = status.policyFound ? 'found' : 'not found';
24
+ $('versionStatus').textContent = status.version;
25
+ $('workspaceStatus').textContent = status.workspace.split('/').slice(-2).join('/');
26
+ await loadPolicy();
27
+ configs = await api('/api/configs');
28
+ $('proxyConfig').textContent = configs.proxy;
29
+ $('mcpConfig').textContent = configs.mcp;
30
+ $('agentRules').textContent = configs.codex;
31
+ await loadTelemetry();
32
+ await loadRouteScore();
33
+ }
34
+
35
+ async function loadPolicy() {
36
+ policy = await api('/api/policy');
37
+ const x402 = policy.payments?.x402 || {};
38
+ $('strategy').value = x402.strategy || 'balanced';
39
+ $('strategyStatus').textContent = x402.strategy || 'balanced';
40
+ $('trustedDomains').value = listToTextarea(x402.trustedDomains?.length ? x402.trustedDomains : x402.allowedDomains || []);
41
+ $('maxPerRequest').value = x402.maxPerRequestUSDC ?? '';
42
+ $('approvalAbove').value = x402.requireApprovalAboveUSDC ?? '';
43
+ $('maxDaily').value = x402.maxDailyUSDC ?? '';
44
+ $('unknownMax').value = x402.unknown?.maxPerRequestUSDC ?? '';
45
+ $('unknownDailyMax').value = x402.unknown?.maxDailyUSDC ?? '';
46
+ $('autoScore').value = x402.routeScore?.autoAllowMinScore ?? '';
47
+ }
48
+
49
+ async function savePolicy() {
50
+ const next = structuredClone(policy);
51
+ next.payments ??= {};
52
+ next.payments.x402 ??= {};
53
+ next.payments.x402.strategy = $('strategy').value;
54
+ next.payments.x402.trustedDomains = textareaToList($('trustedDomains').value);
55
+ next.payments.x402.allowedDomains = [];
56
+ next.payments.x402.maxPerRequestUSDC = Number($('maxPerRequest').value || 0);
57
+ next.payments.x402.requireApprovalAboveUSDC = Number($('approvalAbove').value || 0);
58
+ next.payments.x402.maxDailyUSDC = Number($('maxDaily').value || 0);
59
+ next.payments.x402.unknown ??= {};
60
+ next.payments.x402.unknown.allowTrial = true;
61
+ next.payments.x402.unknown.maxPerRequestUSDC = Number($('unknownMax').value || 0);
62
+ next.payments.x402.unknown.maxDailyUSDC = Number($('unknownDailyMax').value || 0);
63
+ next.payments.x402.unknown.requireApprovalAboveUSDC = Number($('unknownMax').value || 0);
64
+ next.payments.x402.routeScore ??= {};
65
+ next.payments.x402.routeScore.autoAllowMinScore = Number($('autoScore').value || 80);
66
+ const saved = await api('/api/policy', next);
67
+ policy = saved.policy;
68
+ $('strategyStatus').textContent = next.payments.x402.strategy;
69
+ showDecision({ decision: 'saved', ok: true, reasons: ['mythos.policy.json updated'] });
70
+ }
71
+
72
+ async function loadRouteScore() {
73
+ routeCatalog = await api('/api/routescore/catalog');
74
+ $('routeMetrics').innerHTML = [
75
+ metric('services', routeCatalog.summary.services),
76
+ metric('prefer', routeCatalog.summary.prefer),
77
+ metric('trial', routeCatalog.summary.trial),
78
+ metric('avoid', routeCatalog.summary.avoid),
79
+ metric('samples', routeCatalog.summary.passiveSamples || 0)
80
+ ].join('');
81
+ $('routeCatalog').innerHTML = routeCatalog.services.map(serviceCard).join('');
82
+ }
83
+
84
+ function metric(label, value) {
85
+ return `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
86
+ }
87
+
88
+ function serviceCard(service) {
89
+ return `<article class="service-card">
90
+ <div class="service-top"><strong>${escapeHtml(service.name)}</strong><span class="score ${escapeHtml(service.risk)}">${service.score}</span></div>
91
+ <div class="muted-row">${escapeHtml(service.category)} · ${escapeHtml(service.domain)} · $${escapeHtml(service.priceUSDC)}</div>
92
+ <p>${escapeHtml(service.note)}</p>
93
+ <small>${escapeHtml(service.recommendation)} · ${escapeHtml(service.reasons.slice(0, 2).join(' · '))}</small>
94
+ </article>`;
95
+ }
96
+
97
+ async function loadTelemetry() {
98
+ const status = await api('/api/telemetry/status');
99
+ telemetrySummary = await api('/api/telemetry/summary');
100
+ $('telemetryStatus').textContent = status.enabled ? 'enabled' : 'disabled';
101
+ $('telemetryMetrics').innerHTML = [
102
+ metric('enabled', status.enabled ? 'yes' : 'no'),
103
+ metric('events', telemetrySummary.eventCount),
104
+ metric('services', telemetrySummary.aggregates.length),
105
+ metric('privacy', 'local')
106
+ ].join('');
107
+ $('telemetrySummary').innerHTML = telemetrySummary.aggregates.length
108
+ ? telemetrySummary.aggregates.map((item) => `<article class="service-card compact-card"><div class="service-top"><strong>${escapeHtml(item.serviceId)}</strong><span class="score low">${Math.round(item.successRate * 100)}%</span></div><div class="muted-row">${item.samples} samples · ${item.medianLatencyMs ?? 'n/a'}ms median · $${Number(item.totalAmountUSDC || 0).toFixed(4)} observed</div><small>Last observed ${escapeHtml(item.lastObservedAt || 'never')}</small></article>`).join('')
109
+ : '<div class="quiet-box">No local telemetry yet. Enable it, route real calls through proxy, or create a demo event.</div>';
110
+ }
111
+
112
+ async function setTelemetry(enabled) {
113
+ await api(enabled ? '/api/telemetry/enable' : '/api/telemetry/disable', {});
114
+ await loadPolicy();
115
+ await loadTelemetry();
116
+ await loadRouteScore();
117
+ showDecision({ ok: true, decision: enabled ? 'telemetry_enabled' : 'telemetry_disabled', reasons: [enabled ? 'local opt-in telemetry enabled' : 'local telemetry disabled', 'No prompts, responses, secrets, or wallet balances are stored.'] });
118
+ }
119
+
120
+ function showDecision(decision) {
121
+ const label = decision.ok ? 'ALLOW ✅' : decision.decision === 'approval_required' ? 'APPROVAL REQUIRED ⚠️' : 'BLOCK ⛔';
122
+
123
+ // Update console borders based on state
124
+ $('resultSummary').classList.remove('empty', 'state-allow', 'state-approval', 'state-block');
125
+ if (decision.ok) {
126
+ $('resultSummary').classList.add('state-allow');
127
+ } else if (decision.decision === 'approval_required') {
128
+ $('resultSummary').classList.add('state-approval');
129
+ } else {
130
+ $('resultSummary').classList.add('state-block');
131
+ }
132
+
133
+ $('resultSummary').textContent = `${label}\n${decision.subject ? `Subject: ${decision.subject}\n` : ''}${decision.trustTier ? `Trust: ${decision.trustTier}\n` : ''}${decision.amountUSDC !== undefined ? `Amount: ${decision.amountUSDC} USDC\n` : ''}${decision.routeScore !== null && decision.routeScore !== undefined ? `RouteScore: ${decision.routeScore}/100\n` : ''}${(decision.reasons || []).map((r) => `- ${r}`).join('\n')}`;
134
+ $('findings').innerHTML = '';
135
+ }
136
+
137
+ function showReport(report) {
138
+ // Update console borders based on scan result
139
+ $('resultSummary').classList.remove('empty', 'state-allow', 'state-approval', 'state-block');
140
+ if (report.summary.ok) {
141
+ $('resultSummary').classList.add('state-allow');
142
+ } else {
143
+ $('resultSummary').classList.add('state-block');
144
+ }
145
+
146
+ $('resultSummary').textContent = `Scan complete\nFindings: ${report.summary.findingCount}\nHighest: ${report.summary.highestSeverity.toUpperCase()}\nStatus: ${report.summary.ok ? 'PASS' : 'REVIEW'}`;
147
+ $('findings').innerHTML = report.findings.slice(0, 12).map((f) => `
148
+ <div class="finding">
149
+ <div class="top"><strong>${escapeHtml(f.id)} · ${escapeHtml(f.title)}</strong><span class="badge ${f.severity}">${f.severity}</span></div>
150
+ <div>${escapeHtml(f.file)}:${f.line}</div>
151
+ <small>${escapeHtml(f.recommendation || '')}</small>
152
+ </div>`).join('') || '<div class="finding"><strong>No findings.</strong><small>This workspace is clean under the current scan rules.</small></div>';
153
+ }
154
+
155
+ function showRecommendation(rec) {
156
+ if (!rec.best) return showDecision({ ok: false, decision: 'approval_required', reasons: [rec.note] });
157
+ showDecision({
158
+ ok: true,
159
+ decision: 'allow',
160
+ subject: rec.best.domain,
161
+ routeScore: rec.best.score,
162
+ reasons: [`Recommended ${rec.best.name}`, `endpoint ${rec.best.endpoint}`, `price ${rec.best.priceUSDC} USDC`, ...rec.best.reasons]
163
+ });
164
+ }
165
+
166
+ function escapeHtml(value) {
167
+ return String(value ?? '').replace(/[&<>\"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
168
+ }
169
+
170
+ $('initBase').addEventListener('click', async () => {
171
+ const res = await api('/api/init', { base: true, force: true });
172
+ policy = res.policy;
173
+ await loadPolicy();
174
+ showDecision({ ok: true, decision: 'allow', reasons: ['Adaptive Base/x402 policy initialized'] });
175
+ });
176
+
177
+ $('createDemo').addEventListener('click', async () => showReport((await api('/api/demo/create', {})).report));
178
+ $('savePolicy').addEventListener('click', savePolicy);
179
+ $('recommendRoute').addEventListener('click', async () => showRecommendation(await api('/api/routescore/recommend', { category: $('routeCategory').value, maxPriceUSDC: 0.05 })));
180
+ $('checkPayment').addEventListener('click', async () => showDecision(await api('/api/check-payment', {
181
+ domain: $('payDomain').value || 'api.exa.ai',
182
+ amount: Number($('payAmount').value || 0.01),
183
+ routeScore: $('payScore').value ? Number($('payScore').value) : undefined
184
+ })));
185
+ $('checkCommand').addEventListener('click', async () => showDecision(await api('/api/check-command', { command: $('commandInput').value || 'npm test' })));
186
+ $('checkFile').addEventListener('click', async () => showDecision(await api('/api/check-file', { path: $('filePath').value || '.env', operation: $('fileOp').value || 'read' })));
187
+ $('scanWorkspace').addEventListener('click', async () => showReport((await api('/api/scan', { target: '.', failOn: 'none' })).report));
188
+ $('enableTelemetry').addEventListener('click', async () => setTelemetry(true));
189
+ $('disableTelemetry').addEventListener('click', async () => setTelemetry(false));
190
+ $('recordTelemetryDemo').addEventListener('click', async () => {
191
+ const res = await api('/api/routescore/event', { domain: 'api.exa.ai', ok: true, latencyMs: 760, amountUSDC: 0.005, decision: 'allow', source: 'dashboard_demo' });
192
+ await loadTelemetry();
193
+ await loadRouteScore();
194
+ showDecision({ ok: true, decision: res.stored ? 'telemetry_stored' : 'telemetry_disabled', reasons: [res.message] });
195
+ });
196
+
197
+ document.querySelectorAll('[data-copy]').forEach((button) => {
198
+ button.addEventListener('click', async () => {
199
+ const id = button.getAttribute('data-copy');
200
+ await navigator.clipboard.writeText($(id).textContent);
201
+ const before = button.textContent;
202
+ button.textContent = 'Copied';
203
+ setTimeout(() => { button.textContent = before; }, 1100);
204
+ });
205
+ });
206
+
207
+ boot().catch((error) => {
208
+ $('resultSummary').classList.remove('empty');
209
+ $('resultSummary').textContent = `Dashboard error: ${error.message}`;
210
+ });