فهرست منبع

feat(ui): serve the viewer from `codegraph ui`, loopback-only and read-only (CG-41)

Adds the `codegraph ui [path]` command (alias `web`) and `src/ui-server/`, a
`node:http` server with no framework and no new dependency.

The command reads an index that already exists — it never creates one, so a
missing index prints the same friendly guidance the MCP tools give instead of
a stack trace, and a sensitive system directory is refused up front.

Security is the substance here, not the routing. The server binds 127.0.0.1
only, answers GET and HEAD only, and sends no CORS headers ever. The realistic
attack on a process that serves your source code from a local port is DNS
rebinding, so every request must carry a loopback `Host` (on our port) and, if
it carries an `Origin` at all, a loopback one — anything else is 403 before
the filesystem is touched. Every path resolves through the engine's existing
`validatePathWithinRoot` chokepoint, which already handles `../` traversal and
in-tree symlinks pointing out of the root (#527); `..` segments are refused
outright so a traversal attempt gets a 404 rather than the SPA shell.

`PathRefusalError` moves from `mcp/tools.ts` into the dependency-free
`errors.ts` (re-exported from its old home, so class identity and every
`instanceof` check are unchanged) — that is what lets a non-MCP read sink
enforce the same refusal without importing the MCP tool graph.

Assets come from `dist/viewer/` resolved relative to `__dirname`, the way
`db/index.ts` finds `schema.sql`. Hashed assets are cached immutably,
`index.html` never. Port 4747, or the next free one — an explicit `--port`
stays explicit rather than silently moving. `--no-open` skips the browser, and
`CODEGRAPH_BROWSER` picks one (or `none` to suppress it), which is also what
makes "did it open a browser" testable end to end.

`resolveProjectFile` and the `/api/` handler seam are the boundary CG-42's
JSON API plugs into; `/api/*` 404s as JSON so a typo'd endpoint never returns
the app shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry 1 هفته پیش
والد
کامیت
0196c2e53a

+ 358 - 0
__tests__/cli-ui-command.test.ts

@@ -0,0 +1,358 @@
+/**
+ * `codegraph ui` — the CLI face of the viewer server (CG-41).
+ *
+ * Exercised end-to-end against the built binary, because the things worth
+ * pinning here are the ones that only exist once commander, the project
+ * resolver and the server are wired together: the help text, the friendly
+ * "not indexed" guidance, the sensitive-directory refusal, and whether
+ * `--no-open` actually stops a browser from being launched.
+ *
+ * The browser check works by pointing `CODEGRAPH_BROWSER` at a script that
+ * touches a marker file — so "did it try to open a browser" becomes an
+ * observable fact rather than a promise.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { execFileSync, spawn, type ChildProcess } from 'child_process';
+import * as fs from 'fs';
+import * as http from 'http';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { DEFAULT_UI_PORT as DEFAULT_PORT } from '../src/ui-server/constants';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+const BASE_ENV = {
+  ...process.env,
+  CODEGRAPH_NO_DAEMON: '1',
+  CODEGRAPH_WASM_RELAUNCHED: '1',
+  NO_COLOR: '1',
+};
+
+/** Run the CLI to completion, capturing stdout+stderr and the exit code. */
+function runCli(args: string[], env: Record<string, string> = {}): { code: number; output: string } {
+  try {
+    const output = execFileSync(process.execPath, [BIN, ...args], {
+      encoding: 'utf-8',
+      env: { ...BASE_ENV, ...env },
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    return { code: 0, output };
+  } catch (err) {
+    const e = err as { status?: number; stdout?: string; stderr?: string };
+    return { code: e.status ?? 1, output: `${e.stdout ?? ''}${e.stderr ?? ''}` };
+  }
+}
+
+/** GET a path from a running viewer, with a valid loopback Host. */
+function get(port: number, requestPath: string): Promise<{ status: number; body: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      { host: '127.0.0.1', port, path: requestPath, method: 'GET' },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+/**
+ * Start `codegraph ui` and wait for the URL it prints.
+ *
+ * The banner IS the readiness signal: the server is bound before the URL is
+ * printed, so anything the test does after this line is talking to a live
+ * socket.
+ */
+function startViewer(
+  args: string[],
+  env: Record<string, string>
+): Promise<{ child: ChildProcess; port: number; output: () => string }> {
+  return new Promise((resolve, reject) => {
+    const child = spawn(process.execPath, [BIN, 'ui', ...args], {
+      env: { ...BASE_ENV, ...env },
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    let output = '';
+    const timer = setTimeout(() => {
+      child.kill('SIGKILL');
+      reject(new Error(`codegraph ui never printed a URL. Output:\n${output}`));
+    }, 30_000);
+
+    const onChunk = (chunk: Buffer): void => {
+      output += chunk.toString('utf-8');
+      const match = output.match(/http:\/\/127\.0\.0\.1:(\d+)/);
+      if (match?.[1]) {
+        clearTimeout(timer);
+        resolve({ child, port: Number(match[1]), output: () => output });
+      }
+    };
+    child.stdout?.on('data', onChunk);
+    child.stderr?.on('data', onChunk);
+    child.on('error', (err) => {
+      clearTimeout(timer);
+      reject(err);
+    });
+    child.on('exit', (code) => {
+      clearTimeout(timer);
+      reject(new Error(`codegraph ui exited with ${code} before serving. Output:\n${output}`));
+    });
+  });
+}
+
+async function stopViewer(child: ChildProcess): Promise<void> {
+  if (child.exitCode !== null) return;
+  await new Promise<void>((resolve) => {
+    child.once('exit', () => resolve());
+    child.kill('SIGTERM');
+    // A viewer that ignores SIGTERM must not hang the suite.
+    setTimeout(() => {
+      child.kill('SIGKILL');
+      resolve();
+    }, 5_000).unref();
+  });
+}
+
+describe('codegraph ui — help', () => {
+  it('reads well and documents the flags', () => {
+    const { code, output } = runCli(['ui', '--help']);
+    expect(code).toBe(0);
+    expect(output).toContain('--port');
+    expect(output).toContain('--no-open');
+    expect(output).toContain('4747');
+    expect(output).toContain('127.0.0.1');
+    expect(output).toContain('read-only');
+    expect(output).toContain('Examples:');
+    expect(output).toContain('CODEGRAPH_BROWSER');
+  });
+
+  it('works through `codegraph help ui`', () => {
+    const viaHelpCommand = runCli(['help', 'ui']);
+    const viaFlag = runCli(['ui', '--help']);
+    expect(viaHelpCommand.code).toBe(0);
+    expect(viaHelpCommand.output).toBe(viaFlag.output);
+  });
+
+  it('is listed in the top-level help, and `web` is an alias', () => {
+    const top = runCli(['--help']);
+    expect(top.output).toContain('ui|web [options] [path]');
+    const viaAlias = runCli(['help', 'web']);
+    expect(viaAlias.code).toBe(0);
+    expect(viaAlias.output).toContain('--no-open');
+  });
+
+  it('rejects a nonsense --port with a plain message, not a stack trace', () => {
+    const { code, output } = runCli(['ui', '--port', 'banana']);
+    expect(code).toBe(1);
+    expect(output).toContain('--port must be a whole number');
+    expect(output).not.toContain('at Object.');
+  });
+});
+
+describe('codegraph ui — refusals', () => {
+  let unindexed: string;
+
+  beforeAll(() => {
+    unindexed = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-unindexed-'));
+    fs.writeFileSync(path.join(unindexed, 'a.ts'), 'export const a = 1;\n');
+  });
+
+  afterAll(() => {
+    fs.rmSync(unindexed, { recursive: true, force: true });
+  });
+
+  it('gives friendly guidance — never a stack trace — when there is no index', () => {
+    const { code, output } = runCli(['ui', unindexed]);
+    expect(code).toBe(1);
+    expect(output).toContain('No CodeGraph index found');
+    expect(output).toContain('codegraph init');
+    expect(output).not.toContain('at Object.');
+    expect(output).not.toContain('Error:');
+  });
+
+  // `/etc` is only sensitive on POSIX; on Windows it resolves to a
+  // non-existent `C:\etc` and the "no index" path handles it instead.
+  it.runIf(process.platform !== 'win32')('refuses a sensitive system directory', () => {
+    const { code, output } = runCli(['ui', '/etc']);
+    expect(code).toBe(1);
+    expect(output).toContain('Refusing to operate on sensitive');
+  });
+});
+
+describe('codegraph ui — serving', () => {
+  let projectDir: string;
+  let markerDir: string;
+  let opener: string;
+
+  beforeAll(async () => {
+    projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-cli-'));
+    fs.mkdirSync(path.join(projectDir, 'src'));
+    fs.writeFileSync(
+      path.join(projectDir, 'src', 'auth.ts'),
+      'export function parseToken(t: string){ return t.trim(); }\n'
+    );
+    const cg = CodeGraph.initSync(projectDir);
+    await cg.indexAll();
+    cg.close();
+
+    // A stand-in browser: records that it was launched, and with what.
+    markerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-open-'));
+    const markerFile = path.join(markerDir, 'opened.txt');
+    if (process.platform === 'win32') {
+      opener = path.join(markerDir, 'open.cmd');
+      fs.writeFileSync(opener, `@echo %1 > "${markerFile}"\r\n`);
+    } else {
+      opener = path.join(markerDir, 'open.sh');
+      fs.writeFileSync(opener, `#!/bin/sh\nprintf '%s' "$1" > "${markerFile}"\n`);
+      fs.chmodSync(opener, 0o755);
+    }
+  }, 120_000);
+
+  afterAll(() => {
+    fs.rmSync(projectDir, { recursive: true, force: true });
+    fs.rmSync(markerDir, { recursive: true, force: true });
+  });
+
+  const markerFile = (): string => path.join(markerDir, 'opened.txt');
+
+  /** The opener is async (detached); give it a moment before concluding. */
+  async function waitForMarker(timeoutMs: number): Promise<string | null> {
+    const deadline = Date.now() + timeoutMs;
+    for (;;) {
+      if (fs.existsSync(markerFile())) return fs.readFileSync(markerFile(), 'utf-8');
+      if (Date.now() > deadline) return null;
+      await new Promise((r) => setTimeout(r, 50));
+    }
+  }
+
+  it('serves the viewer and prints where it is', async () => {
+    const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
+    try {
+      const res = await get(viewer.port, '/');
+      expect(res.status).toBe(200);
+      expect(res.body).toContain('<div id="app">');
+
+      const banner = viewer.output();
+      expect(banner).toContain('CodeGraph viewer');
+      expect(banner).toContain(projectDir);
+      expect(banner).toContain('this machine only');
+    } finally {
+      await stopViewer(viewer.child);
+    }
+  }, 60_000);
+
+  it('honours --no-open: no browser is launched', async () => {
+    fs.rmSync(markerFile(), { force: true });
+    const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {
+      CODEGRAPH_BROWSER: opener,
+    });
+    try {
+      // Confirm the server is genuinely up before concluding "nothing opened" —
+      // otherwise this passes for the wrong reason.
+      expect((await get(viewer.port, '/')).status).toBe(200);
+      expect(await waitForMarker(1_500)).toBeNull();
+      expect(viewer.output()).toContain('Open that URL in a browser');
+      expect(viewer.output()).not.toContain('Opening your browser');
+    } finally {
+      await stopViewer(viewer.child);
+    }
+  }, 60_000);
+
+  it('opens the browser at the served URL when --no-open is absent', async () => {
+    fs.rmSync(markerFile(), { force: true });
+    const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: opener });
+    try {
+      const opened = await waitForMarker(10_000);
+      expect(opened).not.toBeNull();
+      expect(opened?.trim()).toContain(`http://127.0.0.1:${viewer.port}`);
+      expect(viewer.output()).toContain('Opening your browser');
+    } finally {
+      await stopViewer(viewer.child);
+    }
+  }, 60_000);
+
+  it('CODEGRAPH_BROWSER=none suppresses the launch like --no-open', async () => {
+    fs.rmSync(markerFile(), { force: true });
+    const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: 'none' });
+    try {
+      expect((await get(viewer.port, '/')).status).toBe(200);
+      expect(await waitForMarker(1_000)).toBeNull();
+    } finally {
+      await stopViewer(viewer.child);
+    }
+  }, 60_000);
+
+  it('moves off the default port when it is busy', async () => {
+    // Occupy 4747 so the fallback has something to fall back FROM. If a
+    // developer's own viewer already holds it, the bind fails and the
+    // assertion below is still exactly the right one: the new viewer must not
+    // be on 4747 either way.
+    const blocker = http.createServer(() => {});
+    const bound = await new Promise<boolean>((resolve) => {
+      blocker.once('error', () => resolve(false));
+      blocker.listen(DEFAULT_PORT, '127.0.0.1', () => resolve(true));
+    });
+
+    try {
+      const viewer = await startViewer(['--no-open', projectDir], {});
+      try {
+        expect(viewer.port).not.toBe(DEFAULT_PORT);
+        expect((await get(viewer.port, '/')).status).toBe(200);
+      } finally {
+        await stopViewer(viewer.child);
+      }
+    } finally {
+      if (bound) await new Promise<void>((resolve) => blocker.close(() => resolve()));
+    }
+  }, 60_000);
+
+  it('refuses to move off a port the user pinned with --port', async () => {
+    const blocker = http.createServer(() => {});
+    await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
+    const taken = (blocker.address() as { port: number }).port;
+    try {
+      const { code, output } = runCli(['ui', '--no-open', '--port', String(taken), projectDir]);
+      expect(code).toBe(1);
+      expect(output).toContain('already in use');
+      expect(output).not.toContain('at Object.');
+    } finally {
+      await new Promise<void>((resolve) => blocker.close(() => resolve()));
+    }
+  }, 60_000);
+
+  it('refuses a foreign Host end-to-end', async () => {
+    const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
+    try {
+      const res = await new Promise<{ status: number; body: string }>((resolve, reject) => {
+        const req = http.request(
+          {
+            host: '127.0.0.1',
+            port: viewer.port,
+            path: '/',
+            headers: { Host: 'evil.example' },
+            setHost: false,
+          },
+          (r) => {
+            const chunks: Buffer[] = [];
+            r.on('data', (c: Buffer) => chunks.push(c));
+            r.on('end', () =>
+              resolve({ status: r.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
+            );
+          }
+        );
+        req.on('error', reject);
+        req.end();
+      });
+      expect(res.status).toBe(403);
+      expect(res.body).not.toContain('<div id="app">');
+    } finally {
+      await stopViewer(viewer.child);
+    }
+  }, 60_000);
+});

+ 530 - 0
__tests__/ui-server.test.ts

@@ -0,0 +1,530 @@
+/**
+ * `codegraph ui` server — the loopback boundary (CG-41).
+ *
+ * This process serves the user's source code from a port on their machine, so
+ * the tests that matter are the refusals: a foreign `Host` (DNS rebinding is
+ * the only realistic attack on a loopback code viewer), a traversal out of the
+ * asset root, a write method, a cross-origin read. The happy path — index.html
+ * and hashed assets — is here mostly so a refusal that accidentally blocks
+ * everything can't pass.
+ *
+ * Requests go through `http.request`, not `fetch`: `Host` is a forbidden header
+ * name in undici, and forging it is the whole point of half these cases.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import {
+  browserOpenCommand,
+  cacheControlFor,
+  contentTypeFor,
+  isAllowedHost,
+  isAllowedOrigin,
+  isSafeRequestPath,
+  PathRefusalError,
+  resolveProjectFile,
+  resolveStaticAsset,
+  startUiServer,
+  type UiServerHandle,
+} from '../src/ui-server';
+
+interface Response {
+  status: number;
+  headers: http.IncomingHttpHeaders;
+  body: string;
+}
+
+/**
+ * One request with full control over the request line and headers.
+ *
+ * `setHost: false` stops node from adding its own `Host`, and `path` is sent
+ * verbatim — so a traversal case really does put `/../../x` on the wire.
+ */
+function request(
+  port: number,
+  requestPath: string,
+  options: { method?: string; headers?: Record<string, string> } = {}
+): Promise<Response> {
+  return new Promise((resolve, reject) => {
+    const headers: Record<string, string> = { Host: `127.0.0.1:${port}`, ...options.headers };
+    const req = http.request(
+      { host: '127.0.0.1', port, path: requestPath, method: options.method ?? 'GET', headers, setHost: false },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            headers: res.headers,
+            body: Buffer.concat(chunks).toString('utf-8'),
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+describe('codegraph ui server', () => {
+  let tempDir: string;
+  let viewerDir: string;
+  let projectRoot: string;
+  let server: UiServerHandle;
+
+  beforeAll(async () => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-server-'));
+
+    // A stand-in for dist/viewer: same shape (index.html + hashed assets/), so
+    // the tests don't need the Svelte build to have run.
+    viewerDir = path.join(tempDir, 'viewer');
+    fs.mkdirSync(path.join(viewerDir, 'assets'), { recursive: true });
+    fs.writeFileSync(
+      path.join(viewerDir, 'index.html'),
+      '<!doctype html><html><body><div id="app"></div>' +
+        '<script type="module" src="./assets/index-abc123.js"></script></body></html>'
+    );
+    fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.js'), 'export const viewer = 1;\n');
+    fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.css'), ':root{color:#16150f}\n');
+
+    projectRoot = path.join(tempDir, 'project');
+    fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
+    fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
+
+    // A file OUTSIDE both roots that a traversal would be trying to reach.
+    fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
+
+    server = await startUiServer({ projectRoot, viewerDir, port: 0 });
+  });
+
+  afterAll(async () => {
+    await server?.close();
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  describe('serving the viewer', () => {
+    it('serves index.html at the root', async () => {
+      const res = await request(server.port, '/');
+      expect(res.status).toBe(200);
+      expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
+      expect(res.body).toContain('<div id="app">');
+    });
+
+    it('serves index.html directly too', async () => {
+      const res = await request(server.port, '/index.html');
+      expect(res.status).toBe(200);
+      expect(res.body).toContain('<div id="app">');
+    });
+
+    it('serves hashed assets with their real content type', async () => {
+      const js = await request(server.port, '/assets/index-abc123.js');
+      expect(js.status).toBe(200);
+      expect(js.headers['content-type']).toBe('text/javascript; charset=utf-8');
+      expect(js.body).toContain('export const viewer');
+
+      const css = await request(server.port, '/assets/index-abc123.css');
+      expect(css.status).toBe(200);
+      expect(css.headers['content-type']).toBe('text/css; charset=utf-8');
+    });
+
+    it('caches hashed assets forever and index.html never', async () => {
+      const asset = await request(server.port, '/assets/index-abc123.js');
+      expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable');
+      const index = await request(server.port, '/');
+      expect(index.headers['cache-control']).toBe('no-store');
+    });
+
+    it('falls back to index.html for an unknown route, but not for a missing asset', async () => {
+      // A hash-routed app only ever asks for `/`, but a hand-typed deep path
+      // should still open the app.
+      const route = await request(server.port, '/s/some-symbol-id');
+      expect(route.status).toBe(200);
+      expect(route.body).toContain('<div id="app">');
+
+      // A missing FILE must 404 — answering with HTML would hand the browser a
+      // script that isn't one, and hide a broken build.
+      const asset = await request(server.port, '/assets/index-doesnotexist.js');
+      expect(asset.status).toBe(404);
+    });
+
+    it('answers HEAD with the same headers and no body', async () => {
+      const res = await request(server.port, '/', { method: 'HEAD' });
+      expect(res.status).toBe(200);
+      expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
+      expect(res.headers['content-length']).toBeDefined();
+      expect(res.body).toBe('');
+    });
+  });
+
+  describe('binding', () => {
+    it('listens on loopback only', () => {
+      const address = server.server.address();
+      expect(address).not.toBeNull();
+      expect(typeof address === 'object' ? address?.address : null).toBe('127.0.0.1');
+      expect(server.url).toBe(`http://127.0.0.1:${server.port}`);
+    });
+
+    it('falls back to the next free port when the preferred one is taken', async () => {
+      const blocker = http.createServer(() => {});
+      await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
+      const taken = (blocker.address() as { port: number }).port;
+
+      const second = await startUiServer({ projectRoot, viewerDir, port: taken });
+      try {
+        expect(second.port).not.toBe(taken);
+        expect(second.port).toBeGreaterThan(taken);
+        // …and it actually works on the port it landed on.
+        const res = await request(second.port, '/');
+        expect(res.status).toBe(200);
+      } finally {
+        await second.close();
+        await new Promise<void>((resolve) => blocker.close(() => resolve()));
+      }
+    });
+
+    it('refuses to move off a port the caller pinned', async () => {
+      const blocker = http.createServer(() => {});
+      await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
+      const taken = (blocker.address() as { port: number }).port;
+
+      try {
+        await expect(
+          startUiServer({ projectRoot, viewerDir, port: taken, portFallback: false })
+        ).rejects.toThrow(/already in use/i);
+      } finally {
+        await new Promise<void>((resolve) => blocker.close(() => resolve()));
+      }
+    });
+  });
+
+  describe('Host allowlist (DNS rebinding)', () => {
+    it('serves the loopback names', async () => {
+      for (const host of ['127.0.0.1', 'localhost', '[::1]', `localhost:${server.port}`, `[::1]:${server.port}`]) {
+        const res = await request(server.port, '/', { headers: { Host: host } });
+        expect(res.status, `Host: ${host}`).toBe(200);
+      }
+    });
+
+    it('refuses a foreign Host', async () => {
+      for (const host of ['evil.example', `evil.example:${server.port}`, 'attacker.localhost.evil.com']) {
+        const res = await request(server.port, '/', { headers: { Host: host } });
+        expect(res.status, `Host: ${host}`).toBe(403);
+        expect(res.body).not.toContain('<div id="app">');
+      }
+    });
+
+    it('refuses a loopback Host carrying someone else\u2019s port', async () => {
+      const res = await request(server.port, '/', { headers: { Host: '127.0.0.1:9' } });
+      expect(res.status).toBe(403);
+    });
+
+    it('refuses a malformed or missing Host', async () => {
+      const malformed = await request(server.port, '/', { headers: { Host: '127.0.0.1:notaport' } });
+      expect(malformed.status).toBe(403);
+      // Node's client insists on sending something for Host, so the empty-value
+      // case is covered by the unit assertions on isAllowedHost below.
+    });
+
+    it('refuses before touching the filesystem — even for an asset', async () => {
+      const res = await request(server.port, '/assets/index-abc123.js', {
+        headers: { Host: 'evil.example' },
+      });
+      expect(res.status).toBe(403);
+      expect(res.body).not.toContain('export const viewer');
+    });
+  });
+
+  describe('cross-origin', () => {
+    it('never sends CORS headers', async () => {
+      const res = await request(server.port, '/');
+      expect(res.headers['access-control-allow-origin']).toBeUndefined();
+      expect(res.headers['access-control-allow-credentials']).toBeUndefined();
+      expect(res.headers['access-control-allow-methods']).toBeUndefined();
+    });
+
+    it('refuses a request carrying a foreign Origin', async () => {
+      const res = await request(server.port, '/', { headers: { Origin: 'https://evil.example' } });
+      expect(res.status).toBe(403);
+    });
+
+    it('allows the viewer\u2019s own origin', async () => {
+      const res = await request(server.port, '/', {
+        headers: { Origin: `http://127.0.0.1:${server.port}` },
+      });
+      expect(res.status).toBe(200);
+    });
+
+    it('sends the hardening headers on every response', async () => {
+      const res = await request(server.port, '/');
+      expect(res.headers['x-content-type-options']).toBe('nosniff');
+      expect(res.headers['x-frame-options']).toBe('DENY');
+      expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'");
+      expect(res.headers['content-security-policy']).toContain("connect-src 'self'");
+    });
+  });
+
+  describe('read-only', () => {
+    it('refuses every method that is not GET or HEAD', async () => {
+      for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
+        const res = await request(server.port, '/', { method });
+        expect(res.status, method).toBe(405);
+        expect(res.headers['allow']).toBe('GET, HEAD');
+      }
+    });
+  });
+
+  describe('paths outside the asset root', () => {
+    const traversals = [
+      '/../secret.txt',
+      '/../../secret.txt',
+      '/assets/../../secret.txt',
+      '/..%2fsecret.txt',
+      '/%2e%2e/secret.txt',
+      '/%2e%2e%2fsecret.txt',
+      '/....//secret.txt',
+    ];
+
+    it('never serves a file outside the viewer directory', async () => {
+      for (const traversal of traversals) {
+        const res = await request(server.port, traversal);
+        expect(res.body, traversal).not.toContain('SUPER-SECRET-VALUE');
+        expect(res.status, traversal).not.toBe(200);
+      }
+    });
+
+    it('404s an absolute system path rather than reading it', async () => {
+      const res = await request(server.port, '/etc/passwd');
+      expect(res.body).not.toContain('root:');
+      // No such file under the viewer root; an extension-less path is a route.
+      expect(res.status).toBe(200);
+      expect(res.body).toContain('<div id="app">');
+
+      const shadow = await request(server.port, '/etc/hosts.txt');
+      expect(shadow.status).toBe(404);
+    });
+
+    it('404s a NUL-truncation attempt', async () => {
+      const res = await request(server.port, '/index.html%00.png');
+      expect(res.status).toBe(404);
+    });
+  });
+
+  describe('/api is reserved', () => {
+    it('404s as JSON, never as the app shell', async () => {
+      const res = await request(server.port, '/api/nodes');
+      expect(res.status).toBe(404);
+      expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
+      expect(JSON.parse(res.body)).toHaveProperty('error');
+      expect(res.body).not.toContain('<div id="app">');
+    });
+
+    it('hands requests to a mounted handler with a decoded path and query', async () => {
+      const seen: Array<{ pathname: string; symbol: string | null; root: string }> = [];
+      const withApi = await startUiServer({
+        projectRoot,
+        viewerDir,
+        port: 0,
+        api: (_req, res, ctx) => {
+          seen.push({
+            pathname: ctx.pathname,
+            symbol: ctx.query.get('symbol'),
+            root: ctx.projectRoot,
+          });
+          res.writeHead(200, { 'Content-Type': 'application/json' });
+          res.end('{"ok":true}');
+          return true;
+        },
+      });
+      try {
+        const res = await request(withApi.port, '/api/node?symbol=parse%20Token');
+        expect(res.status).toBe(200);
+        expect(JSON.parse(res.body)).toEqual({ ok: true });
+        expect(seen).toEqual([{ pathname: '/api/node', symbol: 'parse Token', root: projectRoot }]);
+      } finally {
+        await withApi.close();
+      }
+    });
+
+    it('turns a throwing handler into a JSON 500, not a crashed server', async () => {
+      const withApi = await startUiServer({
+        projectRoot,
+        viewerDir,
+        port: 0,
+        api: () => {
+          throw new Error('handler blew up');
+        },
+      });
+      try {
+        const res = await request(withApi.port, '/api/boom');
+        expect(res.status).toBe(500);
+        expect(JSON.parse(res.body).error).toContain('handler blew up');
+        // Still alive afterwards.
+        expect((await request(withApi.port, '/')).status).toBe(200);
+      } finally {
+        await withApi.close();
+      }
+    });
+  });
+});
+
+describe('resolveProjectFile — the source read chokepoint', () => {
+  let tempDir: string;
+  let projectRoot: string;
+
+  beforeAll(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-paths-'));
+    projectRoot = path.join(tempDir, 'project');
+    fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
+    fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
+    fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
+  });
+
+  afterAll(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('resolves a file inside the project', () => {
+    expect(resolveProjectFile(projectRoot, 'src/auth.ts')).toBe(
+      fs.realpathSync(path.join(projectRoot, 'src', 'auth.ts'))
+    );
+  });
+
+  it('refuses traversal out of the project', () => {
+    for (const escape of ['../secret.txt', 'src/../../secret.txt', '..%2fsecret.txt']) {
+      expect(() => resolveProjectFile(projectRoot, escape), escape).toThrow(PathRefusalError);
+    }
+  });
+
+  it('refuses an absolute path', () => {
+    expect(() => resolveProjectFile(projectRoot, path.join(tempDir, 'secret.txt'))).toThrow(
+      PathRefusalError
+    );
+  });
+
+  it('refuses an empty path', () => {
+    expect(() => resolveProjectFile(projectRoot, '')).toThrow(PathRefusalError);
+    expect(() => resolveProjectFile(projectRoot, '   ')).toThrow(PathRefusalError);
+  });
+
+  it('refuses a NUL byte', () => {
+    expect(() => resolveProjectFile(projectRoot, 'src/auth.ts%00.png')).toThrow(PathRefusalError);
+  });
+
+  // `/etc` resolves to a non-existent `C:\etc` on Windows, so the sensitive-path
+  // list only means anything on POSIX.
+  it.runIf(process.platform !== 'win32')('refuses a sensitive system directory as the root', () => {
+    expect(() => resolveProjectFile('/etc', 'passwd')).toThrow(PathRefusalError);
+    expect(() => resolveProjectFile('/', 'etc/passwd')).toThrow(PathRefusalError);
+  });
+
+  it.runIf(process.platform !== 'win32')('refuses a symlink pointing out of the project (#527)', () => {
+    const link = path.join(projectRoot, 'src', 'escape.ts');
+    fs.symlinkSync(path.join(tempDir, 'secret.txt'), link);
+    try {
+      expect(() => resolveProjectFile(projectRoot, 'src/escape.ts')).toThrow(PathRefusalError);
+    } finally {
+      fs.unlinkSync(link);
+    }
+  });
+});
+
+describe('security helpers', () => {
+  it('isAllowedHost accepts only loopback names on our port', () => {
+    expect(isAllowedHost('127.0.0.1', 4747)).toBe(true);
+    expect(isAllowedHost('127.0.0.1:4747', 4747)).toBe(true);
+    expect(isAllowedHost('localhost:4747', 4747)).toBe(true);
+    expect(isAllowedHost('LOCALHOST', 4747)).toBe(true);
+    expect(isAllowedHost('[::1]:4747', 4747)).toBe(true);
+
+    expect(isAllowedHost(undefined, 4747)).toBe(false);
+    expect(isAllowedHost('', 4747)).toBe(false);
+    expect(isAllowedHost('evil.example', 4747)).toBe(false);
+    expect(isAllowedHost('127.0.0.1:4748', 4747)).toBe(false);
+    expect(isAllowedHost('127.0.0.1.evil.example', 4747)).toBe(false);
+    expect(isAllowedHost('localhost.evil.example:4747', 4747)).toBe(false);
+    expect(isAllowedHost('127.0.0.1:4747:4747', 4747)).toBe(false);
+    // Unbracketed IPv6 is malformed per RFC 7230 — rejected, not guessed at.
+    expect(isAllowedHost('::1', 4747)).toBe(false);
+    // A non-loopback address that merely resolves here still fails the check.
+    expect(isAllowedHost('192.168.1.5:4747', 4747)).toBe(false);
+  });
+
+  it('isAllowedOrigin allows absent and same-origin, refuses everything else', () => {
+    expect(isAllowedOrigin(undefined, 4747)).toBe(true);
+    expect(isAllowedOrigin('http://127.0.0.1:4747', 4747)).toBe(true);
+    expect(isAllowedOrigin('http://localhost:4747', 4747)).toBe(true);
+    expect(isAllowedOrigin('http://[::1]:4747', 4747)).toBe(true);
+
+    expect(isAllowedOrigin('null', 4747)).toBe(false);
+    expect(isAllowedOrigin('https://evil.example', 4747)).toBe(false);
+    expect(isAllowedOrigin('http://127.0.0.1:4748', 4747)).toBe(false);
+    expect(isAllowedOrigin('file://', 4747)).toBe(false);
+    expect(isAllowedOrigin('not a url', 4747)).toBe(false);
+  });
+
+  it('isSafeRequestPath rejects a `..` segment however it is spelled', () => {
+    expect(isSafeRequestPath('/')).toBe(true);
+    expect(isSafeRequestPath('/assets/index-abc123.js')).toBe(true);
+    expect(isSafeRequestPath('/s/Some.Symbol')).toBe(true);
+
+    expect(isSafeRequestPath('/../secret')).toBe(false);
+    expect(isSafeRequestPath('/a/../../secret')).toBe(false);
+    expect(isSafeRequestPath('/%2e%2e/secret')).toBe(false);
+    expect(isSafeRequestPath('/..%2Fsecret')).toBe(false);
+    expect(isSafeRequestPath('/a%00b')).toBe(false);
+    expect(isSafeRequestPath('/a\\b')).toBe(false);
+    expect(isSafeRequestPath('/%zz')).toBe(false);
+  });
+
+  it('resolveStaticAsset returns null for anything that is not a file in the root', () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-static-'));
+    try {
+      fs.mkdirSync(path.join(dir, 'assets'));
+      fs.writeFileSync(path.join(dir, 'index.html'), 'x');
+      expect(resolveStaticAsset(dir, '/index.html')).toBe(
+        fs.realpathSync(path.join(dir, 'index.html'))
+      );
+      expect(resolveStaticAsset(dir, '/assets')).toBeNull(); // a directory
+      expect(resolveStaticAsset(dir, '/missing.js')).toBeNull();
+      expect(resolveStaticAsset(dir, '/../etc/passwd')).toBeNull();
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
+  it('contentTypeFor covers the viewer bundle and defaults safely', () => {
+    expect(contentTypeFor('a/index.html')).toBe('text/html; charset=utf-8');
+    expect(contentTypeFor('a/index-abc.js')).toBe('text/javascript; charset=utf-8');
+    expect(contentTypeFor('a/archivo.woff2')).toBe('font/woff2');
+    expect(contentTypeFor('a/thing.unknownext')).toBe('application/octet-stream');
+  });
+
+  it('cacheControlFor pins hashed assets and never index.html', () => {
+    expect(cacheControlFor(path.join('assets', 'index-abc.js'))).toContain('immutable');
+    expect(cacheControlFor('index.html')).toBe('no-store');
+  });
+});
+
+describe('browserOpenCommand', () => {
+  it('uses the platform opener', () => {
+    expect(browserOpenCommand('http://x', 'darwin')).toEqual({ command: 'open', args: ['http://x'] });
+    expect(browserOpenCommand('http://x', 'linux')).toEqual({ command: 'xdg-open', args: ['http://x'] });
+    expect(browserOpenCommand('http://x', 'win32')).toEqual({
+      command: 'cmd',
+      args: ['/c', 'start', '', 'http://x'],
+    });
+  });
+
+  it('honours the CODEGRAPH_BROWSER override', () => {
+    expect(browserOpenCommand('http://x', 'darwin', 'firefox')).toEqual({
+      command: 'firefox',
+      args: ['http://x'],
+    });
+    for (const off of ['none', 'NONE', '0', 'false', 'off', '', '  ']) {
+      expect(browserOpenCommand('http://x', 'darwin', off), off).toBeNull();
+    }
+  });
+});

+ 136 - 0
src/bin/codegraph.ts

@@ -20,6 +20,7 @@
  *   codegraph callees <symbol>   Find what a function/method calls
  *   codegraph impact <symbol>    Analyze what code is affected by changing a symbol
  *   codegraph affected [files]   Find test files affected by changes
+ *   codegraph ui [path]          Open the browser viewer for an indexed project
  *   codegraph upgrade [version]  Update CodeGraph to the latest release
  */
 
@@ -53,6 +54,11 @@ import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime
 import { installCommandSupervision } from './command-supervision';
 import { EXTRACTION_VERSION } from '../extraction/extraction-version';
 import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
+// Value import, but dependency-free by design so `--help` text can name the
+// default port without dragging node:http into every other subcommand; the
+// server itself is loaded lazily inside the `ui` action. See ui-server/constants.
+import { BROWSER_ENV, DEFAULT_UI_PORT } from '../ui-server/constants';
+import type { UiServerHandle } from '../ui-server';
 
 // Decided once, before `--color`/`--no-color` are stripped from argv below
 // (#1281). Piped/redirected stdout, NO_COLOR, or --no-color -> plain output.
@@ -1822,6 +1828,136 @@ program
     });
   });
 
+/**
+ * Print the "no index here" guidance.
+ *
+ * The viewer READS an index; it never builds one — indexing stays the user's
+ * decision, exactly as it is for the MCP tools. So a missing index is normal
+ * input, not a failure to apologize for: say what is missing, say the one
+ * command that fixes it, and never print a stack trace.
+ */
+function printNoIndexGuidance(projectPath: string): void {
+  error(`No CodeGraph index found for ${projectPath}`);
+  console.error('');
+  console.error('  The viewer reads an index that already exists — it never creates one.');
+  console.error('  To index this project:');
+  console.error('');
+  console.error(`    ${chalk.cyan('codegraph init')}`);
+  console.error('');
+  console.error('  Already indexed somewhere else? Point the viewer at it:');
+  console.error('');
+  console.error(`    ${chalk.cyan('codegraph ui /path/to/indexed/project')}`);
+  console.error('');
+}
+
+/**
+ * codegraph ui [path]  (alias: web)
+ *
+ * The browser reader: serves the built viewer (`dist/viewer/`) over loopback
+ * and opens it. Read-only in every sense — it answers GET, it opens the index
+ * for reading, and it never writes to the project or the graph.
+ *
+ * Deliberately absent from TELEMETRY_FLUSH_COMMANDS above: the command's own
+ * banner tells the user nothing leaves their machine, so it must not be the
+ * thing that triggers a telemetry send. The usage count still buffers locally
+ * like every other quick command.
+ */
+program
+  .command('ui [path]')
+  .alias('web')
+  .description('Open the CodeGraph viewer in your browser — read your indexed project as a graph')
+  .option('--port <number>', `Port to listen on (default: ${DEFAULT_UI_PORT}, or the next free one)`)
+  .option('--no-open', 'Print the URL instead of opening a browser')
+  .addHelpText(
+    'after',
+    `
+Examples:
+  $ codegraph ui                    Read the project you're standing in
+  $ codegraph ui ~/code/my-app      Read a specific indexed project
+  $ codegraph ui --port 8080        Use one specific port (fails if it's taken)
+  $ codegraph ui --no-open          Just print the URL (headless boxes, SSH)
+
+The viewer listens on 127.0.0.1 only, so nothing on your network can reach it,
+and it is read-only: it opens an index that already exists and never changes
+your project or your graph. Requests from any other host are refused.
+
+Without --port it takes ${DEFAULT_UI_PORT}, or the next free port if that one is busy.
+
+Set ${BROWSER_ENV}=<command> to choose which browser opens, or
+${BROWSER_ENV}=none to never open one.
+`
+  )
+  .action(async (pathArg: string | undefined, options: { port?: string; open?: boolean }) => {
+    // An explicit --port stays explicit: a scripted `--port 8080` that quietly
+    // lands on 8081 is worse than one that says the port is busy. The default
+    // port is the only one we're free to walk away from.
+    let requestedPort: number | undefined;
+    if (options.port !== undefined) {
+      requestedPort = Number(options.port);
+      if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
+        error(`--port must be a whole number between 0 and 65535 (got "${options.port}").`);
+        process.exit(1);
+      }
+    }
+
+    const projectPath = resolveProjectPath(pathArg);
+
+    // Sensitive-directory refusal before anything opens: the same guard the MCP
+    // entry points use, so `codegraph ui /etc` is turned away here rather than
+    // becoming a browsable view of the system.
+    const { validateProjectPath } = await import('../utils');
+    const rootError = validateProjectPath(projectPath);
+    if (rootError) {
+      error(rootError);
+      process.exit(1);
+    }
+
+    if (!isInitialized(projectPath)) {
+      printNoIndexGuidance(projectPath);
+      process.exit(1);
+    }
+
+    const { startUiServer, openBrowser, ViewerMissingError } = await import('../ui-server');
+
+    let handle: UiServerHandle;
+    try {
+      handle = await startUiServer({
+        projectRoot: projectPath,
+        port: requestedPort,
+        portFallback: requestedPort === undefined,
+      });
+    } catch (err) {
+      // Both failure modes here (viewer assets missing, no port available) carry
+      // their own remediation — print it plainly, never a stack trace.
+      error(err instanceof ViewerMissingError || err instanceof Error ? err.message : String(err));
+      process.exit(1);
+    }
+
+    console.log('');
+    console.log(chalk.bold('CodeGraph viewer'));
+    console.log('');
+    console.log(`  ${chalk.dim('Reading')}  ${projectPath}`);
+    console.log(`  ${chalk.dim('URL')}      ${chalk.cyan(handle.url)}`);
+    console.log(`  ${chalk.dim('Access')}   this machine only ${getGlyphs().dash} read-only, nothing leaves your computer`);
+    console.log('');
+
+    const opened = options.open === false ? false : openBrowser(handle.url);
+    console.log(
+      opened
+        ? chalk.dim('  Opening your browser… press Ctrl+C to stop.')
+        : chalk.dim('  Open that URL in a browser. Press Ctrl+C to stop.')
+    );
+    console.log('');
+
+    // The http server keeps the event loop alive on its own; these just make
+    // Ctrl-C hang up live sockets instead of waiting on browser keep-alives.
+    const shutdown = (): void => {
+      void handle.close().then(() => process.exit(0));
+    };
+    process.once('SIGINT', shutdown);
+    process.once('SIGTERM', shutdown);
+  });
+
 /**
  * codegraph serve
  */

+ 14 - 0
src/errors.ts

@@ -161,6 +161,20 @@ export class ConfigError extends CodeGraphError {
   }
 }
 
+/**
+ * A refused path — the caller asked for something outside the project root, or
+ * for a sensitive system directory. Deliberately a plain `Error` and NOT a
+ * {@link CodeGraphError}: it is a security marker every read sink tests with
+ * `instanceof`, not a categorized operational failure, and the MCP layer treats
+ * it as one of the only two "stop trying" conditions (see `mcp/tools.ts`).
+ *
+ * It lives here — in the dependency-free error module — rather than next to its
+ * first caller so that a consumer can enforce the refusal WITHOUT importing the
+ * MCP tool graph. `mcp/tools.ts` re-exports it, so the class identity stays
+ * single and every existing `instanceof` check keeps working.
+ */
+export class PathRefusalError extends Error {}
+
 /**
  * Simple logger for CodeGraph operations
  *

+ 6 - 1
src/mcp/tools.ts

@@ -80,8 +80,13 @@ export class NotIndexedError extends Error {}
 /**
  * A security refusal (sensitive system path). Stays `isError: true` WITHOUT
  * retry guidance — abandoning this path is the desired agent reaction.
+ *
+ * Defined in `../errors` so non-MCP read sinks (the `codegraph ui` server) can
+ * enforce the same refusal without importing this module; re-exported here
+ * because this is where every existing caller imports it from.
  */
-export class PathRefusalError extends Error {}
+export { PathRefusalError } from '../errors';
+import { PathRefusalError } from '../errors';
 import { resolve as resolvePath, relative as relativePath } from 'path';
 
 /** Maximum output length to prevent context bloat (characters) */

+ 76 - 0
src/ui-server/assets.ts

@@ -0,0 +1,76 @@
+/**
+ * Locating the built browser viewer on disk.
+ *
+ * The viewer is a static Vite build that ships inside the package, exactly like
+ * `schema.sql` and the tree-sitter grammars: emitted into `dist/viewer/`,
+ * copied wholesale by `scripts/build-bundle.sh`, packed by
+ * `scripts/pack-npm.sh`. So it is found the same way `db/index.ts` finds
+ * `schema.sql` — relative to `__dirname`, never to `process.cwd()`, which is
+ * whatever directory the user happened to be standing in.
+ *
+ * `dist/viewer`, NOT `dist/ui`: `src/ui/` is the engine's TERMINAL ui and tsc
+ * already compiles it to `dist/ui/`. See `ui/vite.config.ts`.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { VIEWER_PATH_ENV } from './constants';
+
+export { VIEWER_PATH_ENV };
+
+/**
+ * The viewer build is missing — the package was assembled without it, or the
+ * repo was built with `tsc` alone. Carries user-facing remediation rather than
+ * a stack trace, because the CLI prints `.message` verbatim.
+ */
+export class ViewerMissingError extends Error {
+  constructor(searched: readonly string[]) {
+    super(
+      'The CodeGraph viewer assets are missing from this installation.\n' +
+        'Looked in:\n' +
+        searched.map((p) => `  ${p}`).join('\n') +
+        '\n\nIf you installed CodeGraph normally, reinstall it — the release bundle ' +
+        'ships the viewer.\nIf you are working from a source checkout, run: npm run build'
+    );
+    this.name = 'ViewerMissingError';
+  }
+}
+
+/**
+ * Candidate locations for the viewer, most-specific first.
+ *
+ * 1. The `CODEGRAPH_VIEWER_PATH` override.
+ * 2. `<__dirname>/../viewer` — the shipped layout (`dist/ui-server/` →
+ *    `dist/viewer/`).
+ * 3. `<__dirname>/../../dist/viewer` — running the TypeScript straight out of
+ *    `src/` (vitest, tsx), where `__dirname` is `src/ui-server/`.
+ */
+export function viewerDirCandidates(): string[] {
+  const override = process.env[VIEWER_PATH_ENV]?.trim();
+  const candidates = [
+    path.join(__dirname, '..', 'viewer'),
+    path.join(__dirname, '..', '..', 'dist', 'viewer'),
+  ];
+  return override ? [path.resolve(override), ...candidates] : candidates;
+}
+
+/**
+ * Resolve the directory holding the built viewer.
+ *
+ * @throws {ViewerMissingError} when no candidate contains an `index.html`.
+ */
+export function resolveViewerDir(): string {
+  const candidates = viewerDirCandidates();
+  for (const dir of candidates) {
+    try {
+      if (fs.statSync(path.join(dir, 'index.html')).isFile()) {
+        // realpath so the containment checks in `security.ts` compare like for
+        // like when the install lives behind a symlink (Homebrew, nvm, pnpm).
+        return fs.realpathSync(dir);
+      }
+    } catch {
+      // Not here — try the next candidate.
+    }
+  }
+  throw new ViewerMissingError(candidates);
+}

+ 36 - 0
src/ui-server/constants.ts

@@ -0,0 +1,36 @@
+/**
+ * User-facing constants for the `codegraph ui` server.
+ *
+ * Deliberately dependency-free so the CLI can import them for `--help` text
+ * without pulling `node:http` (and the rest of the server) into every
+ * invocation of every other subcommand. `ui-server/index.ts` re-exports them,
+ * so consumers have one import to reach for.
+ */
+
+/** The port `codegraph ui` asks for first. */
+export const DEFAULT_UI_PORT = 4747;
+
+/** How many consecutive ports to try before giving up. */
+export const DEFAULT_PORT_ATTEMPTS = 20;
+
+/**
+ * The only interface the server ever binds. Not configurable, on purpose: this
+ * process serves the user's source code, and a `--host` flag is one typo away
+ * from publishing it to the local network.
+ */
+export const LOOPBACK_ADDRESS = '127.0.0.1';
+
+/**
+ * Overrides which browser (if any) `codegraph ui` launches. `none` — or `0`,
+ * `false`, `off`, or an empty value — suppresses the launch entirely, the same
+ * as `--no-open`. Any other value is run as a command with the URL as its
+ * single argument.
+ */
+export const BROWSER_ENV = 'CODEGRAPH_BROWSER';
+
+/**
+ * Development/test override for the directory served as the viewer. Point it at
+ * a directory containing an `index.html` to serve something other than the
+ * shipped build.
+ */
+export const VIEWER_PATH_ENV = 'CODEGRAPH_VIEWER_PATH';

+ 426 - 0
src/ui-server/index.ts

@@ -0,0 +1,426 @@
+/**
+ * The `codegraph ui` server.
+ *
+ * A loopback-only, read-only `node:http` server that hands the browser the
+ * built viewer (`dist/viewer/`) and — once the JSON API lands on the `api` seam
+ * below — a read-only view of one indexed project. No framework, no new
+ * dependency: it answers GET, serves files, and refuses everything else.
+ *
+ * The interesting part is not the routing, it is the boundary in `security.ts`.
+ * Read that first.
+ */
+
+import * as fs from 'fs';
+import * as http from 'http';
+import * as path from 'path';
+import { resolveViewerDir } from './assets';
+import {
+  ALLOWED_METHODS,
+  isAllowedHost,
+  isAllowedOrigin,
+  isSafeRequestPath,
+  resolveStaticAsset,
+} from './security';
+import { sendFile, sendJson, sendText, shouldFallBackToIndex } from './static';
+import { DEFAULT_PORT_ATTEMPTS, DEFAULT_UI_PORT, LOOPBACK_ADDRESS } from './constants';
+
+export { ViewerMissingError } from './assets';
+export {
+  BROWSER_ENV,
+  DEFAULT_PORT_ATTEMPTS,
+  DEFAULT_UI_PORT,
+  LOOPBACK_ADDRESS,
+  VIEWER_PATH_ENV,
+} from './constants';
+export {
+  ALLOWED_METHODS,
+  PathRefusalError,
+  isAllowedHost,
+  isAllowedOrigin,
+  isSafeRequestPath,
+  resolveProjectFile,
+  resolveStaticAsset,
+} from './security';
+export { browserOpenCommand, openBrowser } from './open-browser';
+export { contentTypeFor, cacheControlFor } from './static';
+
+
+/**
+ * Everything a request handler needs, already validated.
+ */
+export interface UiRequestContext {
+  /** Percent-decoded path portion of the request URL, always starting with `/`. */
+  pathname: string;
+  /** Parsed query string. */
+  query: URLSearchParams;
+  /** Absolute path of the indexed project this server is reading. */
+  projectRoot: string;
+  /** The request method — `GET` or `HEAD`; nothing else reaches a handler. */
+  method: string;
+}
+
+/**
+ * A handler mounted under `/api/`. Returns `true` when it answered the request
+ * (i.e. wrote a response), `false` to fall through to a 404.
+ *
+ * This is the seam the read-only JSON API plugs into. Everything it serves out
+ * of the user's repository must go through `resolveProjectFile` — see
+ * `security.ts`.
+ */
+export type UiApiHandler = (
+  req: http.IncomingMessage,
+  res: http.ServerResponse,
+  ctx: UiRequestContext
+) => boolean | Promise<boolean>;
+
+export interface UiServerOptions {
+  /** Absolute path of the indexed project to read. */
+  projectRoot: string;
+  /**
+   * Port to bind. `0` lets the OS choose. Defaults to {@link DEFAULT_UI_PORT}.
+   */
+  port?: number;
+  /**
+   * Try the next port when the requested one is taken (default `true`).
+   *
+   * The CLI turns this OFF for an explicit `--port`: a scripted invocation that
+   * silently lands somewhere else is worse than one that says the port is busy.
+   */
+  portFallback?: boolean;
+  /** How many ports to try in total. Defaults to {@link DEFAULT_PORT_ATTEMPTS}. */
+  maxPortAttempts?: number;
+  /** Directory of built viewer assets. Defaults to the shipped `dist/viewer/`. */
+  viewerDir?: string;
+  /** Optional read-only JSON API mounted under `/api/`. */
+  api?: UiApiHandler;
+}
+
+export interface UiServerHandle {
+  /** The port actually bound (may differ from the requested one — see fallback). */
+  port: number;
+  /** The URL to open. */
+  url: string;
+  /** Directory being served as the viewer. */
+  viewerDir: string;
+  /** The underlying server, for tests and for callers that want raw events. */
+  server: http.Server;
+  /** Stop listening and drop live connections. Idempotent. */
+  close(): Promise<void>;
+}
+
+/**
+ * Response headers sent on EVERY response.
+ *
+ * `frame-ancestors`/`X-Frame-Options` stop another page from framing the viewer
+ * and reading it by overlay; `nosniff` stops an asset with a surprising
+ * extension from being executed as script; the CSP pins every resource to this
+ * origin, so a future viewer change cannot start phoning out with what it read.
+ * `style-src` keeps `'unsafe-inline'` because the syntax highlighter emits
+ * inline `style=` attributes on code spans.
+ *
+ * Note what is NOT here: any `Access-Control-*` header. Their absence is what
+ * makes a cross-origin read of a response body impossible even if a request
+ * somehow gets past the `Host` check.
+ */
+const SECURITY_HEADERS: Readonly<Record<string, string>> = {
+  'X-Content-Type-Options': 'nosniff',
+  'X-Frame-Options': 'DENY',
+  'Referrer-Policy': 'no-referrer',
+  'Content-Security-Policy': [
+    "default-src 'none'",
+    "script-src 'self'",
+    "style-src 'self' 'unsafe-inline'",
+    "img-src 'self' data:",
+    "font-src 'self'",
+    "connect-src 'self'",
+    "base-uri 'none'",
+    "form-action 'none'",
+    "frame-ancestors 'none'",
+  ].join('; '),
+};
+
+/**
+ * Start the viewer server.
+ *
+ * Resolves once the socket is bound, so the caller can print a URL that is
+ * already answering.
+ */
+export async function startUiServer(options: UiServerOptions): Promise<UiServerHandle> {
+  // realpath, not just resolve: `resolveStaticAsset` hands back realpaths (the
+  // symlink check in `validatePathWithinRoot` resolves them), so a viewerDir
+  // that still holds a symlink — every macOS `/var/folders` temp dir, plenty of
+  // package managers — would make `path.relative` between the two nonsense, and
+  // the cache policy that keys off it silently wrong.
+  const viewerDir = options.viewerDir ? realpath(options.viewerDir) : resolveViewerDir();
+  const projectRoot = path.resolve(options.projectRoot);
+  const indexHtml = path.join(viewerDir, 'index.html');
+
+  // The bound port is needed by the Host check, but is only known after listen.
+  // Captured by reference so the handler always sees the real value.
+  let boundPort = 0;
+
+  const server = http.createServer((req, res) => {
+    handleRequest(req, res, {
+      viewerDir,
+      indexHtml,
+      projectRoot,
+      api: options.api,
+      port: () => boundPort,
+    }).catch(() => {
+      // handleRequest already answers every error it can; reaching here means
+      // the socket itself is gone. Never let it become an unhandled rejection,
+      // which the CLI's fatal handlers would turn into a process exit.
+      if (!res.writableEnded) res.destroy();
+    });
+  });
+
+  // A browser holds keep-alive sockets open; without this, `close()` would wait
+  // for them and Ctrl-C would appear to hang.
+  server.keepAliveTimeout = 5_000;
+
+  boundPort = await listenWithFallback(server, {
+    port: options.port ?? DEFAULT_UI_PORT,
+    fallback: options.portFallback ?? true,
+    attempts: options.maxPortAttempts ?? DEFAULT_PORT_ATTEMPTS,
+  });
+
+  let closed = false;
+  return {
+    port: boundPort,
+    url: `http://${LOOPBACK_ADDRESS}:${boundPort}`,
+    viewerDir,
+    server,
+    close(): Promise<void> {
+      if (closed) return Promise.resolve();
+      closed = true;
+      return new Promise<void>((resolve) => {
+        server.closeAllConnections();
+        server.close(() => resolve());
+      });
+    },
+  };
+}
+
+interface HandlerDeps {
+  viewerDir: string;
+  indexHtml: string;
+  projectRoot: string;
+  api: UiApiHandler | undefined;
+  port: () => number;
+}
+
+/**
+ * One request, start to finish. Order matters: the cheap refusals (method,
+ * `Host`, `Origin`) run before anything touches the filesystem.
+ */
+async function handleRequest(
+  req: http.IncomingMessage,
+  res: http.ServerResponse,
+  deps: HandlerDeps
+): Promise<void> {
+  const method = req.method ?? 'GET';
+  for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
+    res.setHeader(name, value);
+  }
+
+  if (!ALLOWED_METHODS.includes(method)) {
+    res.setHeader('Allow', ALLOWED_METHODS.join(', '));
+    sendText(res, 405, `codegraph ui is read-only — ${method} is not allowed.`, method);
+    return;
+  }
+
+  const port = deps.port();
+  if (!isAllowedHost(req.headers.host, port)) {
+    // The DNS-rebinding refusal. Say why, since a human hitting this through a
+    // proxy or a container hostname needs to know what to change.
+    sendText(
+      res,
+      403,
+      'Refused: codegraph ui only answers requests addressed to this machine ' +
+        `(localhost, 127.0.0.1 or [::1] on port ${port}).\n` +
+        `This request said Host: ${forEcho(req.headers.host)}`,
+      method
+    );
+    return;
+  }
+
+  if (!isAllowedOrigin(readHeader(req, 'origin'), port)) {
+    sendText(res, 403, 'Refused: cross-origin requests are not served.', method);
+    return;
+  }
+
+  // Checked on the RAW url, before WHATWG parsing folds `..` segments away.
+  const rawPath = (req.url ?? '/').split(/[?#]/)[0] ?? '/';
+  if (!isSafeRequestPath(rawPath)) {
+    sendText(res, 404, 'Not found', method);
+    return;
+  }
+
+  let url: URL;
+  try {
+    url = new URL(req.url ?? '/', `http://${LOOPBACK_ADDRESS}:${port}`);
+  } catch {
+    sendText(res, 400, 'Bad request URL.', method);
+    return;
+  }
+
+  // `/api/` is reserved — it must 404 as JSON rather than fall through to the
+  // SPA, or a typo'd endpoint returns 200 + HTML and the viewer parses the app
+  // shell as a payload.
+  if (url.pathname === '/api' || url.pathname.startsWith('/api/')) {
+    const ctx: UiRequestContext = {
+      pathname: safeDecode(url.pathname),
+      query: url.searchParams,
+      projectRoot: deps.projectRoot,
+      method,
+    };
+    if (deps.api) {
+      try {
+        if (await deps.api(req, res, ctx)) return;
+      } catch (err) {
+        if (!res.headersSent) {
+          sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }, method);
+        } else {
+          res.destroy();
+        }
+        return;
+      }
+    }
+    if (!res.headersSent) sendJson(res, 404, { error: `No such endpoint: ${url.pathname}` }, method);
+    return;
+  }
+
+  const requested = url.pathname === '/' ? '/index.html' : url.pathname;
+  const file = resolveStaticAsset(deps.viewerDir, requested);
+  if (file) {
+    sendFile(res, file, { rootDir: deps.viewerDir, method });
+    return;
+  }
+
+  if (shouldFallBackToIndex(url.pathname)) {
+    sendFile(res, deps.indexHtml, { rootDir: deps.viewerDir, method });
+    return;
+  }
+
+  sendText(res, 404, 'Not found', method);
+}
+
+/** `path.resolve` + symlink resolution, falling back when the path is missing. */
+function realpath(dir: string): string {
+  const resolved = path.resolve(dir);
+  try {
+    return fs.realpathSync(resolved);
+  } catch {
+    return resolved;
+  }
+}
+
+/**
+ * Bound and de-fang an attacker-supplied header before echoing it back.
+ *
+ * The refused `Host` is worth showing — a human hitting this through a proxy or
+ * a container hostname needs to know what was actually sent. But it is
+ * attacker-chosen text, so it goes out truncated and stripped of control bytes.
+ * (The response is `text/plain` + `nosniff`, so there is nothing to inject
+ * into; this is belt and braces.)
+ */
+function forEcho(value: string | undefined): string {
+  if (!value) return '(none)';
+  // eslint-disable-next-line no-control-regex -- stripping raw control bytes IS the point
+  const clean = value.replace(/[\x00-\x1f\x7f]/g, '?');
+  return clean.length > 100 ? `${clean.slice(0, 100)}…` : clean;
+}
+
+/** Read a header as a single string (node gives arrays for some headers). */
+function readHeader(req: http.IncomingMessage, name: string): string | undefined {
+  const value = req.headers[name];
+  if (value === undefined) return undefined;
+  return Array.isArray(value) ? value[0] : value;
+}
+
+/** Percent-decode for display; the raw value is used for anything security-relevant. */
+function safeDecode(value: string): string {
+  try {
+    return decodeURIComponent(value);
+  } catch {
+    return value;
+  }
+}
+
+/**
+ * Bind the first free port at or after `port`, on loopback only.
+ *
+ * Only `EADDRINUSE` advances to the next port — a permission failure or a bad
+ * address will not get better one port over, and retrying twenty times would
+ * only bury the real error.
+ */
+async function listenWithFallback(
+  server: http.Server,
+  opts: { port: number; fallback: boolean; attempts: number }
+): Promise<number> {
+  // Port 0 means "any free port", so there is nothing to fall back from.
+  const attempts = opts.port === 0 || !opts.fallback ? 1 : Math.max(1, opts.attempts);
+
+  for (let i = 0; i < attempts; i++) {
+    const candidate = opts.port === 0 ? 0 : opts.port + i;
+    try {
+      await listenOnce(server, candidate);
+      const address = server.address();
+      if (address === null || typeof address === 'string') {
+        throw new Error('The UI server bound to an unexpected address.');
+      }
+      return address.port;
+    } catch (err) {
+      const code = (err as NodeJS.ErrnoException).code;
+      if (code !== 'EADDRINUSE' || i === attempts - 1) {
+        throw describeBindFailure(err, candidate, opts);
+      }
+    }
+  }
+  /* istanbul ignore next — the loop either returns or throws */
+  throw new Error('The UI server could not bind a port.');
+}
+
+/**
+ * One `listen()` attempt, with both outcomes as a promise.
+ *
+ * The same `http.Server` is reused across attempts: a `listen()` that failed
+ * with EADDRINUSE never took a handle, so it can be listened on again directly
+ * (verified on Node 20 and 22 — `server.listening` is still `false` afterwards,
+ * and `close()` on a never-listening server would itself throw).
+ */
+function listenOnce(server: http.Server, port: number): Promise<void> {
+  return new Promise<void>((resolve, reject) => {
+    const onError = (err: Error): void => {
+      server.removeListener('listening', onListening);
+      reject(err);
+    };
+    const onListening = (): void => {
+      server.removeListener('error', onError);
+      resolve();
+    };
+    server.once('error', onError);
+    server.once('listening', onListening);
+    server.listen(port, LOOPBACK_ADDRESS);
+  });
+}
+
+/** Turn a bind failure into something a user can act on. */
+function describeBindFailure(
+  err: unknown,
+  port: number,
+  opts: { port: number; fallback: boolean; attempts: number }
+): Error {
+  const code = (err as NodeJS.ErrnoException).code;
+  if (code === 'EADDRINUSE') {
+    return opts.fallback
+      ? new Error(
+          `Ports ${opts.port}–${port} are all in use. Free one, or pick another with --port.`
+        )
+      : new Error(`Port ${port} is already in use. Pick another with --port, or omit --port to let CodeGraph find a free one.`);
+  }
+  if (code === 'EACCES') {
+    return new Error(`Not allowed to listen on port ${port}. Ports below 1024 usually need elevated privileges — pick a higher one with --port.`);
+  }
+  return err instanceof Error ? err : new Error(String(err));
+}

+ 74 - 0
src/ui-server/open-browser.ts

@@ -0,0 +1,74 @@
+/**
+ * Opening the user's browser at the viewer URL.
+ *
+ * No dependency: the three platform openers are one-liners, and pulling in a
+ * package to shell out to `open` would be the only runtime dependency the
+ * viewer adds to a CLI that currently has ten.
+ */
+
+import { spawn } from 'child_process';
+import { BROWSER_ENV } from './constants';
+
+export { BROWSER_ENV };
+
+const SUPPRESS_VALUES: ReadonlySet<string> = new Set(['', 'none', '0', 'false', 'off']);
+
+export interface OpenCommand {
+  command: string;
+  args: string[];
+}
+
+/**
+ * The command that would open `url`, or `null` when opening is suppressed.
+ *
+ * Split out from {@link openBrowser} so the platform mapping is testable
+ * without launching anything.
+ */
+export function browserOpenCommand(
+  url: string,
+  platform: NodeJS.Platform,
+  override?: string
+): OpenCommand | null {
+  if (override !== undefined) {
+    const trimmed = override.trim();
+    if (SUPPRESS_VALUES.has(trimmed.toLowerCase())) return null;
+    return { command: trimmed, args: [url] };
+  }
+  if (platform === 'darwin') return { command: 'open', args: [url] };
+  if (platform === 'win32') {
+    // `start` is a cmd builtin, not an executable. The empty string is the
+    // window title — without it `start` treats a quoted URL as the title and
+    // opens a blank console instead.
+    return { command: 'cmd', args: ['/c', 'start', '', url] };
+  }
+  return { command: 'xdg-open', args: [url] };
+}
+
+/**
+ * Open `url` in the user's default browser, best effort.
+ *
+ * Never throws and never keeps the CLI alive: the child is detached and
+ * unref'd, and a missing opener (a headless Linux box with no `xdg-open`) is
+ * swallowed — the URL is already printed, which is the part that matters.
+ *
+ * @returns `true` if a launch was attempted.
+ */
+export function openBrowser(url: string, platform: NodeJS.Platform = process.platform): boolean {
+  const open = browserOpenCommand(url, platform, process.env[BROWSER_ENV]);
+  if (!open) return false;
+  try {
+    const child = spawn(open.command, open.args, {
+      detached: true,
+      stdio: 'ignore',
+      // `start` is a shell builtin reached through `cmd /c`, so no shell here.
+      shell: false,
+    });
+    child.on('error', () => {
+      /* no opener installed — the printed URL is the fallback */
+    });
+    child.unref();
+    return true;
+  } catch {
+    return false;
+  }
+}

+ 238 - 0
src/ui-server/security.ts

@@ -0,0 +1,238 @@
+/**
+ * The `codegraph ui` server's security boundary.
+ *
+ * Threat model, stated plainly: this process serves a browser-readable view of
+ * the user's SOURCE CODE from a port on their machine. It binds loopback, so
+ * nothing on the network can reach it. That leaves one realistic attack —
+ * **DNS rebinding**: any page the user visits can point `evil.example` at
+ * `127.0.0.1` and then have the browser issue same-origin requests to us. The
+ * browser will happily connect; the only thing that distinguishes the attacker's
+ * request from the viewer's own is the `Host` header, which the browser fills in
+ * from the URL and script cannot forge.
+ *
+ * So the rules are:
+ *
+ * - **`Host` must be a loopback name** (`localhost`, `127.0.0.1`, `[::1]`) and,
+ *   if it carries a port, that port must be ours. Anything else is 403.
+ * - **`Origin`, when present, must be loopback too.** Belt and braces: absent on
+ *   the viewer's own same-origin GETs, and present-and-foreign only on a
+ *   cross-site request we want nothing to do with.
+ * - **No CORS headers, ever.** Not adding `Access-Control-Allow-Origin` is what
+ *   keeps a cross-origin reader from seeing a response body even if it does
+ *   reach us. There is deliberately no way to turn this on.
+ * - **GET/HEAD only.** The viewer is a reader; nothing it serves has a side
+ *   effect, so there is no state for a forged request to change.
+ * - **Every path resolves through {@link validatePathWithinRoot}** — the same
+ *   chokepoint the MCP read sinks use, which catches `../` traversal AND
+ *   in-tree symlinks pointing out of the root (#527).
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { PathRefusalError } from '../errors';
+import { validatePathWithinRoot, validateProjectPath } from '../utils';
+
+export { PathRefusalError };
+
+/**
+ * Host names that mean "this machine". A browser only ever sends the bracketed
+ * form for IPv6, but the raw form is accepted after brackets are stripped.
+ */
+const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '::1']);
+
+/** HTTP methods the viewer server answers. Everything else is 405. */
+export const ALLOWED_METHODS: readonly string[] = ['GET', 'HEAD'];
+
+interface HostParts {
+  hostname: string;
+  /** `undefined` when the header carried no `:port` suffix. */
+  port: number | undefined;
+}
+
+/**
+ * Split a `Host` header into hostname and port, or `null` if it is malformed.
+ *
+ * An unbracketed IPv6 literal (`::1`) is malformed per RFC 7230 and is rejected
+ * rather than guessed at — no browser produces one, so accepting it would only
+ * widen the parser for an attacker's benefit.
+ */
+function splitHostPort(host: string): HostParts | null {
+  const trimmed = host.trim();
+  if (!trimmed) return null;
+
+  if (trimmed.startsWith('[')) {
+    const end = trimmed.indexOf(']');
+    if (end < 0) return null;
+    const port = parsePortSuffix(trimmed.slice(end + 1));
+    if (port === null) return null;
+    return { hostname: trimmed.slice(1, end), port };
+  }
+
+  const colon = trimmed.indexOf(':');
+  if (colon === -1) return { hostname: trimmed, port: undefined };
+  // A second colon without brackets is a bare IPv6 literal or junk.
+  if (trimmed.indexOf(':', colon + 1) !== -1) return null;
+  const port = parsePortSuffix(trimmed.slice(colon));
+  if (port === null) return null;
+  return { hostname: trimmed.slice(0, colon), port };
+}
+
+/**
+ * Parse the `:1234` tail of a `Host` header.
+ *
+ * @returns the port, `undefined` for an empty suffix, or `null` when the suffix
+ *   is present but not a plain port number.
+ */
+function parsePortSuffix(suffix: string): number | undefined | null {
+  if (suffix === '') return undefined;
+  if (!suffix.startsWith(':')) return null;
+  const digits = suffix.slice(1);
+  if (!/^\d{1,5}$/.test(digits)) return null;
+  const port = Number(digits);
+  return port >= 0 && port <= 65535 ? port : null;
+}
+
+/**
+ * Whether a request's `Host` header names this loopback server.
+ *
+ * A missing `Host` is rejected: HTTP/1.1 requires it, and the one client that
+ * may legally omit it (HTTP/1.0) is not a browser we need to serve.
+ */
+export function isAllowedHost(host: string | undefined, port: number): boolean {
+  if (typeof host !== 'string') return false;
+  const parts = splitHostPort(host);
+  if (!parts) return false;
+  if (!LOOPBACK_HOSTNAMES.has(parts.hostname.toLowerCase())) return false;
+  return parts.port === undefined || parts.port === port;
+}
+
+/**
+ * Whether a request's `Origin` header is acceptable.
+ *
+ * An ABSENT `Origin` is allowed — browsers omit it on same-origin GETs, which
+ * is every request the viewer makes. A present one must be loopback-on-our-port;
+ * the literal `null` origin (sandboxed iframe, `file://` page) is refused.
+ */
+export function isAllowedOrigin(origin: string | undefined, port: number): boolean {
+  if (origin === undefined) return true;
+  const trimmed = origin.trim();
+  if (trimmed === '') return true;
+  if (trimmed === 'null') return false;
+
+  let url: URL;
+  try {
+    url = new URL(trimmed);
+  } catch {
+    return false;
+  }
+  if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
+  // WHATWG keeps IPv6 hostnames bracketed; the allowlist stores them bare.
+  const hostname = url.hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
+  if (!LOOPBACK_HOSTNAMES.has(hostname)) return false;
+  return url.port === '' || Number(url.port) === port;
+}
+
+/**
+ * Whether a raw request path is worth resolving at all.
+ *
+ * Rejects any `..` segment outright rather than letting containment sort it
+ * out later. Containment WOULD catch it — but the SPA fallback sits behind
+ * containment, so `GET /../../etc/passwd` would otherwise be answered with the
+ * app shell (a 200) instead of the 404 a traversal attempt deserves. Nothing
+ * outside the root leaks either way; this just stops the server from
+ * pretending a hostile path was an ordinary route.
+ *
+ * Takes the RAW path from `req.url`, before WHATWG URL parsing folds `..`
+ * segments away — that folding is what would hide the attempt.
+ */
+export function isSafeRequestPath(rawPath: string): boolean {
+  const decoded = decodePath(rawPath);
+  if (decoded === null) return false;
+  return !decoded.split('/').includes('..');
+}
+
+/**
+ * Resolve a request path to a file inside the static asset root.
+ *
+ * Returns the absolute path, or `null` for anything that is not a readable file
+ * inside `rootDir` — a traversal attempt, a symlink escape, a directory, a
+ * missing file. Callers turn `null` into a 404 (never a 403): telling a prober
+ * which of those it hit is free information.
+ *
+ * Percent-decoding happens HERE, before containment is checked, so an encoded
+ * `..%2f` is caught by the same guard as a literal `../`.
+ */
+export function resolveStaticAsset(rootDir: string, urlPath: string): string | null {
+  const decoded = decodePath(urlPath);
+  if (decoded === null) return null;
+
+  const relative = decoded.replace(/^\/+/, '');
+  const absolute = validatePathWithinRoot(rootDir, relative);
+  if (!absolute) return null;
+
+  try {
+    return fs.statSync(absolute).isFile() ? absolute : null;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Percent-decode a URL path and reject the encodings that only ever show up in
+ * an attack: NUL (truncates a path in some syscalls), other C0 control bytes,
+ * and backslashes (a separator on Windows, a legal filename character on POSIX
+ * — treating it as a separator everywhere is the safe direction, and no built
+ * asset name contains one).
+ *
+ * @returns the decoded path, or `null` if it is unusable.
+ */
+function decodePath(urlPath: string): string | null {
+  let decoded: string;
+  try {
+    decoded = decodeURIComponent(urlPath);
+  } catch {
+    return null; // malformed percent-encoding
+  }
+  // eslint-disable-next-line no-control-regex -- rejecting raw control bytes IS the point
+  if (/[\x00-\x1f\x7f\\]/.test(decoded)) return null;
+  return decoded;
+}
+
+/**
+ * Resolve a project-relative source path to an absolute path that is safe to
+ * read and hand to the browser.
+ *
+ * This is the single read chokepoint for anything served OUT OF THE USER'S
+ * REPOSITORY (as opposed to the viewer's own bundled assets). The JSON API
+ * built on top of this server must route every file read through it — that is
+ * what keeps `/api/source?path=../../.ssh/id_rsa` from being a credential leak
+ * over a port the user opened to read their own code.
+ *
+ * @throws {PathRefusalError} when the root is a sensitive system directory, or
+ *   the path escapes the root by traversal or symlink.
+ */
+export function resolveProjectFile(projectRoot: string, relativePath: string): string {
+  if (typeof relativePath !== 'string' || relativePath.trim() === '') {
+    throw new PathRefusalError('No file path was given.');
+  }
+  const decoded = decodePath(relativePath);
+  if (decoded === null) {
+    throw new PathRefusalError(`Refusing to read an unusable path: ${relativePath}`);
+  }
+
+  // Sensitive-directory refusal, same list the MCP entry points use. Checked on
+  // the ROOT rather than the leaf: a root of `/etc` makes every path under it
+  // sensitive, and a leaf check would have to enumerate the world.
+  const rootError = validateProjectPath(projectRoot);
+  if (rootError) throw new PathRefusalError(rootError);
+
+  if (path.isAbsolute(decoded)) {
+    throw new PathRefusalError(`Refusing to read an absolute path: ${decoded}`);
+  }
+
+  const absolute = validatePathWithinRoot(projectRoot, decoded);
+  if (!absolute) {
+    throw new PathRefusalError(`Refusing to read a path outside the project: ${decoded}`);
+  }
+  return absolute;
+}

+ 138 - 0
src/ui-server/static.ts

@@ -0,0 +1,138 @@
+/**
+ * Static file serving for the viewer's own bundle (`dist/viewer/`).
+ *
+ * Deliberately small: a MIME table, a stream, and the SPA fallback. Everything
+ * that decides WHETHER a path may be read lives in `security.ts`.
+ */
+
+import * as fs from 'fs';
+import type { ServerResponse } from 'http';
+import * as path from 'path';
+
+/**
+ * Content types for everything the Vite build emits, plus the handful of things
+ * a future viewer asset might be. Unknown extensions fall back to
+ * `application/octet-stream`, which — with `X-Content-Type-Options: nosniff` —
+ * a browser will download rather than execute.
+ */
+const CONTENT_TYPES: Readonly<Record<string, string>> = {
+  '.html': 'text/html; charset=utf-8',
+  '.js': 'text/javascript; charset=utf-8',
+  '.mjs': 'text/javascript; charset=utf-8',
+  '.css': 'text/css; charset=utf-8',
+  '.json': 'application/json; charset=utf-8',
+  '.map': 'application/json; charset=utf-8',
+  '.txt': 'text/plain; charset=utf-8',
+  '.svg': 'image/svg+xml',
+  '.png': 'image/png',
+  '.jpg': 'image/jpeg',
+  '.jpeg': 'image/jpeg',
+  '.gif': 'image/gif',
+  '.webp': 'image/webp',
+  '.avif': 'image/avif',
+  '.ico': 'image/x-icon',
+  '.woff': 'font/woff',
+  '.woff2': 'font/woff2',
+  '.ttf': 'font/ttf',
+  '.otf': 'font/otf',
+  '.wasm': 'application/wasm',
+};
+
+/** The content type to send for a file, by extension. */
+export function contentTypeFor(filePath: string): string {
+  return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
+}
+
+/**
+ * Cache policy.
+ *
+ * Vite content-hashes everything under `assets/`, so those are immutable for a
+ * year — a reload of the viewer refetches nothing, and an upgraded CodeGraph
+ * changes the hash and therefore the URL. `index.html` names those hashes, so
+ * it must never be cached.
+ */
+export function cacheControlFor(relativePath: string): string {
+  const normalized = relativePath.split(path.sep).join('/');
+  return normalized.startsWith('assets/')
+    ? 'public, max-age=31536000, immutable'
+    : 'no-store';
+}
+
+/**
+ * Stream a file as the response body.
+ *
+ * `HEAD` gets identical headers and no body — it is a GET whose body the client
+ * asked us to skip, which keeps it read-only by construction.
+ */
+export function sendFile(
+  res: ServerResponse,
+  absolutePath: string,
+  options: { rootDir: string; method: string; extraHeaders?: Record<string, string> }
+): void {
+  let stats: fs.Stats;
+  try {
+    stats = fs.statSync(absolutePath);
+  } catch {
+    sendText(res, 404, 'Not found', options.method);
+    return;
+  }
+
+  const relative = path.relative(options.rootDir, absolutePath);
+  res.writeHead(200, {
+    'Content-Type': contentTypeFor(absolutePath),
+    'Content-Length': String(stats.size),
+    'Cache-Control': cacheControlFor(relative),
+    'Last-Modified': stats.mtime.toUTCString(),
+    ...options.extraHeaders,
+  });
+
+  if (options.method === 'HEAD') {
+    res.end();
+    return;
+  }
+
+  const stream = fs.createReadStream(absolutePath);
+  stream.on('error', () => {
+    // Headers are already out, so there is no status left to change: drop the
+    // connection so the client sees a truncated body rather than a silent lie.
+    res.destroy();
+  });
+  res.on('close', () => stream.destroy());
+  stream.pipe(res);
+}
+
+/** Send a plain-text status response (the error path for a browser or curl). */
+export function sendText(res: ServerResponse, status: number, message: string, method: string): void {
+  const body = Buffer.from(message.endsWith('\n') ? message : `${message}\n`, 'utf-8');
+  res.writeHead(status, {
+    'Content-Type': 'text/plain; charset=utf-8',
+    'Content-Length': String(body.byteLength),
+    'Cache-Control': 'no-store',
+  });
+  res.end(method === 'HEAD' ? undefined : body);
+}
+
+/** Send a JSON response. Used for `/api/*`, which must never get HTML back. */
+export function sendJson(res: ServerResponse, status: number, payload: unknown, method: string): void {
+  const body = Buffer.from(JSON.stringify(payload), 'utf-8');
+  res.writeHead(status, {
+    'Content-Type': 'application/json; charset=utf-8',
+    'Content-Length': String(body.byteLength),
+    'Cache-Control': 'no-store',
+  });
+  res.end(method === 'HEAD' ? undefined : body);
+}
+
+/**
+ * Whether a request path should fall back to `index.html` when no file matches.
+ *
+ * The viewer is hash-routed (`/#/s/<id>`), so in practice only `/` is ever
+ * requested — but a bookmarked or hand-typed `/anything` should still open the
+ * app rather than a 404 page. A path that names a FILE (has an extension) never
+ * falls back: answering `/assets/index-abc123.js` with HTML would hand the
+ * browser a script that is not a script, and hide a genuinely missing asset
+ * behind a page that looks like it loaded.
+ */
+export function shouldFallBackToIndex(pathname: string): boolean {
+  return path.extname(pathname) === '';
+}