Skip to content
Merged
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
78 changes: 73 additions & 5 deletions src/mcp-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync, renameSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
Expand Down Expand Up @@ -101,6 +102,62 @@ export function configuredAgentSessions(config) {
return [...sessions];
}

// Gate daemons (startConfirmationsServer / iak-mcp-daemon) enforce their
// auth_token on EVERY endpoint, /wake included — a caller without the bearer
// header gets 401 {"ok":false,"error":"unauthorized"} even for a plain nudge.
// Resolve this machine's copy of the fleet token: the token file first
// (~/.config/iak-gate.token, overridable via IAK_GATE_TOKEN_FILE), then the
// IAK_GATE_TOKEN env var. Returns '' when neither exists so open daemons keep
// working unauthenticated. Callers re-resolve per request rather than caching
// at boot — a token rotation must not leave a long-lived MCP session sending
// a stale credential (the poller stale-key incident shape).
export function resolveGateToken({ env = process.env } = {}) {
const tokenFile = env.IAK_GATE_TOKEN_FILE || join(homedir(), '.config', 'iak-gate.token');
try {
const fromFile = readFileSync(tokenFile, 'utf8').trim();
if (fromFile) return fromFile;
} catch { /* no token file */ }
return typeof env.IAK_GATE_TOKEN === 'string' ? env.IAK_GATE_TOKEN.trim() : '';
}

export function gateAuthHeaders(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}

// The fleet gate token must never travel to a caller-influenced destination:
// tool args are reachable from untrusted input (room messages, web pages, PR
// text can steer a wake_remote call), so an arbitrary gateUrl + bearer header
// is a token-exfiltration path (claudemm, PR #52 review). Trusted hosts are
// where fleet daemons can actually live: loopback, RFC1918 LAN ranges, the
// tailnet CGNAT range, and .local mDNS names. Anything else still gets the
// wake — just unauthenticated, the same graceful degradation open daemons
// already rely on.
export function isTrustedGateHost(hostname) {
const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
if (h === 'localhost' || h === '::1') return true;
if (h.endsWith('.local')) return true;
const m = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (!m) return false;
const [a, b] = [Number(m[1]), Number(m[2])];
if (a === 127) return true; // loopback
if (a === 10) return true; // RFC1918
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
if (a === 192 && b === 168) return true; // RFC1918
if (a === 100 && b >= 64 && b <= 127) return true; // tailnet CGNAT
return false;
}

// Auth headers for a request to `url`: bearer only when the destination host
// is trusted, {} otherwise (and {} on any unparseable URL).
export function gateAuthHeadersFor(url, token = resolveGateToken()) {
try {
if (!isTrustedGateHost(new URL(url).hostname)) return {};
} catch {
return {};
}
return gateAuthHeaders(token);
}

export function confirmationFromHandle(args = {}, config = {}) {
const explicit = args.fromHandle || args.from_handle;
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
Expand Down Expand Up @@ -364,9 +421,17 @@ export async function runMcpServer({ configPath } = {}) {
const daemonHost = confirmCfg.host || '127.0.0.1';
const daemonPort = confirmCfg.port || 8788;
const daemonBase = `http://${daemonHost === '0.0.0.0' ? '127.0.0.1' : daemonHost}:${daemonPort}`;
// The local daemon enforces the same bearer gate when its auth_token is
// set; our own config value is authoritative for it, with the fleet token
// as fallback.
const daemonAuthHeaders = () => gateAuthHeaders(confirmCfg.auth_token || resolveGateToken());
let daemonAvailable = false;
try {
const probe = await fetch(`${daemonBase}/intents`, { method: 'GET', signal: AbortSignal.timeout(500) });
const probe = await fetch(`${daemonBase}/intents`, {
method: 'GET',
headers: daemonAuthHeaders(),
signal: AbortSignal.timeout(500),
});
daemonAvailable = probe.ok;
} catch { /* not running */ }

Expand Down Expand Up @@ -473,7 +538,10 @@ export async function runMcpServer({ configPath } = {}) {
'runs its configured wake script (typically scripts/claudemb-wake.sh, an osascript ' +
'injector for the Claude desktop app) so the remote agent gets a "check rooms" prompt ' +
'within ~500ms regardless of room-poll cadence. Use this for direct cross-machine ' +
'agent-to-agent coordination (e.g. claudemm has a question that needs claudemb).',
'agent-to-agent coordination (e.g. claudemm has a question that needs claudemb). ' +
'Token-protected daemons are handled automatically: the request carries ' +
'Authorization: Bearer <token> from ~/.config/iak-gate.token (or IAK_GATE_TOKEN_FILE / ' +
'IAK_GATE_TOKEN env) when one exists; without a token the request stays unauthenticated.',
inputSchema: {
type: 'object',
properties: {
Expand Down Expand Up @@ -688,7 +756,7 @@ export async function runMcpServer({ configPath } = {}) {
try {
const res = await fetch(`${args.gateUrl}/wake`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...gateAuthHeadersFor(args.gateUrl) },
body: JSON.stringify({ text }),
signal: AbortSignal.timeout(5000),
});
Expand Down Expand Up @@ -756,7 +824,7 @@ export async function runMcpServer({ configPath } = {}) {
if (daemonAvailable) {
const createRes = await fetch(`${daemonBase}/intent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...daemonAuthHeaders() },
body: JSON.stringify({
prompt: args.prompt,
session: args.session,
Expand All @@ -772,7 +840,7 @@ export async function runMcpServer({ configPath } = {}) {
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 1000));
try {
const list = await (await fetch(`${daemonBase}/intents`)).json();
const list = await (await fetch(`${daemonBase}/intents`, { headers: daemonAuthHeaders() })).json();
const found = list.find((i) => i.id === id);
if (found && found.status === 'decided') {
return ok(JSON.stringify({ id, decision: found.decision }, null, 2));
Expand Down
168 changes: 166 additions & 2 deletions test/mcp-server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import {
roomApiConfigured,
removeConsumedNotifications,
ackNotificationFile,
resolveGateToken,
gateAuthHeaders,
gateAuthHeadersFor,
isTrustedGateHost,
} from '../src/mcp-server.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -152,6 +156,7 @@ test('roomApiConfigured: requires both API key and room', () => {

import { writeFileSync, appendFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { createServer } from 'node:http';

function rpc(line) { return JSON.stringify(line) + '\n'; }

Expand Down Expand Up @@ -229,6 +234,82 @@ test('iak-mcp.mjs with room API config exposes low-latency room tools', async ()
}
});

// --- resolveGateToken --------------------------------------------------------

test('resolveGateToken: reads the token file named by IAK_GATE_TOKEN_FILE', () => {
const dir = mkdtempSync(join(tmpdir(), 'iak-gate-token-'));
const tokenFile = join(dir, 'gate.token');
try {
writeFileSync(tokenFile, 'file-secret-1\n');
assert.equal(resolveGateToken({ env: { IAK_GATE_TOKEN_FILE: tokenFile } }), 'file-secret-1');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test('resolveGateToken: token file wins over IAK_GATE_TOKEN env', () => {
const dir = mkdtempSync(join(tmpdir(), 'iak-gate-token-'));
const tokenFile = join(dir, 'gate.token');
try {
writeFileSync(tokenFile, 'file-secret-2\n');
assert.equal(
resolveGateToken({ env: { IAK_GATE_TOKEN_FILE: tokenFile, IAK_GATE_TOKEN: 'env-secret' } }),
'file-secret-2'
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test('resolveGateToken: missing file falls back to IAK_GATE_TOKEN env', () => {
const dir = mkdtempSync(join(tmpdir(), 'iak-gate-token-'));
try {
const env = { IAK_GATE_TOKEN_FILE: join(dir, 'does-not-exist'), IAK_GATE_TOKEN: 'env-secret' };
assert.equal(resolveGateToken({ env }), 'env-secret');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test('resolveGateToken: no file and no env resolves to empty (open daemons keep working)', () => {
const dir = mkdtempSync(join(tmpdir(), 'iak-gate-token-'));
try {
assert.equal(resolveGateToken({ env: { IAK_GATE_TOKEN_FILE: join(dir, 'does-not-exist') } }), '');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test('gateAuthHeaders: bearer header only when a token exists', () => {
assert.deepEqual(gateAuthHeaders('tok'), { Authorization: 'Bearer tok' });
assert.deepEqual(gateAuthHeaders(''), {});
});

// The exfiltration guard (claudemm, PR #52 review): the fleet token must only
// ever accompany requests to hosts a fleet daemon can live on. A caller-
// influenced gateUrl outside those ranges gets NO Authorization header.
test('isTrustedGateHost: fleet-plausible hosts only', () => {
for (const h of ['localhost', '127.0.0.1', '::1', '10.0.0.5', '172.16.0.2',
'172.31.255.1', '192.168.50.240', '100.97.140.13', '100.64.0.1', 'mini.local']) {
assert.equal(isTrustedGateHost(h), true, h);
}
for (const h of ['evil.example.com', '8.8.8.8', '172.15.0.1', '172.32.0.1',
'100.63.0.1', '100.128.0.1', '193.168.1.1', 'local.evil.com', '', undefined]) {
assert.equal(isTrustedGateHost(h), false, String(h));
}
});

test('gateAuthHeadersFor: NO bearer to off-allowlist or unparseable destinations', () => {
assert.deepEqual(gateAuthHeadersFor('https://evil.example.com/wake', 'tok'), {});
assert.deepEqual(gateAuthHeadersFor('http://8.8.8.8:8788', 'tok'), {});
assert.deepEqual(gateAuthHeadersFor('not a url', 'tok'), {});
assert.deepEqual(gateAuthHeadersFor('http://100.97.140.13:8788', 'tok'),
{ Authorization: 'Bearer tok' });
assert.deepEqual(gateAuthHeadersFor('http://127.0.0.1:8788', 'tok'),
{ Authorization: 'Bearer tok' });
assert.deepEqual(gateAuthHeadersFor('http://192.168.50.240:8788', ''), {});
});

// --- removeConsumedNotifications (pure) --------------------------------------

test('removeConsumedNotifications: consumed prefix is dropped, later append survives', () => {
Expand Down Expand Up @@ -324,8 +405,11 @@ test('ackNotificationFile: missing notification file is a no-op ack', () => {

// --- end-to-end stdio regression: room_list_new → append → room_ack ---------

function bootMcp(configPath) {
const child = spawn('node', [BIN, '--config', configPath], { stdio: ['pipe', 'pipe', 'pipe'] });
function bootMcp(configPath, { env } = {}) {
const child = spawn('node', [BIN, '--config', configPath], {
stdio: ['pipe', 'pipe', 'pipe'],
env: env ? { ...process.env, ...env } : process.env,
});
let buf = '';
const waiters = new Map();
child.stdout.on('data', (b) => {
Expand Down Expand Up @@ -389,3 +473,83 @@ test('iak-mcp.mjs REGRESSION: room_ack clears only what room_list_new returned',
rmSync(dir, { recursive: true, force: true });
}
});

// --- end-to-end stdio regression: wake_remote gate auth ----------------------

// A tiny stand-in for a remote IAK daemon whose auth_token is set: every
// request must carry the exact bearer or it 401s, mirroring
// startConfirmationsServer's gate.
function bootFakeGate(requiredToken) {
const seen = [];
const server = createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
seen.push({ path: req.url, auth: req.headers.authorization || null, body });
const authed = !requiredToken || req.headers.authorization === `Bearer ${requiredToken}`;
res.writeHead(authed ? 202 : 401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(authed ? { ok: true } : { ok: false, error: 'unauthorized' }));
});
});
return new Promise((resolvePromise) => {
server.listen(0, '127.0.0.1', () => {
resolvePromise({
url: `http://127.0.0.1:${server.address().port}`,
seen,
close: () => new Promise((r) => server.close(r)),
});
});
});
}

test('iak-mcp.mjs REGRESSION: wake_remote sends Authorization: Bearer when a gate token file exists', async () => {
const dir = mkdtempSync(join(tmpdir(), 'iak-mcp-test-'));
const cfgPath = join(dir, 'config.json');
const tokenFile = join(dir, 'gate.token');
writeFileSync(cfgPath, JSON.stringify({ tmux: { allow: [], default_session: 't' } }));
writeFileSync(tokenFile, 'gate-secret-1\n');
const gate = await bootFakeGate('gate-secret-1');
const { request, close } = bootMcp(cfgPath, { env: { IAK_GATE_TOKEN_FILE: tokenFile, IAK_GATE_TOKEN: '' } });
try {
const res = await request('tools/call', {
name: 'wake_remote',
arguments: { gateUrl: gate.url, text: 'check rooms' },
});
const text = res.result.content[0].text;
assert.match(text, /202/, `expected a 202 wake, got: ${text}`);
const wake = gate.seen.find((r) => r.path === '/wake');
assert.ok(wake, 'expected the gate to receive POST /wake');
assert.equal(wake.auth, 'Bearer gate-secret-1');
assert.equal(JSON.parse(wake.body).text, 'check rooms');
} finally {
await close();
await gate.close();
rmSync(dir, { recursive: true, force: true });
}
});

test('iak-mcp.mjs: wake_remote stays unauthenticated when no gate token exists', async () => {
const dir = mkdtempSync(join(tmpdir(), 'iak-mcp-test-'));
const cfgPath = join(dir, 'config.json');
writeFileSync(cfgPath, JSON.stringify({ tmux: { allow: [], default_session: 't' } }));
const gate = await bootFakeGate(''); // open daemon — no token required
const { request, close } = bootMcp(cfgPath, {
// Point the file lookup at a path that cannot exist and clear the env
// fallback so the test is independent of the host's real fleet token.
env: { IAK_GATE_TOKEN_FILE: join(dir, 'no-such-token'), IAK_GATE_TOKEN: '' },
});
try {
const res = await request('tools/call', {
name: 'wake_remote',
arguments: { gateUrl: gate.url },
});
assert.match(res.result.content[0].text, /202/);
const wake = gate.seen.find((r) => r.path === '/wake');
assert.ok(wake, 'expected the gate to receive POST /wake');
assert.equal(wake.auth, null, 'open daemons must not receive a bearer header');
} finally {
await close();
await gate.close();
rmSync(dir, { recursive: true, force: true });
}
});
Loading