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 @@
[](https://github.com/thymeed/agent-workshop/actions/workflows/ci.yml)
[](LICENSE)
-[](CHANGELOG.md)
+[](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/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/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": {
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"