Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions cli/mpprouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ async function cmdInfer(prompt: string, flags: Record<string, unknown>) {
// surface the detection so the forced private lane is never silent.
const det = detectSensitive(prompt)
if (det.sensitive && !json) {
console.log(`🔒 sensitive payload detected (${det.matches.join(', ')}) → forcing attested private lane`)

}

if (!config.agentPrivateKey) {
console.error('AGENT_PRIVATE_KEY not set (fund a Tempo testnet wallet)')

process.exit(2)
}

Expand All @@ -67,7 +67,7 @@ async function cmdInfer(prompt: string, flags: Record<string, unknown>) {
})

if (json) {
console.log(

JSON.stringify(
{
answer: res.answer,
Expand All @@ -84,10 +84,10 @@ async function cmdInfer(prompt: string, flags: Record<string, unknown>) {
}

if (res.attestation.postPay) {
console.log('\n── post-pay receipt verification ──\n' + formatReport(res.attestation.postPay))

}
console.log('\n🔓 decrypted answer (plaintext only ever seen by you + the attested enclave):\n' + res.answer)
console.log(`\n(${res.units} units · ${res.paid} pathUSD)`)


} catch (e) {
if (e instanceof AttestationError) {
console.error('⛔ ' + e.message + '\n' + formatReport(e.report))
Expand All @@ -104,13 +104,13 @@ async function cmdVerify(flags: Record<string, unknown>) {
expectedMeasurement: config.expectedMeasurement || undefined,
})
const report = await client.verify()
console.log(formatReport(report))

process.exit(report.ok ? 0 : 1)
}

function cmdDetect(text: string) {
const det = detectSensitive(text)
console.log(JSON.stringify({ sensitive: det.sensitive, matches: det.matches }, null, 2))

}

async function main() {
Expand Down
12 changes: 6 additions & 6 deletions mcp/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,23 @@ const client = new Client({ name: 'smoke', version: '0' })
await client.connect(transport)

const { tools } = await client.listTools()
console.log('TOOLS:', tools.map((t) => t.name).join(', '))

const res: any = await client.callTool({

// FIXME: replace 'any' with a proper type — auto-chore finding
name: 'detect_sensitive',
arguments: { text: 'rotate this leaked key sk-proj-1a2b3c4d5e6f7g8h9i0jklmnop' },
})
console.log('detect_sensitive →', res.content?.[0]?.text)


// Set MCP_PAID=1 to run a real paid private_inference against the configured server.
if (process.env.MCP_PAID) {
const inf: any = await client.callTool({
// FIXME: replace 'any' with a proper type — auto-chore finding
name: 'private_inference',
arguments: { prompt: 'A service leaked sk-proj-1a2b3c4d5e6f7g8h9i0jklmnop. One-line rotation step?' },
})
const t = inf.content?.[0]?.text ?? ''
console.log('private_inference → isError:', !!inf.isError, '| len:', t.length, '| tail:', t.slice(-90).replace(/\n/g, ' '))

}

await client.close()
console.log('OK')

12 changes: 6 additions & 6 deletions scripts/capture-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,25 @@ const PROMPT = 'mppRouter fixture capture — verify the attested private lane.'

const enc = await encrypt(PROMPT, BASE)
const encryptedPrompt = packageForTEE(enc)
console.log('→ POST', BASE + '/tee/process')

const res = await fetch(`${BASE}/tee/process`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ encryptedPrompt, model: MODEL }),
})
const body: any = await res.json()
console.log('status:', res.status, '| has encryptionProof:', !!body.encryptionProof, '| teeType:', body.attestation?.teeType)
// FIXME: replace 'any' with a proper type — auto-chore finding


mkdirSync('fixtures', { recursive: true })
// Save the response + the encryptedPrompt (needed to re-verify the ed25519 sig over the ciphertext).
writeFileSync('fixtures/process.json', JSON.stringify({ encryptedPrompt, model: MODEL, response: body }, null, 2))
console.log('saved fixtures/process.json')

console.log('\n── verifyAttestation() against the real receipt ──')


const report = await verifyAttestation({
response: { attestation: body.attestation, encryptionProof: body.encryptionProof },
encryptedPrompt,
model: MODEL,
})
console.log(formatReport(report))

process.exit(report.ok ? 0 : 1)
22 changes: 0 additions & 22 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,29 +64,7 @@ export type InferResult = {
attestation: { prePay: VerifyReport; postPay?: VerifyReport }
}

/** Thrown when the pre-pay attestation gate fails. Zero vouchers are signed. */
export class AttestationError extends Error {
report: VerifyReport
constructor(report: VerifyReport) {
super('mppRouter: attestation gate FAILED — refusing to pay (zero vouchers signed).')
this.name = 'AttestationError'
this.report = report
}
}

/**
* The mppRouter client. Construct once with a payer wallet, then call `infer()`.
*
* @example
* ```ts
* import { MppRouter, detectSensitive } from '@mpprouter/sdk'
*
* const client = new MppRouter({ serverUrl: 'https://mpprouter.onrender.com', account: '0x…' })
* if (detectSensitive(prompt).sensitive) {
* const { answer } = await client.infer(prompt) // verify → encrypt → pay → decrypt
* }
* ```
*/
export class MppRouter {
#serverUrl: string
#accountInput?: Account | `0x${string}`
Expand Down
6 changes: 3 additions & 3 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ async function main() {
const det = detectSensitive(PROMPT)
if (det.sensitive) console.log(`🔒 sensitive payload detected (${det.matches.join(', ')}) → forcing attested private lane`)
else {
console.log('ℹ️ not sensitive → a normal agent would use a public/frontier model (out of mppRouter scope)')

return
}

if (!config.agentPrivateKey) {
console.error('\nAGENT_PRIVATE_KEY not set — fund a Tempo testnet key to run the paid stream (faucet: https://explore.testnet.tempo.xyz).')

process.exit(2)
}

Expand All @@ -43,7 +43,7 @@ async function main() {
onUnit: (n, paid) => process.stdout.write(`\r 💸 [units paid: ${n} | ${paid} pathUSD]`),
})
if (res.attestation.postPay) console.log('\n── post-pay receipt verification ──\n' + formatReport(res.attestation.postPay))
console.log('\n🔓 decrypted answer (plaintext only ever seen by you + the attested enclave):\n' + res.answer)

} catch (e) {
if (e instanceof AttestationError) {
console.error('\n⛔ ' + e.message + '\n' + formatReport(e.report))
Expand Down
78 changes: 1 addition & 77 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,83 +5,7 @@
try {
;(process as any).loadEnvFile?.('.env')
} catch {
/* no .env — use process env / defaults */
}

export const config = {
port: Number(process.env.PORT ?? 8402),
secretKey: process.env.MPP_SECRET_KEY ?? 'dev-insecure-secret-change-me',
recipient: (process.env.TEMPO_RECIPIENT ??
'0xa726a1CD723409074DF9108A2187cfA19899aCF8') as `0x${string}`,

// Pricing (decimal token units; TIP-20 stablecoins use 6 decimals).
pricePerUnit: process.env.PRICE_PER_UNIT ?? '0.0002', // per response-chunk (session/SSE)
// How many SSE chunks the blind relay slices the enclave's single ciphertext
// blob into — each chunk = one MPP voucher tick, so the payer's balance visibly
// ticks per chunk. Multi-unit metering is fixed + verified end-to-end (ADR-0003).
// Default 1 = one charge per inference; set CHUNK_COUNT>1 to meter a response in N ticks.
chunkCount: Number(process.env.CHUNK_COUNT ?? 1),

// Real Phala Intel TDX enclave (the private upstream). When unset → stub mode.
teeEndpoint: (process.env.TEE_ENDPOINT ?? '').replace(/\/$/, ''),

// Optional strict-pin: reject the quote unless mrtd/rtmr equals this value.
// Unset → soft-pin (compare-to-advertised + display only). See DESIGN §1.
expectedMeasurement: process.env.EXPECTED_MEASUREMENT ?? '',

// Optional PAYEE key: when set, the server can cooperatively CLOSE (settle) the
// channel on-chain as the recipient — the settlement tx sender must equal the
// channel payee. Its address MUST equal TEMPO_RECIPIENT. Unset → close stays
// best-effort (deposit reclaims on channel timeout). See ADR-0003.
recipientPrivateKey: (process.env.TEMPO_RECIPIENT_PRIVATE_KEY ?? '') as `0x${string}` | '',

// Client/agent side.
agentPrivateKey: (process.env.AGENT_PRIVATE_KEY ?? '') as `0x${string}` | '',
serverUrl: (process.env.SERVER_URL ?? 'http://localhost:8402').replace(/\/$/, ''),
maxDeposit: process.env.MAX_DEPOSIT ?? '1', // pathUSD headroom cap (human units)

// Default model id the blind relay forwards to the enclave when a client omits one.
upstreamModel: process.env.UPSTREAM_MODEL ?? 'nosana:gpt-oss:20b',
} as const

// Tempo chains — verified from mppx/dist/tempo/internal/defaults.
// Mainnet (Allegro): chain 4217, currency USDC.e
// Testnet (Moderato): chain 42431, currency pathUSD
export const tempoMainnet = {
chainId: 4217,
rpcUrl: 'https://rpc.tempo.xyz',
explorer: 'https://explore.tempo.xyz',
currency: '0x20C000000000000000000000b9537d11c60E8b50' as `0x${string}`, // USDC.e
currencyName: 'USDC',
decimals: 6,
} as const

export const tempoTestnet = {
chainId: 42431,
rpcUrl: 'https://rpc.moderato.tempo.xyz',
explorer: 'https://explore.testnet.tempo.xyz',
currency: '0x20c0000000000000000000000000000000000000' as `0x${string}`, // pathUSD
currencyName: 'pathUSD',
decimals: 6,
} as const

// Active chain — select via NETWORK env ("mainnet" | "testnet"), default testnet.
export const isMainnet = process.env.NETWORK === 'mainnet'
export const tempoChain = isMainnet ? tempoMainnet : tempoTestnet

// Derived: where to fetch the enclave attestation + public key (blind passthrough).
export const teeAttestationUrl = config.teeEndpoint ? `${config.teeEndpoint}/attestation` : ''
export const teePublicKeyUrl = config.teeEndpoint ? `${config.teeEndpoint}/public-key` : ''

/**
* Three-valued privacy mode, resolved at boot by an honest healthcheck of the
* real Phala TDX enclave. The green/private path is reachable ONLY in 'tdx-live'.
* See ADR-0001 + DESIGN §2 (loud, code-enforced fallback).
*
* - 'stub' : no TEE_ENDPOINT configured → intentional offline demo (no TDX).
* - 'down' : TEE_ENDPOINT set but unreachable / not a real INTEL-TDX-PHALA enclave.
* - 'tdx-live' : reachable enclave returning teeType INTEL-TDX-PHALA + a non-null tdxQuote.
*/
export type PrivacyMode = 'tdx-live' | 'stub' | 'down'

export async function resolveMode(timeoutMs = 6000): Promise<PrivacyMode> {
Expand All @@ -92,10 +16,10 @@
const res = await fetch(teeAttestationUrl, { signal: ctrl.signal })
clearTimeout(t)
if (!res.ok) return 'down'
const att: any = await res.json()
// FIXME: replace 'any' with a proper type — auto-chore finding
const live = att?.teeType === 'INTEL-TDX-PHALA' && att?.tdxQuote != null
return live ? 'tdx-live' : 'down'
} catch {
return 'down'
}
}
39 changes: 1 addition & 38 deletions src/detectSensitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,7 @@ const PATTERNS: { label: string; re: RegExp }[] = [
// Valid BIP-39 mnemonic word counts (entropy 128..256 bits → 12/15/18/21/24 words).
const MNEMONIC_LENGTHS = new Set([12, 15, 18, 21, 24])

/**
* Seed-phrase / mnemonic shape — a deliberately fail-SAFE heuristic, no 2048-word list.
*
* A BIP-39 mnemonic is a run of EXACTLY 12/15/18/21/24 words, each 3–8 lowercase letters,
* separated only by whitespace. We scan for such a run ANYWHERE in the text — including a
* phrase pasted inline in a sentence — because for a privacy gate, MISSING a seed phrase
* (leaking it to a public model) is far worse than over-routing. Capital letters and
* punctuation break a run (a sentence's "." / "The" won't match [a-z]{3,8}), so real prose
* rarely trips it. Honest caveat: this is a SHAPE check, so a contrived all-lowercase,
* punctuation-free run of exactly mnemonic length CAN over-fire (→ the paid private lane);
* that's the acceptable, safe direction. Eliminating it entirely would need the wordlist.
*/

function looksLikeSeedPhrase(text: string): boolean {
const runs = text.match(/\b[a-z]{3,8}(?:\s+[a-z]{3,8})*\b/g) ?? []
return runs.some((run) => MNEMONIC_LENGTHS.has(run.split(/\s+/).length))
Expand All @@ -58,33 +47,7 @@ function luhnValid(digits: string): boolean {
return sum % 10 === 0
}

/** Credit-card shape: a 13–19 digit run (optionally space/hyphen separated) that passes Luhn. */
function looksLikeCreditCard(text: string): boolean {
for (const m of text.matchAll(/\b(?:\d[ -]?){12,18}\d\b/g)) {
const digits = m[0].replace(/[ -]/g, '')
if (digits.length >= 13 && digits.length <= 19 && luhnValid(digits)) return true
}
return false
}

// Shannon entropy — catches high-entropy secrets that don't match a known shape.
function entropy(s: string): number {
const freq: Record<string, number> = {}
for (const c of s) freq[c] = (freq[c] ?? 0) + 1
return -Object.values(freq).reduce((h, n) => {
const p = n / s.length
return h + p * Math.log2(p)
}, 0)
}

function hasHighEntropyToken(text: string): boolean {
for (const tok of text.split(/\s+/)) {
if (tok.length >= 24 && /[A-Za-z]/.test(tok) && /[0-9]/.test(tok) && entropy(tok) > 3.6) return true
}
return false
}

/** Returns whether the prompt contains secrets/PII that must use the private lane. */
export function detectSensitive(prompt: string): Detection {
const matches: string[] = []
for (const { label, re } of PATTERNS) if (re.test(prompt)) matches.push(label)
Expand Down
4 changes: 2 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,11 @@
}
yield proofFrame // receipt metadata — not a billable unit
})
} catch (e: any) {
// FIXME: replace 'any' with a proper type — auto-chore finding
log.error('stream.failed', { error: String(e?.message ?? e) })
return c.json({ error: 'stream_failed', detail: String(e?.message ?? e) }, 500)
}
})

Check failure on line 210 in src/server.ts

View workflow job for this annotation

GitHub Actions / build-test

'catch' or 'finally' expected.

// ── MPP service discovery (mpp.dev/services + MPPScan) ───────────────────────
app.get('/openapi.json', (c) => {
Expand Down Expand Up @@ -258,7 +258,7 @@
app.get('/.well-known/skill.md', (c) => c.redirect('/SKILL.md'))

// ── Agent skill entrypoint (installable: `npx skills add Router-Labs/mppRouter`) ──
const serveSkill = (c: any) => {
// FIXME: replace 'any' with a proper type — auto-chore finding
try {
return c.text(readFileSync(new URL('../skills/mpprouter/SKILL.md', import.meta.url), 'utf8'), 200, {
'content-type': 'text/markdown; charset=utf-8',
Expand All @@ -266,7 +266,7 @@
} catch {
return c.text('# mppRouter\nPayable, E2E-encrypted LLM inference on MPP. See /llms.txt + /openapi.json.\n')
}
}

Check failure on line 269 in src/server.ts

View workflow job for this annotation

GitHub Actions / build-test

Declaration or statement expected.
// Canonical path + the common variants a human or agent might try.
for (const p of ['/SKILL.md', '/skill.md', '/skill', '/skills.md', '/skills']) app.get(p, serveSkill)

Expand Down
17 changes: 2 additions & 15 deletions src/upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,13 @@ import { config, teeAttestationUrl, teePublicKeyUrl } from './config.js'

export type TeeProcessResult = {
encryptedResponse: string
attestation: any
encryptionProof: any
// FIXME: replace 'any' with a proper type — auto-chore finding
// FIXME: replace 'any' with a proper type — auto-chore finding
}

const STUB_ATTESTATION = { teeType: 'STUB-NO-TDX', tdxQuote: null }

/** POST ciphertext to the enclave; returns the re-encrypted blob + per-request proof. */
export async function teeProcess(encryptedPrompt: string, model: string): Promise<TeeProcessResult> {
if (!config.teeEndpoint) throw new Error('TEE_ENDPOINT not configured (stub mode cannot run real inference)')
const res = await fetch(`${config.teeEndpoint}/process`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ encryptedPrompt, model }),
})
if (!res.ok) throw new Error(`TEE /process ${res.status}: ${(await res.text()).slice(0, 200)}`)
const b: any = await res.json()
return { encryptedResponse: b.encryptedResponse, attestation: b.attestation, encryptionProof: b.encryptionProof }
}

/** GET the enclave attestation (blind passthrough). Returns STUB-NO-TDX when no TEE. */
export async function fetchAttestation(): Promise<any> {
if (!teeAttestationUrl) return STUB_ATTESTATION
try {
Expand Down
Loading