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,54 @@
1
+ import path from 'node:path';
2
+
3
+ export function normalizePath(filePath) {
4
+ return filePath.replaceAll('\\', '/').replace(/^\.\//, '');
5
+ }
6
+
7
+ export function toPosixRelative(base, target) {
8
+ return normalizePath(path.relative(base, target) || '.');
9
+ }
10
+
11
+ export function globToRegExp(glob) {
12
+ const normalized = normalizePath(glob).replace(/^\/+/, '');
13
+ let out = '^';
14
+ for (let i = 0; i < normalized.length; i++) {
15
+ const c = normalized[i];
16
+ const next = normalized[i + 1];
17
+ if (c === '*') {
18
+ if (next === '*') {
19
+ const after = normalized[i + 2];
20
+ if (after === '/') {
21
+ out += '(?:.*\/)?';
22
+ i += 2;
23
+ } else {
24
+ out += '.*';
25
+ i += 1;
26
+ }
27
+ } else {
28
+ out += '[^/]*';
29
+ }
30
+ } else if (c === '?') {
31
+ out += '[^/]';
32
+ } else if ('\\.^$+{}()|[]'.includes(c)) {
33
+ out += `\\${c}`;
34
+ } else {
35
+ out += c;
36
+ }
37
+ }
38
+ out += '$';
39
+ return new RegExp(out);
40
+ }
41
+
42
+ export function matchesAnyGlob(filePath, globs = []) {
43
+ const normalized = normalizePath(filePath);
44
+ return globs.some((glob) => globToRegExp(glob).test(normalized));
45
+ }
46
+
47
+ export function safeJoinInside(base, target) {
48
+ const resolvedBase = path.resolve(base);
49
+ const resolvedTarget = path.resolve(resolvedBase, target);
50
+ if (!resolvedTarget.startsWith(resolvedBase)) {
51
+ throw new Error(`Refusing to access path outside ${resolvedBase}: ${target}`);
52
+ }
53
+ return resolvedTarget;
54
+ }
@@ -0,0 +1,326 @@
1
+ import fs from 'node:fs/promises';
2
+ import { exists } from './fs.js';
3
+ import { matchesAnyGlob, normalizePath } from './path-utils.js';
4
+
5
+ export const SEVERITY_ORDER = ['info', 'low', 'medium', 'high', 'critical'];
6
+
7
+ export const defaultPolicy = Object.freeze({
8
+ version: '0.10',
9
+ mode: 'enforce',
10
+ project: 'mythos-sentinel-project',
11
+ filesystem: {
12
+ deny: ['.env', '.env.*', '**/.env', '**/.env.*', '**/id_rsa', '**/id_ed25519', '**/*.pem', '**/*.key', '**/*.p12', '**/*.pfx'],
13
+ allowRead: ['**/*'],
14
+ allowWrite: ['src/**', 'test/**', 'docs/**', 'examples/**', '.github/workflows/**', 'README.md', 'package.json', 'package-lock.json', 'mythos.policy.json']
15
+ },
16
+ commands: {
17
+ blockedPatterns: [
18
+ 'curl\\s+[^|]+\\|\\s*(sudo\\s+)?(bash|sh|zsh)',
19
+ 'wget\\s+[^|]+\\|\\s*(sudo\\s+)?(bash|sh|zsh)',
20
+ 'Invoke-WebRequest[^|]+\\|\\s*iex',
21
+ 'iwr\\s+[^|]+\\s*\\|\\s*iex',
22
+ 'rm\\s+-rf\\s+(/|~|\\$HOME|\\.\\./)',
23
+ 'chmod\\s+777',
24
+ 'base64\\s+-d\\s+[^|]+\\|\\s*(bash|sh|zsh)',
25
+ 'powershell\\s+.*-enc(odedcommand)?'
26
+ ],
27
+ approvalPatterns: [
28
+ 'npm\\s+install',
29
+ 'pnpm\\s+install',
30
+ 'yarn\\s+add',
31
+ 'pip\\s+install',
32
+ 'docker\\s+run',
33
+ 'git\\s+push'
34
+ ]
35
+ },
36
+ network: {
37
+ blockUnknown: false,
38
+ allowedDomains: ['api.github.com', 'api.openai.com', 'api.anthropic.com', 'api.coinbase.com', 'api.developer.coinbase.com', 'api.exa.ai'],
39
+ deniedDomains: []
40
+ },
41
+ payments: {
42
+ x402: {
43
+ enabled: true,
44
+ strategy: 'balanced',
45
+ enforceAllowlist: false,
46
+ maxPerRequestUSDC: 0.25,
47
+ maxDailyUSDC: 5,
48
+ requireApprovalAboveUSDC: 0.25,
49
+ trustedDomains: ['api.coinbase.com', 'api.developer.coinbase.com', 'api.exa.ai', 'www.x402.org', 'x402.org'],
50
+ allowedDomains: [],
51
+ deniedDomains: [],
52
+ unknown: {
53
+ allowTrial: true,
54
+ maxPerRequestUSDC: 0.02,
55
+ maxDailyUSDC: 0.25,
56
+ requireApprovalAboveUSDC: 0.02
57
+ },
58
+ routeScore: {
59
+ autoAllowMinScore: 80,
60
+ requireApprovalBelowScore: 60,
61
+ blockBelowScore: 35
62
+ }
63
+ }
64
+ },
65
+ routeScore: {
66
+ enabled: true,
67
+ catalogMode: 'seed',
68
+ telemetry: {
69
+ enabled: false,
70
+ anonymous: true,
71
+ localOnly: true,
72
+ storePath: '.mythos/telemetry/events.jsonl',
73
+ collectPrompts: false,
74
+ collectResponses: false,
75
+ collectWalletBalances: false
76
+ },
77
+ seedCategories: ['web_search', 'content_extraction', 'inference', 'web3_data', 'wallet_intel']
78
+ },
79
+ mcpProxy: {
80
+ enabled: true,
81
+ mode: 'enforce',
82
+ approvalMode: 'return_error',
83
+ toolNameStrategy: 'preserve_unless_collision',
84
+ exposeSentinelTools: false,
85
+ upstreams: []
86
+ },
87
+ findings: {
88
+ failOn: ['critical', 'high'],
89
+ warnOn: ['medium']
90
+ },
91
+ scanner: {
92
+ ignore: ['mythos.policy.json'],
93
+ useMythosIgnore: true
94
+ },
95
+ receipts: {
96
+ require: true,
97
+ includeFileHashes: true
98
+ }
99
+ });
100
+
101
+ export async function loadPolicy(policyPath = 'mythos.policy.json') {
102
+ if (!(await exists(policyPath))) return structuredClone(defaultPolicy);
103
+ const raw = await fs.readFile(policyPath, 'utf8');
104
+ let parsed;
105
+ try {
106
+ parsed = JSON.parse(raw);
107
+ } catch (error) {
108
+ throw new Error(`Policy must be JSON for v0.2. Could not parse ${policyPath}: ${error.message}`);
109
+ }
110
+ return mergePolicy(defaultPolicy, parsed);
111
+ }
112
+
113
+ export function mergePolicy(base, overrides) {
114
+ if (Array.isArray(base) || Array.isArray(overrides)) return overrides ?? base;
115
+ if (isObject(base) && isObject(overrides)) {
116
+ const result = { ...base };
117
+ for (const [key, value] of Object.entries(overrides)) result[key] = mergePolicy(base[key], value);
118
+ return result;
119
+ }
120
+ return overrides ?? base;
121
+ }
122
+
123
+ function isObject(value) {
124
+ return value && typeof value === 'object' && !Array.isArray(value);
125
+ }
126
+
127
+ export function severityAtLeast(actual, threshold) {
128
+ return SEVERITY_ORDER.indexOf(actual) >= SEVERITY_ORDER.indexOf(threshold);
129
+ }
130
+
131
+ export function highestSeverity(findings = []) {
132
+ return findings.reduce((highest, finding) => {
133
+ return SEVERITY_ORDER.indexOf(finding.severity) > SEVERITY_ORDER.indexOf(highest) ? finding.severity : highest;
134
+ }, 'info');
135
+ }
136
+
137
+ export function evaluateFindings(findings, policy, failOnOverride) {
138
+ const failOn = failOnOverride && failOnOverride !== 'none' ? [failOnOverride] : policy.findings?.failOn || [];
139
+ const failing = failOnOverride === 'none' ? [] : findings.filter((finding) =>
140
+ failOn.some((severity) => severityAtLeast(finding.severity, severity))
141
+ );
142
+ return {
143
+ ok: failing.length === 0,
144
+ highestSeverity: highestSeverity(findings),
145
+ findingCount: findings.length,
146
+ failingCount: failing.length,
147
+ failing
148
+ };
149
+ }
150
+
151
+ export function normalizeDomain(input) {
152
+ if (!input) return '';
153
+ try {
154
+ const withProtocol = /^[a-z]+:\/\//i.test(input) ? input : `https://${input}`;
155
+ return new URL(withProtocol).hostname.toLowerCase();
156
+ } catch {
157
+ return String(input).toLowerCase().replace(/^https?:\/\//, '').split('/')[0];
158
+ }
159
+ }
160
+
161
+ export function domainMatches(domain, pattern) {
162
+ const normalized = normalizeDomain(domain);
163
+ const normalizedPattern = normalizeDomain(pattern);
164
+ return normalized === normalizedPattern || normalized.endsWith(`.${normalizedPattern}`);
165
+ }
166
+
167
+ export function checkNetwork({ domain }, policy) {
168
+ const normalizedDomain = normalizeDomain(domain);
169
+ const network = policy.network || {};
170
+ const reasons = [];
171
+
172
+ if (!normalizedDomain) reasons.push('missing network domain');
173
+ if ((network.deniedDomains || []).some((pattern) => domainMatches(normalizedDomain, pattern))) reasons.push(`domain denied: ${normalizedDomain}`);
174
+ if (network.blockUnknown && (network.allowedDomains || []).length && !(network.allowedDomains || []).some((pattern) => domainMatches(normalizedDomain, pattern))) {
175
+ reasons.push(`domain not in network allowlist: ${normalizedDomain}`);
176
+ }
177
+
178
+ return reasons.length ? { ok: false, decision: 'block', subject: normalizedDomain, reasons } : { ok: true, decision: 'allow', subject: normalizedDomain, reasons: ['network access within policy'] };
179
+ }
180
+
181
+ export function checkCommand({ command }, policy) {
182
+ const value = String(command || '').trim();
183
+ const reasons = [];
184
+ const approvals = [];
185
+ if (!value) reasons.push('missing command');
186
+
187
+ for (const pattern of policy.commands?.blockedPatterns || []) {
188
+ const regex = safeRegex(pattern);
189
+ if (regex?.test(value)) reasons.push(`blocked command pattern matched: ${pattern}`);
190
+ }
191
+ for (const pattern of policy.commands?.approvalPatterns || []) {
192
+ const regex = safeRegex(pattern);
193
+ if (regex?.test(value)) approvals.push(`approval pattern matched: ${pattern}`);
194
+ }
195
+
196
+ if (reasons.length) return { ok: false, decision: 'block', subject: redactCommand(value), reasons };
197
+ if (approvals.length) return { ok: false, decision: 'approval_required', subject: redactCommand(value), reasons: approvals };
198
+ return { ok: true, decision: 'allow', subject: redactCommand(value), reasons: ['command within policy'] };
199
+ }
200
+
201
+ export function checkFilesystemAccess({ filePath, operation = 'read' }, policy) {
202
+ const rel = normalizePath(String(filePath || '').replace(/^\.\//, ''));
203
+ const fsPolicy = policy.filesystem || {};
204
+ const reasons = [];
205
+ const op = String(operation || 'read').toLowerCase();
206
+
207
+ if (!rel) reasons.push('missing file path');
208
+ if (matchesAnyGlob(rel, fsPolicy.deny || [])) reasons.push(`path denied by filesystem policy: ${rel}`);
209
+ if (op === 'write' && (fsPolicy.allowWrite || []).length && !matchesAnyGlob(rel, fsPolicy.allowWrite || [])) {
210
+ reasons.push(`write path not in allowWrite list: ${rel}`);
211
+ }
212
+ if (op === 'read' && (fsPolicy.allowRead || []).length && !matchesAnyGlob(rel, fsPolicy.allowRead || [])) {
213
+ reasons.push(`read path not in allowRead list: ${rel}`);
214
+ }
215
+
216
+ return reasons.length ? { ok: false, decision: 'block', subject: rel, operation: op, reasons } : { ok: true, decision: 'allow', subject: rel, operation: op, reasons: ['filesystem access within policy'] };
217
+ }
218
+
219
+ export function checkPayment({
220
+ domain,
221
+ amountUSDC,
222
+ dailySpentUSDC = 0,
223
+ unknownDailySpentUSDC = 0,
224
+ routeScore,
225
+ category,
226
+ knownService = false
227
+ }, policy) {
228
+ const x402 = policy.payments?.x402 || {};
229
+ if (!x402.enabled) return { ok: true, decision: 'allow', reasons: ['x402 guard disabled'] };
230
+
231
+ const normalizedDomain = normalizeDomain(domain);
232
+ const amount = Number(amountUSDC);
233
+ const daily = Number(dailySpentUSDC || 0);
234
+ const unknownDaily = Number(unknownDailySpentUSDC || 0);
235
+ const routeScoreValue = routeScore === undefined || routeScore === null || routeScore === '' ? null : Number(routeScore);
236
+ const strategy = x402.strategy || (x402.enforceAllowlist ? 'strict' : 'balanced');
237
+ const trustedDomains = [...(x402.trustedDomains || []), ...(x402.allowedDomains || [])];
238
+ const isTrustedDomain = trustedDomains.some((pattern) => domainMatches(normalizedDomain, pattern));
239
+ const blockReasons = [];
240
+ const approvalReasons = [];
241
+ const reasons = [];
242
+
243
+ if (!normalizedDomain) blockReasons.push('missing payment domain');
244
+ if (!Number.isFinite(amount) || amount < 0) blockReasons.push('invalid payment amount');
245
+ if ((x402.deniedDomains || []).some((pattern) => domainMatches(normalizedDomain, pattern))) blockReasons.push(`domain denied: ${normalizedDomain}`);
246
+
247
+ if (x402.enforceAllowlist || strategy === 'strict') {
248
+ if (trustedDomains.length && !isTrustedDomain) approvalReasons.push(`domain not trusted for automatic x402 spend: ${normalizedDomain}`);
249
+ }
250
+
251
+ if (Number.isFinite(x402.maxPerRequestUSDC) && amount > x402.maxPerRequestUSDC) {
252
+ blockReasons.push(`amount ${amount} USDC exceeds maxPerRequestUSDC ${x402.maxPerRequestUSDC}`);
253
+ }
254
+ if (Number.isFinite(x402.maxDailyUSDC) && daily + amount > x402.maxDailyUSDC) {
255
+ blockReasons.push(`daily spend ${daily + amount} USDC exceeds maxDailyUSDC ${x402.maxDailyUSDC}`);
256
+ }
257
+
258
+ const routePolicy = x402.routeScore || {};
259
+ if (Number.isFinite(routeScoreValue)) {
260
+ reasons.push(`RouteScore signal: ${routeScoreValue}/100`);
261
+ if (Number.isFinite(routePolicy.blockBelowScore) && routeScoreValue < routePolicy.blockBelowScore) {
262
+ blockReasons.push(`RouteScore ${routeScoreValue} is below blockBelowScore ${routePolicy.blockBelowScore}`);
263
+ } else if (Number.isFinite(routePolicy.requireApprovalBelowScore) && routeScoreValue < routePolicy.requireApprovalBelowScore) {
264
+ approvalReasons.push(`RouteScore ${routeScoreValue} is below approval threshold ${routePolicy.requireApprovalBelowScore}`);
265
+ }
266
+ }
267
+
268
+ const isRouteScoreTrusted = Number.isFinite(routeScoreValue) && routeScoreValue >= Number(routePolicy.autoAllowMinScore ?? 80);
269
+ const trustTier = isTrustedDomain ? 'trusted' : (knownService || isRouteScoreTrusted ? 'known' : 'unknown');
270
+
271
+ if (trustTier === 'trusted') {
272
+ reasons.push(`trusted payment domain: ${normalizedDomain}`);
273
+ } else if (trustTier === 'known') {
274
+ reasons.push(`known service${Number.isFinite(routeScoreValue) ? ` with RouteScore ${routeScoreValue}` : ''}`);
275
+ } else {
276
+ const unknown = x402.unknown || {};
277
+ reasons.push(`unknown x402 domain: ${normalizedDomain}`);
278
+ if (strategy === 'strict') {
279
+ approvalReasons.push('strict strategy requires approval for unknown payment domains');
280
+ } else if (unknown.allowTrial === false) {
281
+ approvalReasons.push('unknown-domain trial payments are disabled');
282
+ } else {
283
+ if (Number.isFinite(unknown.maxPerRequestUSDC) && amount > unknown.maxPerRequestUSDC) {
284
+ approvalReasons.push(`unknown-domain amount ${amount} USDC exceeds trial max ${unknown.maxPerRequestUSDC}`);
285
+ }
286
+ if (Number.isFinite(unknown.maxDailyUSDC) && unknownDaily + amount > unknown.maxDailyUSDC) {
287
+ approvalReasons.push(`unknown-domain daily spend ${unknownDaily + amount} USDC exceeds trial daily max ${unknown.maxDailyUSDC}`);
288
+ }
289
+ if (Number.isFinite(unknown.requireApprovalAboveUSDC) && amount > unknown.requireApprovalAboveUSDC) {
290
+ approvalReasons.push(`unknown-domain amount ${amount} USDC requires approval above ${unknown.requireApprovalAboveUSDC}`);
291
+ }
292
+ if (!approvalReasons.length) reasons.push('tiny unknown-domain trial spend allowed by adaptive policy');
293
+ }
294
+ }
295
+
296
+ if (Number.isFinite(x402.requireApprovalAboveUSDC) && amount > x402.requireApprovalAboveUSDC) {
297
+ approvalReasons.push(`amount ${amount} USDC requires human approval above ${x402.requireApprovalAboveUSDC}`);
298
+ }
299
+
300
+ const base = {
301
+ subject: normalizedDomain,
302
+ category: category || null,
303
+ amountUSDC: amount,
304
+ dailySpentUSDC: daily,
305
+ trustTier,
306
+ routeScore: Number.isFinite(routeScoreValue) ? routeScoreValue : null,
307
+ strategy,
308
+ reasons: [...reasons, ...blockReasons, ...approvalReasons]
309
+ };
310
+
311
+ if (blockReasons.length) return { ...base, ok: false, decision: 'block' };
312
+ if (approvalReasons.length) return { ...base, ok: false, decision: 'approval_required' };
313
+ return { ...base, ok: true, decision: 'allow' };
314
+ }
315
+
316
+ function safeRegex(pattern) {
317
+ try {
318
+ return new RegExp(pattern, 'i');
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ function redactCommand(command) {
325
+ return command.replace(/(PRIVATE_KEY|MNEMONIC|SEED_PHRASE|API_KEY|TOKEN|SECRET)(\s*[:=]\s*)['\"]?[^\s'\"]+/ig, '$1$2[REDACTED]');
326
+ }
@@ -0,0 +1,52 @@
1
+ import path from 'node:path';
2
+ import { readJson, writeJson } from './fs.js';
3
+ import { createSnapshot, diffSnapshots } from './snapshot.js';
4
+ import { scanPath } from '../scanner/scan.js';
5
+ import { evaluateFindings } from './policy.js';
6
+
7
+ export async function createReceipt({ beforePath, afterPath, rootDir = '.', summary = '', agent = 'unknown', provider = 'unknown', tool = 'unknown', policy }) {
8
+ const before = await readJson(beforePath);
9
+ const after = afterPath ? await readJson(afterPath) : await createSnapshot(rootDir);
10
+ const diff = diffSnapshots(before, after);
11
+ const scan = await scanPath(rootDir, { policy });
12
+ return {
13
+ schema: 'https://mythos.dev/schemas/agent-receipt.v0.json',
14
+ createdAt: new Date().toISOString(),
15
+ agent: { name: agent, provider, tool },
16
+ workspace: path.resolve(rootDir),
17
+ summary,
18
+ diff,
19
+ verification: {
20
+ scanner: 'mythos-sentinel',
21
+ findingCount: scan.summary.findingCount,
22
+ highestSeverity: scan.summary.highestSeverity,
23
+ ok: scan.summary.ok
24
+ },
25
+ snapshots: {
26
+ before,
27
+ after
28
+ }
29
+ };
30
+ }
31
+
32
+ export async function writeReceipt(outPath, receipt) {
33
+ await writeJson(outPath, receipt);
34
+ }
35
+
36
+ export async function verifyReceipt({ receiptPath, rootDir = '.', policy, failOn }) {
37
+ const receipt = await readJson(receiptPath);
38
+ if (!receipt.snapshots?.after?.files) throw new Error('Receipt is missing snapshots.after.files');
39
+ const current = await createSnapshot(rootDir);
40
+ const drift = diffSnapshots(receipt.snapshots.after, current);
41
+ const scan = await scanPath(rootDir, { policy, failOn });
42
+ const evaluation = evaluateFindings(scan.findings, policy, failOn);
43
+ return {
44
+ schema: 'https://mythos.dev/schemas/receipt-verification.v0.json',
45
+ checkedAt: new Date().toISOString(),
46
+ receipt: receiptPath,
47
+ ok: drift.changedCount === 0 && evaluation.ok,
48
+ drift,
49
+ scanSummary: scan.summary,
50
+ failingFindings: evaluation.failing
51
+ };
52
+ }