morphit/scripts/mcp-server-read-only-invariant-smoke.ts

377 lines
13 KiB
TypeScript

/**
* mcp-server read-only invariant smoke (cp149).
*
* Locks in the cp148 walkthrough's load-bearing trust claim:
* Charlie (the AI-agent persona invoking Morphit via the MCP
* server) is read-only by construction. The MCP server can
* search orders, fetch listings, list instances, list payment
* methods, and describe Morphit — but it cannot SIGN anything,
* MUTATE any user state, BROADCAST any transaction, or HOLD
* any keys.
*
* The cp148 walkthrough verified this inline with a grep over
* `apps/mcp-server/src/`. This smoke codifies that grep so
* the property gets re-checked on every CI run. If anyone
* ever imports a signing primitive into the MCP server, that's
* a major architectural shift requiring its own ADR — and this
* smoke makes that shift impossible to land silently.
*
* Three invariants:
*
* 1. No signing-primitive imports in mcp-server source.
* Forbidden module specifiers + symbol names. Walks
* every .ts file under apps/mcp-server/src/.
*
* 2. No mutation-API imports from the indexer/relay client
* packages. `@morphit/indexer-client` and
* `@morphit/relay-client` both export READ-only helpers
* (buildV1Url, fetchJson) AND mutation helpers
* (postOrder, postFeedback, etc.). The MCP server must
* only import the read-only ones.
*
* 3. The `fetchJson` callsite cardinality matches expectation.
* Every network call in apps/mcp-server/src/ must go through
* the centralized `fetchJson` from `indexerClient.ts`. Raw
* `fetch(` in tool code would bypass the cp146 SSRF defenses
* (redirect:'manual', User-Agent, URL redaction).
*
* Together: Charlie's trust posture as documented in cp148
* Persona 4 walkthrough is enforced by code, not just by
* convention.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
const ANSI_GREEN = '\x1b[32m';
const ANSI_RED = '\x1b[31m';
const ANSI_RESET = '\x1b[0m';
const REPO_ROOT = resolve(new URL('..', import.meta.url).pathname);
const MCP_SRC = join(REPO_ROOT, 'apps', 'mcp-server', 'src');
interface Result {
name: string;
passed: boolean;
detail?: string;
}
const results: Result[] = [];
function pass(name: string) {
results.push({ name, passed: true });
}
function fail(name: string, detail: string) {
results.push({ name, passed: false, detail });
}
/* ---------------- walk apps/mcp-server/src/ ---------------- */
function walkTsFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
out.push(...walkTsFiles(full));
} else if (entry.endsWith('.ts') && !entry.endsWith('.d.ts')) {
out.push(full);
}
}
return out;
}
const mcpFiles = walkTsFiles(MCP_SRC);
if (mcpFiles.length === 0) {
fail(
'at least one .ts file in apps/mcp-server/src/',
'walker found zero files — directory layout shifted?'
);
}
/* ---------------- invariant 1: no signing primitives ---------------- */
/**
* Each entry is a class of forbidden import. We check both the
* module specifier (the thing after `from '...'`) and the
* imported symbol names. Module-level matches are tighter; the
* symbol-name matches catch transient re-exports or future
* indirection.
*
* The list is intentionally conservative: anything that COULD be
* a signing primitive even by name. False positives are easy to
* carve out by adding to the allowlist; false negatives (a
* signing primitive sneaks in unnoticed) are the failure mode we
* cannot tolerate.
*/
interface SigningPattern {
module?: RegExp;
symbol?: RegExp;
label: string;
}
const SIGNING_PATTERNS: SigningPattern[] = [
// Direct libsodium imports. Morphit uses libsodium-wrappers
// in apps/web (via @noble/curves abstraction) for all crypto.
{ module: /^libsodium-wrappers(-sumo)?$/, label: 'libsodium' },
{ symbol: /^sodium$/, label: 'libsodium symbol' },
// secp256k1 / k256: used by @noble/curves for Blurt's
// signing curve. Should never appear in mcp-server.
{ module: /^@noble\/curves(\/|$)/, label: '@noble/curves' },
{ module: /^@noble\/secp256k1$/, label: '@noble/secp256k1' },
{ module: /^secp256k1$/, label: 'secp256k1' },
{ module: /^tiny-secp256k1$/, label: 'tiny-secp256k1' },
// Blurt-js / dpay / steem-js families. Any of these carry
// signing helpers.
{ module: /^@blurtfoundation\//, label: 'blurt SDK' },
{ module: /^blurt-js$/, label: 'blurt-js' },
{ module: /^dsteem$/, label: 'dsteem' },
{ module: /^@steempro\//, label: 'steempro SDK' },
// Internal Morphit signing primitives. These all live in
// apps/web/src/lib/crypto/ and are NEVER consumed outside
// the web app.
{ symbol: /^signTx$/, label: 'signTx' },
{ symbol: /^signAuthored$/, label: 'signAuthored' },
{ symbol: /^signPostingKey$/, label: 'signPostingKey' },
{ symbol: /^signActiveKey$/, label: 'signActiveKey' },
{ symbol: /^signMemoKey$/, label: 'signMemoKey' },
{ symbol: /^signMemo$/, label: 'signMemo' },
{ symbol: /^broadcastTransaction$/, label: 'broadcastTransaction' },
{ symbol: /^broadcastAuthored$/, label: 'broadcastAuthored' },
{ symbol: /^deriveKeyPair$/, label: 'deriveKeyPair' },
{ symbol: /^derivePostingKey$/, label: 'derivePostingKey' },
{ symbol: /^crypto_sign/, label: 'crypto_sign* (libsodium)' }
];
interface SigningHit {
file: string;
line: number;
text: string;
label: string;
}
const signingHits: SigningHit[] = [];
const IMPORT_RE = /^\s*import\s+(?:type\s+)?(?:\{([^}]*)\}|\*\s+as\s+(\w+)|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"]([^'"]+)['"]/;
for (const file of mcpFiles) {
const rel = relative(REPO_ROOT, file);
const lines = readFileSync(file, 'utf8').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(IMPORT_RE);
if (!m) continue;
const namedImports = (m[1] ?? '') + ',' + (m[4] ?? '');
const nsImport = m[2] ?? '';
const defImport = m[3] ?? '';
const moduleSpec = m[5];
const symbols = [
...namedImports
.split(',')
.map((s) => s.trim().split(/\s+as\s+/)[0].trim())
.filter(Boolean),
nsImport,
defImport
].filter(Boolean);
for (const pat of SIGNING_PATTERNS) {
if (pat.module && pat.module.test(moduleSpec)) {
signingHits.push({
file: rel,
line: i + 1,
text: line.trim(),
label: pat.label
});
}
if (pat.symbol) {
for (const sym of symbols) {
if (pat.symbol.test(sym)) {
signingHits.push({
file: rel,
line: i + 1,
text: line.trim(),
label: `${pat.label} (symbol: ${sym})`
});
}
}
}
}
}
}
if (signingHits.length === 0) {
pass(
`no signing/mutation primitives imported in apps/mcp-server/src/ (${mcpFiles.length} .ts files checked)`
);
} else {
fail(
'no signing/mutation primitives in apps/mcp-server/src/',
`SIGNING PRIMITIVE LEAKED INTO READ-ONLY MCP SERVER: ${signingHits
.map((h) => `${h.file}:${h.line} [${h.label}] — ${h.text}`)
.join('; ')}. This is a major architectural shift. Charlie (the AI-agent persona, cp148 walkthrough) is documented as read-only-by-construction; adding signing primitives to mcp-server invalidates the entire AI-agent trust model. If this is intentional, write an ADR explaining the shift first, then update the cp148 walkthrough, then carve a deliberate exception into this smoke's allowlist.`
);
}
/* ---------------- invariant 2: no mutation API from client packages ---------------- */
/**
* If mcp-server ever starts importing @morphit/indexer-client
* or @morphit/relay-client, audit the imports against this
* allowlist. Currently mcp-server imports nothing from those
* packages (it talks directly to /v1/ via fetch), so this is
* forward-looking.
*
* READ-only symbols that would be safe: buildV1Url, fetchJson,
* trimOrderRow, etc. MUTATION symbols that must NOT appear:
* postOrder, postFeedback, postFeedbackResponse, postCancel,
* anything starting with `post`, `submit`, `broadcast`, `cancel`,
* `mutate`, or `sign`.
*/
const MUTATION_SYMBOL_RE = /^(post|submit|broadcast|cancel|mutate|sign|publish|send)[A-Z]/;
interface MutationHit {
file: string;
line: number;
symbol: string;
source: string;
}
const mutationHits: MutationHit[] = [];
const CLIENT_PACKAGE_RE = /^@morphit\/(indexer-client|relay-client)$/;
for (const file of mcpFiles) {
const rel = relative(REPO_ROOT, file);
const lines = readFileSync(file, 'utf8').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(IMPORT_RE);
if (!m) continue;
const moduleSpec = m[5];
if (!CLIENT_PACKAGE_RE.test(moduleSpec)) continue;
const namedImports = ((m[1] ?? '') + ',' + (m[4] ?? ''))
.split(',')
.map((s) => s.trim().split(/\s+as\s+/)[0].trim())
.filter(Boolean);
for (const sym of namedImports) {
if (MUTATION_SYMBOL_RE.test(sym)) {
mutationHits.push({ file: rel, line: i + 1, symbol: sym, source: moduleSpec });
}
}
}
}
if (mutationHits.length === 0) {
pass(
'no mutation-API symbols imported from @morphit/{indexer,relay}-client'
);
} else {
fail(
'no mutation-API symbols from client packages',
`mutation imports: ${mutationHits.map((h) => `${h.file}:${h.line} ${h.symbol} from ${h.source}`).join('; ')}. Mutation helpers in the client packages exist for apps/web's signed-broadcast flows; mcp-server is read-only and must not consume them.`
);
}
/* ---------------- invariant 3: all network calls via fetchJson ---------------- */
/**
* The cp146 hardening (redirect:'manual', URL redaction in
* errors, User-Agent header, timeout via AbortController) is
* concentrated in `indexerClient.ts`'s `fetchJson()`. Any tool
* that uses raw `fetch(` instead of `fetchJson(` would bypass
* those defenses.
*
* Whitelist `indexerClient.ts` itself (it defines fetchJson and
* naturally contains the `fetch(` callsite that fetchJson
* delegates to). Everywhere else: raw `fetch(` is a smell.
*/
const FETCH_RE = /\bfetch\s*\(/;
const INDEXER_CLIENT_PATH = join(MCP_SRC, 'indexerClient.ts');
interface RawFetchHit {
file: string;
line: number;
text: string;
}
const rawFetchHits: RawFetchHit[] = [];
for (const file of mcpFiles) {
if (file === INDEXER_CLIENT_PATH) continue;
const rel = relative(REPO_ROOT, file);
const text = readFileSync(file, 'utf8');
const lines = text.split('\n');
let inBlockComment = false;
// cp153 — the simpler `stripComments` helper at
// `scripts/lib/strip-comments.ts` does a whole-text regex
// strip, which is faster but destroys line-number alignment
// (a multi-line `/* */` collapses to an empty string,
// shifting all subsequent lines). This smoke reports raw
// fetch() hits with file:line for remediation, so we need
// per-line state that preserves the original line numbers.
// Hence the inline state machine here. Confirmed in cp153
// that consolidation would lose the diagnostic precision.
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Strip line comments.
const lineCommentIdx = line.indexOf('//');
if (lineCommentIdx >= 0) line = line.slice(0, lineCommentIdx);
// Crude block-comment stripping. Good enough for this
// repo's prose-comment style; doesn't perfectly handle
// /* */ inside strings, which is fine for raw fetch
// detection.
if (inBlockComment) {
const end = line.indexOf('*/');
if (end < 0) continue;
line = line.slice(end + 2);
inBlockComment = false;
}
while (true) {
const start = line.indexOf('/*');
if (start < 0) break;
const end = line.indexOf('*/', start + 2);
if (end < 0) {
line = line.slice(0, start);
inBlockComment = true;
break;
}
line = line.slice(0, start) + line.slice(end + 2);
}
if (FETCH_RE.test(line)) {
rawFetchHits.push({ file: rel, line: i + 1, text: lines[i].trim() });
}
}
}
if (rawFetchHits.length === 0) {
pass(
'no raw fetch() calls outside indexerClient.ts (network calls all routed through fetchJson)'
);
} else {
fail(
'no raw fetch() outside indexerClient.ts',
`raw fetch() calls: ${rawFetchHits.map((h) => `${h.file}:${h.line}${h.text}`).join('; ')}. All network calls in apps/mcp-server/src/ must go through fetchJson() in indexerClient.ts so they inherit the cp146 hardening (redirect:'manual', User-Agent, URL redaction in errors, timeout via AbortController).`
);
}
/* ---------------- report ---------------- */
let failed = 0;
for (const r of results) {
if (r.passed) {
console.log(` ${ANSI_GREEN}${ANSI_RESET} ${r.name}`);
} else {
console.log(` ${ANSI_RED}${ANSI_RESET} ${r.name}`);
if (r.detail) console.log(` ${r.detail}`);
failed++;
}
}
console.log();
console.log('──────────────────────────────────────────────────────');
if (failed > 0) {
console.log(`${failed} of ${results.length} scenarios failed`);
process.exit(1);
} else {
console.log(`✓ all ${results.length} scenarios passed`);
}