From b4190b1827579b867d8a409f83b345d2c97b237d Mon Sep 17 00:00:00 2001 From: Thymeed <326794365+thymeed@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:52:56 +0000 Subject: [PATCH 1/2] fix(security): harden bridge, Electron, event log, and exports - Add strict CSP on dashboard HTML from the bridge. - Deny window.open and restrict navigation to loopback in Electron. - Verify bridgeId on /api/status before loading a reused port. - Atomically replace event log on prune/clear; test failed-write preservation. - Escape status wrapper class names in formatStatusHtml. - Clarify CSV TOTAL excludes explorer/researcher rows. Co-authored-by: Thymeed --- dashboard/bill-math.js | 2 +- dashboard/cards.js | 2 +- electron/main.js | 72 +++++++++++++++++++++++++++++++++++- lib/bridge-http.js | 16 +++++++- lib/event-log.js | 39 +++++++++++++++---- live-bridge.js | 6 ++- test/bridge-guards.test.js | 13 +++++++ test/dashboard-units.test.js | 2 +- test/event-log.test.js | 30 +++++++++++++++ test/unit/bill-math.test.js | 4 +- 10 files changed, 169 insertions(+), 17 deletions(-) diff --git a/dashboard/bill-math.js b/dashboard/bill-math.js index 1ecfba7..1e2dd60 100644 --- a/dashboard/bill-math.js +++ b/dashboard/bill-math.js @@ -143,7 +143,7 @@ }); lines.push( [ - "TOTAL", + "TOTAL (excl. explorer/researcher; in Haiku)", summary.totals.inputTokens, summary.totals.outputTokens, summary.totals.estimatedCostUsd diff --git a/dashboard/cards.js b/dashboard/cards.js index 42012ce..011d28a 100644 --- a/dashboard/cards.js +++ b/dashboard/cards.js @@ -305,7 +305,7 @@ } var plain = prettyStatusText(inner.replace(/<[^>]+>/g, " ")); var safe = escapeHtmlText(plain); - if (cls) return '' + safe + ""; + if (cls) return '' + safe + ""; return safe; } diff --git a/electron/main.js b/electron/main.js index 838a12a..19c632e 100644 --- a/electron/main.js +++ b/electron/main.js @@ -6,9 +6,12 @@ */ "use strict"; -const { app, BrowserWindow, Notification } = require("electron"); +const http = require("http"); +const { app, BrowserWindow, Notification, dialog } = require("electron"); const path = require("path"); -const { startBridge, stopBridge } = require(path.join(__dirname, "..", "live-bridge.js")); +const { startBridge, stopBridge, BRIDGE_ID } = require( + path.join(__dirname, "..", "live-bridge.js") +); const PORT = Number(process.env.CURSOR_BRIDGE_PORT || 3847); const WINDOW_TITLE = "Cursor Agents · Model Routing"; @@ -50,6 +53,51 @@ function showAgentAlert(alert) { } } +function isAllowedNavigationUrl(urlString, port) { + try { + const u = new URL(urlString); + if (u.protocol !== "http:") return false; + const host = u.hostname.replace(/^\[|\]$/g, ""); + if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") return false; + const urlPort = u.port || "80"; + return urlPort === String(port); + } catch (_e) { + return false; + } +} + +function probeBridgeIdentity(port) { + return new Promise((resolve) => { + const req = http.get( + { + host: "127.0.0.1", + port, + path: "/api/status", + timeout: 2000 + }, + (res) => { + let body = ""; + res.on("data", (c) => { + body += c; + }); + res.on("end", () => { + try { + const j = JSON.parse(body); + resolve(j && j.ok === true && j.bridgeId === BRIDGE_ID ? j : null); + } catch (_e) { + resolve(null); + } + }); + } + ); + req.on("timeout", () => { + req.destroy(); + resolve(null); + }); + req.on("error", () => resolve(null)); + }); +} + async function bootBridge() { // Start the bridge in-process: quiet, no console chatter, no spawned cmd/node. const result = await startBridge({ @@ -64,6 +112,18 @@ async function bootBridge() { async function createWindow() { const bridge = await bootBridge(); + const identity = await probeBridgeIdentity(bridge.port); + if (!identity) { + dialog.showErrorBox( + WINDOW_TITLE, + `Could not verify the model-routing bridge on port ${bridge.port}. ` + + "Another app may be using that port, or the bridge failed to start. " + + "Close other listeners on this port and restart the app." + ); + await shutdown(); + app.quit(); + return; + } mainWindow = new BrowserWindow({ width: 1180, @@ -82,6 +142,14 @@ async function createWindow() { }); mainWindow.setMenuBarVisibility(false); + + mainWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + mainWindow.webContents.on("will-navigate", (event, url) => { + if (!isAllowedNavigationUrl(url, bridge.port)) { + event.preventDefault(); + } + }); + mainWindow.on("page-title-updated", (e) => { e.preventDefault(); mainWindow.setTitle(WINDOW_TITLE); diff --git a/lib/bridge-http.js b/lib/bridge-http.js index 054c790..3af7cdd 100644 --- a/lib/bridge-http.js +++ b/lib/bridge-http.js @@ -7,6 +7,18 @@ const path = require("path"); const DASHBOARD = path.join(__dirname, "..", "model-routing-dashboard.html"); const STATIC_ROOT = path.join(__dirname, "..", "dashboard"); +/** Tight CSP for the dashboard shell (external scripts; inline styles from JS/SVG). */ +const DASHBOARD_CSP = + "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "connect-src 'self'; " + + "img-src 'self' data:; " + + "font-src 'self'; " + + "object-src 'none'; " + + "base-uri 'self'; " + + "frame-ancestors 'none'"; + const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", @@ -140,7 +152,8 @@ function serveDashboard(res) { } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store" + "Cache-Control": "no-store", + "Content-Security-Policy": DASHBOARD_CSP }); res.end(data); }); @@ -198,6 +211,7 @@ function serveStatic(urlPath, res) { module.exports = { DASHBOARD, STATIC_ROOT, + DASHBOARD_CSP, MIME, sendJson, readRequestBody, diff --git a/lib/event-log.js b/lib/event-log.js index b2dfdef..6e20018 100644 --- a/lib/event-log.js +++ b/lib/event-log.js @@ -204,20 +204,43 @@ function secureLogFile(filePath) { } } +function replaceLogFile(filePath, data) { + ensureParentDir(filePath); + const dir = path.dirname(filePath); + const tmpPath = path.join( + dir, + "." + path.basename(filePath) + "." + process.pid + "." + Date.now() + ".tmp" + ); + try { + if (usePosixLogPermissions()) { + fs.writeFileSync(tmpPath, data, { encoding: "utf8", mode: LOG_FILE_MODE }); + } else { + fs.writeFileSync(tmpPath, data, "utf8"); + } + fs.renameSync(tmpPath, filePath); + secureLogFile(filePath); + } catch (e) { + try { + if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); + } catch (_unlink) { + /* ignore */ + } + throw e; + } +} + function writeLogBytes(filePath, data, append) { ensureParentDir(filePath); - if (usePosixLogPermissions()) { - if (append) { + if (append) { + if (usePosixLogPermissions()) { fs.appendFileSync(filePath, data, { encoding: "utf8", mode: LOG_FILE_MODE }); + secureLogFile(filePath); } else { - fs.writeFileSync(filePath, data, { encoding: "utf8", mode: LOG_FILE_MODE }); + fs.appendFileSync(filePath, data, "utf8"); } - secureLogFile(filePath); - } else if (append) { - fs.appendFileSync(filePath, data, "utf8"); - } else { - fs.writeFileSync(filePath, data, "utf8"); + return; } + replaceLogFile(filePath, data); } /** diff --git a/live-bridge.js b/live-bridge.js index 7c90be3..48eea9d 100644 --- a/live-bridge.js +++ b/live-bridge.js @@ -132,6 +132,8 @@ let currentIngestFile = ""; let currentIngestLine = 0; let PORT = 3847; +/** Identity token for /api/status; Electron refuses foreign listeners on the bridge port. */ +const BRIDGE_ID = "cursor-agents-model-routing-v1"; /** @type {string} */ let TRANSCRIPTS = emptyTranscripts(); /** @type {string|null} */ @@ -597,6 +599,7 @@ function selectProject(id) { function statusPayload() { return { ok: true, + bridgeId: BRIDGE_ID, port: PORT, transcripts: TRANSCRIPTS, projectId: currentProjectId, @@ -1554,7 +1557,7 @@ function probeExistingBridge(port) { res.on("end", () => { try { const j = JSON.parse(body); - resolve(j && j.ok === true ? j : null); + resolve(j && j.ok === true && j.bridgeId === BRIDGE_ID ? j : null); } catch (_) { resolve(null); } @@ -1781,6 +1784,7 @@ function clearHistory() { } module.exports = { + BRIDGE_ID, startBridge, stopBridge, discoverProjects, diff --git a/test/bridge-guards.test.js b/test/bridge-guards.test.js index 04fb2c3..b2b88f0 100644 --- a/test/bridge-guards.test.js +++ b/test/bridge-guards.test.js @@ -156,6 +156,19 @@ async function testStaticAndCors(port) { const home = await request(port, { path: "/" }); assert.strictEqual(home.status, 200, `GET /: expected 200, got ${home.status}`); assert.ok(/text\/html/.test(home.headers["content-type"]), "GET / must be text/html"); + const csp = home.headers["content-security-policy"] || ""; + assert.ok( + csp.includes("script-src 'self'"), + "GET / must send a strict CSP with script-src 'self'" + ); + assert.ok( + csp.includes("style-src 'self' 'unsafe-inline'"), + "GET / CSP must allow inline styles for the dashboard" + ); + + const status = await request(port, { path: "/api/status" }); + const statusJson = JSON.parse(status.body); + assert.ok(statusJson.bridgeId, "status must include bridgeId for port identity checks"); const css = await request(port, { path: "/dashboard/styles.css" }); assert.strictEqual(css.status, 200, `GET stylesheet: expected 200, got ${css.status}`); diff --git a/test/dashboard-units.test.js b/test/dashboard-units.test.js index 3b34ae8..524873a 100644 --- a/test/dashboard-units.test.js +++ b/test/dashboard-units.test.js @@ -258,7 +258,7 @@ async function main() { "CSV header must be stable" ); assert.ok( - csvLines.some((l) => l.startsWith("TOTAL,")), + csvLines.some((l) => l.startsWith("TOTAL (excl. explorer/researcher; in Haiku),")), "CSV must carry a TOTAL row" ); assert.strictEqual(csvLines[1].split(",").length, 4, "CSV data rows keep four columns"); diff --git a/test/event-log.test.js b/test/event-log.test.js index 28c4f7e..7751ee6 100644 --- a/test/event-log.test.js +++ b/test/event-log.test.js @@ -293,6 +293,35 @@ function testContractShape() { if (errors.length) throw new Error("contract failed:\n- " + errors.join("\n- ")); } +function testReplacePreservesLogOnWriteFailure() { + const dir = tmpDir("atomic-fail"); + const log = createEventLog({ logDir: dir }); + log.append(sampleEvent({ agentId: "keep-me" })); + const p = log.getLogPath(); + const before = fs.readFileSync(p, "utf8"); + assert(before.includes("keep-me"), "seed event present"); + + const origWrite = fs.writeFileSync; + fs.writeFileSync = function (file, ...args) { + if (String(file).includes(".tmp")) { + throw new Error("simulated atomic write failure"); + } + return origWrite.call(fs, file, ...args); + }; + try { + let threw = false; + try { + log.clear(); + } catch (_e) { + threw = true; + } + assert(threw, "clear must fail when the temp write fails"); + assert(fs.readFileSync(p, "utf8") === before, "original log must survive a failed replace"); + } finally { + fs.writeFileSync = origWrite; + } +} + function testRetentionAndClear() { const dir = tmpDir("retain"); const log = createEventLog({ logDir: dir, retentionMs: 1000 }); @@ -474,6 +503,7 @@ async function main() { testRedactionSignedUrls(); testPemFullBlockRedaction(); testContractShape(); + testReplacePreservesLogOnWriteFailure(); testRetentionAndClear(); testExistingLogDirChmodOnStart(); testFilePermissions(); diff --git a/test/unit/bill-math.test.js b/test/unit/bill-math.test.js index 0b23cee..be75f0c 100644 --- a/test/unit/bill-math.test.js +++ b/test/unit/bill-math.test.js @@ -203,7 +203,7 @@ test("summaryToCsv round-trips a built summary", () => { const lines = M.summaryToCsv(s).trim().split("\n"); assert.equal(lines[0], "model,inputTokens,outputTokens,estimatedCostUsd"); assert.equal(lines[1], "opus,0,0,0"); - const total = lines.find((l) => l.startsWith("TOTAL,")); + const total = lines.find((l) => l.startsWith("TOTAL (excl. explorer/researcher; in Haiku),")); assert.ok(total, "TOTAL row present"); assert.equal(total.split(",")[1], "250000"); assert.ok( @@ -235,7 +235,7 @@ test("summaryToCsv emits a stable header, one row per model, a TOTAL, and the no assert.equal(lines[0], "model,inputTokens,outputTokens,estimatedCostUsd"); assert.equal(lines[1], "opus,10,20,0.00044"); assert.equal(lines[2], "haiku,1,2,0.0000011"); - assert.equal(lines[3], "TOTAL,11,22,0.0004411"); + assert.equal(lines[3], "TOTAL (excl. explorer/researcher; in Haiku),11,22,0.0004411"); assert.ok( lines.some((l) => l.startsWith("note,")), "note row must be present" From daf9eaf694351f68f8a3ee84337e0ae50bc31c0d Mon Sep 17 00:00:00 2001 From: Thymeed <326794365+thymeed@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:53:08 +0000 Subject: [PATCH 2/2] chore(release): bump version to 1.4.1 Co-authored-by: Thymeed --- CHANGELOG.md | 24 +++++++++++++++++++++++- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f919e..edfb705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.1] - 2026-10-16 + +Hardening-only patch. No new features or dependency changes. + +### Security + +- **Strict CSP on bridge HTML.** Dashboard shell responses send `Content-Security-Policy` with + `script-src 'self'` and `style-src 'self' 'unsafe-inline'` so inline styles from JS/SVG still work. +- **Electron navigation lockdown.** `setWindowOpenHandler` denies popups; `will-navigate` allows only + the app’s loopback bridge URL. +- **Port 3847 identity.** `/api/status` exposes `bridgeId`; Electron verifies it before loading when + reusing an existing listener, and the bridge refuses foreign `ok` responses without the id. +- **Atomic event-log replace.** Prune and clear write a temp file in the log directory and rename over + the JSONL instead of truncating in place. +- **Status class escaping.** `formatStatusHtml` escapes wrapper class names via `SafeHtml`. + +### Changed + +- **CSV TOTAL label.** The TOTAL row notes that explorer and researcher lines are excluded because their + tokens are already counted in Haiku (numbers unchanged). + ## [1.4.0] - 2026-10-16 Correctness pass on the Live path. The bridge was reading a transcript shape Cursor does not @@ -262,7 +283,8 @@ audio fix; see Fixed. - Token and session cost figures are illustrative estimates, not official Cursor billing. -[Unreleased]: https://github.com/thymeed/agent-workshop/compare/v1.4.0...HEAD +[Unreleased]: https://github.com/thymeed/agent-workshop/compare/v1.4.1...HEAD +[1.4.1]: https://github.com/thymeed/agent-workshop/compare/v1.4.0...v1.4.1 [1.4.0]: https://github.com/thymeed/agent-workshop/compare/v1.3.1...v1.4.0 [1.3.1]: https://github.com/thymeed/agent-workshop/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/thymeed/agent-workshop/compare/v1.2.0...v1.3.0 diff --git a/README.md b/README.md index d6db0bc..32afe92 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/thymeed/agent-workshop/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/thymeed/agent-workshop/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-1.4.0-informational)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.1-informational)](CHANGELOG.md) Desktop dashboard that shows how a Cursor Agents session routes work across Opus (parent), Fable (advisor), Worker (Sonnet), and Explorer / Researcher (Haiku). diff --git a/package-lock.json b/package-lock.json index 2556532..efdfca5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-workshop", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-workshop", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/package.json b/package.json index edd4c43..9e276b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-workshop", - "version": "1.4.0", + "version": "1.4.1", "description": "Cursor Agents · Model Routing. Electron desktop dashboard with in-process live bridge", "main": "electron/main.js", "author": {