Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions BUILDLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
7 changes: 3 additions & 4 deletions packages/server/src/sources/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<encoded-cwd>/<session>.jsonl, one JSON
// record per line, append-only. Subagent transcripts live next to it under
Expand Down Expand Up @@ -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 = ?`,
);
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/sources/dsh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<encoded-cwd>/session-<id>/session.jsonl.zstd:
Expand Down Expand Up @@ -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}`);
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/sources/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`,
Expand Down Expand Up @@ -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) =>
Expand Down
11 changes: 11 additions & 0 deletions packages/server/test/redact-secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"));
});