diff --git a/BUILDLOG.md b/BUILDLOG.md index bc6e240..c1326e2 100644 --- a/BUILDLOG.md +++ b/BUILDLOG.md @@ -619,3 +619,18 @@ the whole table on startup is wrong - `EXPLAIN QUERY PLAN` shows `SEARCH events USING INDEX idx_events_seq`, because the index is created before the backfill runs. And the uncommitted layout change it flagged was committed before the review was read. + +## 2026-09-07 - The writer that escaped the net + +The reviewer came back and found one: dsh writes a session title through its own +inline `UPDATE`, which the sweep for call sites missed because it was not a +named prepared statement. Inert on the append path, where the title lands before +the session row exists, but the truncation reset in `poll()` re-reads a whole +file against an existing session and would put the raw title back over the +masked one. + +Fixing that one line would have left the same trap for the next adapter, so +titles now have a single named writer, `renameSession` in emit.ts, which masks. +Claude Code's own overwrite moved onto it too. Grepping `UPDATE sessions SET +title` across the sources returns emit.ts and nothing else, which is the +property worth having: an adapter cannot reach that column directly. diff --git a/package-lock.json b/package-lock.json index 18c8e62..9a0711c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3533,7 +3533,7 @@ }, "packages/server": { "name": "@gengwg/agentlens", - "version": "0.12.4", + "version": "0.12.5", "license": "MIT", "dependencies": { "@hono/node-server": "^1.14.0", diff --git a/packages/server/package.json b/packages/server/package.json index b085827..2834457 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -24,7 +24,7 @@ "tsx": "^4.20.0", "typescript": "^5.9.0" }, - "version": "0.12.4", + "version": "0.12.5", "description": "Local observability for coding-agent harnesses: fleet view and trace viewer for Claude Code, OpenCode, dsh, Cursor Agent, Antigravity, TrueForge and more.", "license": "MIT", "repository": { diff --git a/packages/server/src/sources/claude-code.ts b/packages/server/src/sources/claude-code.ts index 98efba3..a514e89 100644 --- a/packages/server/src/sources/claude-code.ts +++ b/packages/server/src/sources/claude-code.ts @@ -2,9 +2,9 @@ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, rea import { userInfo } from "node:os"; import { basename, join } from "node:path"; import { db, getCursor, insertEvent, setCursor, turnAt, upsertSession, upsertTurn } from "../db.js"; -import { textOf, usageOf } from "./emit.js"; -import type { Source } from "./types.js"; import { maskText } from "../redact-secrets.js"; +import { renameSession, textOf, usageOf } from "./emit.js"; +import type { Source } from "./types.js"; // Claude Code writes ~/.claude/projects//.jsonl, one JSON // record per line, append-only. Subagent transcripts live next to it under @@ -56,7 +56,6 @@ export function readNewLines(path: string, offset: number): { lines: string[]; o const sessionExists = db.prepare(`SELECT 1 FROM sessions WHERE id = ?`); const touchSession = db.prepare(`UPDATE sessions SET updated_at = ? WHERE id = ? AND updated_at < ?`); -const setTitle = db.prepare(`UPDATE sessions SET title = ? WHERE id = ?`); const closeTurn = db.prepare( `UPDATE turns SET status = CASE WHEN status = 'error' THEN 'error' ELSE ? END, completed_at = ? WHERE id = ?`, ); @@ -121,7 +120,7 @@ export function ingestRecords(ctx: Ctx, records: any[], state: FileState) { if (r.type === "ai-title" || r.type === "custom-title") { state.title = r.aiTitle ?? r.customTitle ?? state.title; - if (state.title && sessionExists.get(ctx.sessionId)) setTitle.run(maskText(state.title), ctx.sessionId); + if (state.title && sessionExists.get(ctx.sessionId)) renameSession(ctx.sessionId, state.title); continue; } diff --git a/packages/server/src/sources/dsh.ts b/packages/server/src/sources/dsh.ts index bcb647d..45b961b 100644 --- a/packages/server/src/sources/dsh.ts +++ b/packages/server/src/sources/dsh.ts @@ -2,7 +2,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { basename, join } from "node:path"; import { zstdDecompressSync } from "node:zlib"; import { db, getCursor, setCursor } from "../db.js"; -import { MAX_TOOL_OUTPUT, closeTurn, ensureSession, iso, openTurn, putEvent, textOf, touch } from "./emit.js"; +import { MAX_TOOL_OUTPUT, closeTurn, ensureSession, iso, openTurn, putEvent, renameSession, textOf, touch } from "./emit.js"; import type { Source } from "./types.js"; // dsh writes ~/.dsh/sessions//session-/session.jsonl.zstd: @@ -47,7 +47,7 @@ export function ingestRecords(sessionId: string, records: any[], state: FileStat state.created_at = at ?? undefined; break; case "session/title": - if (d.title) db.prepare(`UPDATE sessions SET title = ? WHERE id = ?`).run(d.title, sessionId); + if (d.title) renameSession(sessionId, d.title); break; case "turn/start": state.turn_id = eid(`t${d.turn}`); diff --git a/packages/server/src/sources/emit.ts b/packages/server/src/sources/emit.ts index c9ca47c..7d24016 100644 --- a/packages/server/src/sources/emit.ts +++ b/packages/server/src/sources/emit.ts @@ -10,6 +10,7 @@ export const MAX_TOOL_OUTPUT = 64 * 1024; const sessionExists = db.prepare(`SELECT 1 FROM sessions WHERE id = ?`); const setTitle = db.prepare(`UPDATE sessions SET title = ? WHERE id = ? AND (title IS NULL OR title = '')`); const setBranch = db.prepare(`UPDATE sessions SET branch = ? WHERE id = ? AND branch IS NULL`); +const renameStmt = db.prepare(`UPDATE sessions SET title = ? WHERE id = ?`); const touchStmt = db.prepare(`UPDATE sessions SET updated_at = ? WHERE id = ? AND updated_at < ?`); const openTurnStmt = db.prepare( `INSERT OR IGNORE INTO turns (id, session_id, created_at, status, ingested) VALUES (?, ?, ?, 'running', 1)`, @@ -55,6 +56,10 @@ export function ensureSession(s: { }); } +// For a harness that names a session after it has started (dsh). Masked like +// every other title: no adapter should write that column itself. +export const renameSession = (sessionId: string, title: string) => renameStmt.run(maskText(title), sessionId); + export const touch = (sessionId: string, at: string) => touchStmt.run(at, sessionId, at); export const openTurn = (id: string, sessionId: string, at: string) => openTurnStmt.run(id, sessionId, at); export const closeTurn = (id: string, status: string, at: string | null, error: string | null = null) => diff --git a/packages/server/test/redact-secrets.test.ts b/packages/server/test/redact-secrets.test.ts index b06dff3..b584b1d 100644 --- a/packages/server/test/redact-secrets.test.ts +++ b/packages/server/test/redact-secrets.test.ts @@ -5,6 +5,7 @@ import { db, insertEvent, seedSession, sessionTrace, upsertEvent } from "./fixtu const { redactSecrets } = await import("../src/redact-secrets.ts"); const { upsertSession, upsertTurn } = await import("../src/db.ts"); +const { renameSession } = await import("../src/sources/emit.ts"); // Fake credentials are assembled at runtime rather than written out. They are // invented, but they match real formats by design, and a literal in the source @@ -135,3 +136,13 @@ test("a turn error carrying a key is masked", () => { assert.ok(row.error.includes("[REDACTED:openai-project-key]")); assert.ok(row.error.startsWith("auth failed for"), "the rest of the message survives"); }); + +test("a harness that renames a session cannot smuggle a secret past the mask", () => { + // dsh names a session after it starts, overwriting the title. That write goes + // through renameSession now rather than an adapter's own UPDATE. + seedSession("r-rename", { source: "dsh" }); + renameSession("r-rename", `debug ${fake.awsKey} in staging`); + const row = db.prepare(`SELECT title FROM sessions WHERE id = ?`).get("r-rename") as { title: string }; + assert.ok(!row.title.includes(fake.awsKey)); + assert.ok(row.title.includes("[REDACTED:aws-access-token]") && row.title.includes("in staging")); +});