From aca5865f2d3e538ea0edc8e47cbd7694fd9323d6 Mon Sep 17 00:00:00 2001 From: Weigang Geng <3356786+gengwg@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:01:45 -0700 Subject: [PATCH] Give session titles a single masked writer The reviewer found a writer the sweep missed: dsh set a title through its own inline UPDATE, so it never picked up maskText. Inert on the append path, but the truncation reset in poll() re-reads a whole file against an existing session and would write the raw title back over the masked one. Patching the one line would leave the trap for the next adapter, so titles have a single named writer now - renameSession in emit.ts, which masks - and Claude Code's overwrite moved onto it too. Grepping for the UPDATE returns emit.ts and nothing else. Also drops a db.prepare that ran inside the poll loop per title record. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZDHk4yVYP4jMZkTUMQ1Lw --- BUILDLOG.md | 15 +++++++++++++++ package-lock.json | 2 +- packages/server/package.json | 2 +- packages/server/src/sources/claude-code.ts | 7 +++---- packages/server/src/sources/dsh.ts | 4 ++-- packages/server/src/sources/emit.ts | 5 +++++ packages/server/test/redact-secrets.test.ts | 11 +++++++++++ 7 files changed, 38 insertions(+), 8 deletions(-) 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")); +});