diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 6ad8d0d0..9c486ca1 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -3992,3 +3992,5 @@ Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork s UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority. T-361 (done, 2026-06-12) added the session-level building blocks this ticket can reuse: StreamJsonSession now watches the process exit code (SessionEnd with stderr tail, replay-latest via session.end) and the pane surfaces ''claude exited (code N) — /clear to restart''. A resume that dies at spawn now surfaces instead of hanging silently; what remains here is the timeout/fallback for a resume that starts but never produces the init event, and resume-decided-by-content.', NULL, '2026-06-11 22:53:52', '2026-06-11 22:53:52', '2026-06-11 22:53:52', NULL, '5a99913696126412ccc7b18f75d3ec15', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:54:18', '2026-06-11 22:54:18', '2026-06-11 22:54:18', NULL, '2cadeabff0a0a89bbcc06db35dfa2f15', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:57:42', '2026-06-11 22:57:42', '2026-06-11 22:57:42', NULL, 'cb060646e7e967d607dc013a75ec0a28', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index ce923f73..6ff02135 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4060,3 +4060,9 @@ Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork s UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority. T-361 (done, 2026-06-12) added the session-level building blocks this ticket can reuse: StreamJsonSession now watches the process exit code (SessionEnd with stderr tail, replay-latest via session.end) and the pane surfaces ''claude exited (code N) — /clear to restart''. A resume that dies at spawn now surfaces instead of hanging silently; what remains here is the timeout/fallback for a resume that starts but never produces the init event, and resume-decided-by-content.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-06-08 13:39:51', '2026-06-11 22:53:52', NULL, '06cc0a2e629ee7a192d24286f58af27f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool. + +Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', 'in_progress', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 22:54:18', NULL, '320e3e3eeb46d8a70e5ecfd5c903563d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool. + +Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', 'done', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 22:57:42', NULL, '14de71ed19e921a370001097887a7e6f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index a26c8c23..2be93a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Security +- **The MCP HTTP server now requires a per-start auth token.** The localhost + SSE port served the entire clide command surface unauthenticated, + bypassing the unix socket's 0600 gate; requests must now present the + token published in the 0600 `/ide` lock file. (T-362) + - **`editor.open` / `editor.save` are now workspace-confined.** Both verbs accepted absolute paths and `..` traversal verbatim — an unconfined read and write primitive over IPC. They now pass the same path-safety guard as diff --git a/lib/src/ipc/mcp_server.dart b/lib/src/ipc/mcp_server.dart index 774e3900..2ddb17aa 100644 --- a/lib/src/ipc/mcp_server.dart +++ b/lib/src/ipc/mcp_server.dart @@ -23,6 +23,7 @@ library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:clide/kernel/src/log.dart'; import 'package:clide/src/daemon/dispatcher.dart'; @@ -33,6 +34,12 @@ import 'package:clide/src/ipc/envelope.dart'; /// separate `/ide` minimum (D-68). const String _clideToolPrefix = 'mcp__clide__'; +/// Auth header Claude Code's `/ide` client sends, populated from the lock +/// file's `authToken`. Every request must carry it (T-362): the unix socket +/// is gated by 0600 per D-71, and an unauthenticated localhost HTTP port +/// would bypass that gate wholesale. +const String kMcpAuthHeader = 'x-claude-code-ide-authorization'; + /// One connected SSE client. Each session has its own response /// stream; POST /messages routes back to the right one via the /// `sessionId` query param. @@ -89,6 +96,7 @@ class McpServer { HttpServer? _http; String? _lockFile; int? _port; + String? _authToken; final Map _sessions = {}; int _sessionCounter = 0; @@ -96,11 +104,16 @@ class McpServer { int? get port => _port; String? get lockFilePath => _lockFile; + /// The per-start bearer token clients must present in [kMcpAuthHeader]. + /// Published to legitimate clients via the 0600 lock file only. + String? get authToken => _authToken; + Future start() async { if (isRunning) return; final server = await HttpServer.bind(bindHost, bindPort); _http = server; _port = server.port; + _authToken = _generateToken(); _lockFile = await _writeDiscoveryFile(); server.listen( _route, @@ -136,6 +149,13 @@ class McpServer { // -- routing -------------------------------------------------------------- Future _route(HttpRequest req) async { + // Token gate first, on every path (T-362). Without it, any local + // process could drive the entire dispatcher D-71's 0600 socket guards. + if (req.headers.value(kMcpAuthHeader) != _authToken) { + req.response.statusCode = HttpStatus.unauthorized; + await req.response.close(); + return; + } final path = req.uri.path; if (path == '/sse' && req.method == 'GET') { await _openSseStream(req); @@ -327,8 +347,39 @@ class McpServer { dirHandle.createSync(recursive: true); } final path = '$dir/$pid.lock'; - final body = jsonEncode({'pid': pid, 'workspace': workspaceRoot, 'transport': 'sse', 'url': 'http://$bindHost:$_port/sse'}); + final body = jsonEncode({ + 'pid': pid, + 'workspace': workspaceRoot, + 'transport': 'sse', + 'url': 'http://$bindHost:$_port/sse', + // Claude Code's /ide lock format carries the bearer token here; the + // 0600 below is what scopes it to this user (T-362). + 'authToken': _authToken, + }); File(path).writeAsStringSync(body); + try { + await _chmod(path, '600'); + } catch (e) { + // Not fatal like the socket's chmod (D-71): the lock lives under + // ~/.claude which the home-dir perms usually already protect. But say so. + log.warn('mcp', 'chmod 600 on $path failed: $e — the auth token may be readable by other local users'); + } return path; } + + /// 32 bytes of CSPRNG entropy, base64url — the per-start bearer token. + static String _generateToken() { + final rng = Random.secure(); + final bytes = List.generate(32, (_) => rng.nextInt(256)); + return base64UrlEncode(bytes).replaceAll('=', ''); + } + + /// `chmod` via `chmod(1)` — dart:io doesn't expose mode bits (same + /// approach as the unix-socket server, D-71). + static Future _chmod(String path, String octal) async { + final r = await Process.run('chmod', [octal, path]); + if (r.exitCode != 0) { + throw ProcessException('chmod', [octal, path], r.stderr.toString(), r.exitCode); + } + } } diff --git a/test/ipc/mcp_server_test.dart b/test/ipc/mcp_server_test.dart index 823df367..542a3dbe 100644 --- a/test/ipc/mcp_server_test.dart +++ b/test/ipc/mcp_server_test.dart @@ -31,10 +31,12 @@ void main() { if (discoveryDir.existsSync()) discoveryDir.deleteSync(recursive: true); }); - Future openSse() async { + Future openSse({String? token}) async { final client = HttpClient(); addTearDown(client.close); final req = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/sse')); + final t = token ?? server.authToken; + if (t != null) req.headers.set(kMcpAuthHeader, t); return req.close(); } @@ -64,6 +66,7 @@ void main() { addTearDown(client.close); final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=$sessionId')); req.headers.contentType = ContentType.json; + req.headers.set(kMcpAuthHeader, server.authToken!); req.write(jsonEncode(body)); final resp = await req.close(); expect(resp.statusCode, HttpStatus.accepted); @@ -93,11 +96,49 @@ void main() { final client = HttpClient(); addTearDown(client.close); final req = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/no-such-thing')); + req.headers.set(kMcpAuthHeader, server.authToken!); final resp = await req.close(); expect(resp.statusCode, HttpStatus.notFound); }); }); + // T-362: D-71's "another user on this host must not drive my IDE" is + // enforced with 0600 on the unix socket — the HTTP port must not bypass it. + group('McpServer (T-362) auth token', () { + test('the lock file publishes the auth token, mode 600', () async { + final lock = File(server.lockFilePath!); + final payload = jsonDecode(lock.readAsStringSync()) as Map; + expect(payload['authToken'], server.authToken); + expect((server.authToken ?? '').length, greaterThanOrEqualTo(32)); + final mode = lock.statSync().mode & 0xFFF; + expect(mode, 0x180, reason: 'lock file must be 0600 — it carries the token'); + }); + + test('a request without the token is rejected with 401', () async { + final client = HttpClient(); + addTearDown(client.close); + final sse = await (await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/sse'))).close(); + expect(sse.statusCode, HttpStatus.unauthorized); + + final post = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=s0')); + post.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}'); + final resp = await post.close(); + expect(resp.statusCode, HttpStatus.unauthorized); + }); + + test('a request with a wrong token is rejected with 401', () async { + final resp = await openSse(token: 'not-the-token'); + expect(resp.statusCode, HttpStatus.unauthorized); + }); + + test('the token rotates per start', () async { + final first = server.authToken; + await server.stop(); + await server.start(); + expect(server.authToken, isNot(first)); + }); + }); + group('McpServer (T-130) JSON-RPC', () { test('SSE opens with an endpoint event carrying the session id', () async { final (sessionId, events) = await connectAndCaptureEndpoint(); @@ -172,6 +213,7 @@ void main() { addTearDown(client.close); final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=ghost')); req.headers.contentType = ContentType.json; + req.headers.set(kMcpAuthHeader, server.authToken!); req.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}'); final resp = await req.close(); expect(resp.statusCode, HttpStatus.notFound); @@ -183,6 +225,7 @@ void main() { addTearDown(client.close); final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=$sessionId')); req.headers.contentType = ContentType.json; + req.headers.set(kMcpAuthHeader, server.authToken!); req.write('{not json'); final resp = await req.close(); expect(resp.statusCode, HttpStatus.badRequest); @@ -224,7 +267,9 @@ void main() { Future<(String, Stream)> connect() async { final client = HttpClient(); addTearDown(client.close); - final resp = await (await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'))).close(); + final sseReq = await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse')); + sseReq.headers.set(kMcpAuthHeader, srv.authToken!); + final resp = await sseReq.close(); final dataLines = resp .transform(utf8.decoder) .transform(const LineSplitter()) @@ -246,6 +291,7 @@ void main() { addTearDown(client.close); final req = await client.postUrl(Uri.parse('http://127.0.0.1:${srv.port}/messages?sessionId=$sid')); req.headers.contentType = ContentType.json; + req.headers.set(kMcpAuthHeader, srv.authToken!); req.write(jsonEncode(body)); final resp = await req.close(); expect(resp.statusCode, HttpStatus.accepted); @@ -306,7 +352,9 @@ void main() { }); final client = HttpClient(); addTearDown(client.close); - final resp = await (await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'))).close(); + final sseReq = await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse')); + sseReq.headers.set(kMcpAuthHeader, srv.authToken!); + final resp = await sseReq.close(); final data = resp .transform(utf8.decoder) .transform(const LineSplitter()) @@ -323,6 +371,7 @@ void main() { final replyFuture = data.firstWhere((s) => s.contains('"id":13')); final post = await client.postUrl(Uri.parse('http://127.0.0.1:${srv.port}/messages?sessionId=$sid')); post.headers.contentType = ContentType.json; + post.headers.set(kMcpAuthHeader, srv.authToken!); post.write( jsonEncode({ 'jsonrpc': '2.0',