Skip to content

Commit 93152df

Browse files
ndemiancclaude
andcommitted
fix(sessions): address review — verbatim export text + redact the filename
Two review findings on #65: - toMarkdown trimmed each turn's text, which changes Markdown semantics: a leading 4-space indent (indented code block) got de-indented and a trailing " " (hard line break) was eaten. Turn text is now emitted verbatim aside from redaction — the `**role**` blank line already supplies the blank an indented block needs. Pinned by a new test (indented code + trailing hard-break survive). - The default save-dialog filename was derived from the RAW entry.title, not the scrubbed copy toMarkdown makes — and the filesystem sanitiser only strips slashes/colons, so a credential's own characters survive it (ghp_ABC… → ghp-abc…). entry.title now goes through redactSecrets before the stem, so a token in a title can't leak into the save path. Pinned by a source invariant. 32 suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 49ab023 commit 93152df

4 files changed

Lines changed: 24 additions & 5 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -939,10 +939,12 @@ async function exportSession(id) {
939939
'Copied ' + turns + ' turn' + (turns === 1 ? '' : 's') + ' as Markdown.', 'Save as file…');
940940
if (pick !== 'Save as file…') { return; }
941941

942-
// Derive a filename from the title so a folder of exports stays readable. The title has already
943-
// been through redactSecrets above, but it is sanitised again here for the FILESYSTEM's sake —
944-
// a slash or a colon in a title is a path, not a name.
945-
const stem = String(entry.title || 'session').toLowerCase()
942+
// Derive a filename from the title so a folder of exports stays readable. REDACT it first: the exported
943+
// body was scrubbed, but `entry.title` here is the raw index title (toMarkdown redacts its own copy, not
944+
// this variable), and the filesystem sanitiser below only strips slashes/colons — a credential's own
945+
// characters survive it (`ghp_ABC…` → `ghp-abc…`), so a token in a title would leak into the save path.
946+
const safeTitle = sessionMemory.redactSecrets(String(entry.title || 'session'));
947+
const stem = safeTitle.toLowerCase()
946948
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'session';
947949
const target = await vscode.window.showSaveDialog({
948950
filters: { Markdown: ['md'] },

extensions/levelcode-ai/sessionEvents.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,10 @@ function toMarkdown(meta, messages, opts) {
166166

167167
for (const t of turns) {
168168
md += '\n---\n\n**' + (t.role === 'user' ? 'You' : 'LevelCode') + '**\n\n';
169-
md += scrub(String(t.text)).trim() + '\n';
169+
// Verbatim aside from redaction — NOT trimmed. Trimming changes Markdown semantics: it de-indents a
170+
// leading 4-space (indented code block) and eats trailing " " (a hard line break). The `**role**\n\n`
171+
// above already supplies the blank line an indented block needs after it.
172+
md += scrub(String(t.text)) + '\n';
170173
}
171174
// An empty session still exports — a file with a header and no turns is a truthful answer, and
172175
// silently producing nothing would read as a broken button.

extensions/levelcode-ai/test/sessionEvents.test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ test('EXPORT: roles are BOLD, never headings — a turn owns the heading levels'
138138
assert.match(md, /^## Step one$/m, "the turn's own headings are untouched");
139139
});
140140

141+
test('EXPORT: turn text is VERBATIM — indented code + a trailing hard-break survive (not trimmed)', () => {
142+
// Trimming the turn would de-indent a leading 4-space (killing an indented code block) and eat the two
143+
// trailing spaces of a Markdown hard line break. Export must preserve the text as written, save redaction.
144+
const turn = ' indented = code()\n\nfinal line ends with a hard break ';
145+
const md = E.toMarkdown(META, [{ role: 'assistant', content: turn }]);
146+
assert.match(md, /\*\*LevelCode\*\*\n\n {4}indented = code\(\)/, 'the leading 4-space indent survives (indented code block)');
147+
assert.match(md, /hard break {2}\n/, 'the trailing two-space hard line break survives');
148+
});
149+
141150
test('EXPORT: scrubs, because export is the first surface that SHARES a session', () => {
142151
// chat-sessions-design §10: "anything that later shares a session must scrub — that is that
143152
// feature's burden." A token pasted into chat must not ride into a pull request.

extensions/levelcode-ai/test/sessionsUi.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,9 @@ test('NO-JUMP: line 2 swaps file·time ⇄ actions in a fixed min-height row (no
258258
}
259259
});
260260

261+
test('EXPORT: the body is scrubbed on the way out, and the save-dialog filename is redacted too', () => {
262+
assert.match(ext, /toMarkdown\(entry, m\.transcript\(id\), \{ redact: sessionMemory\.redactSecrets \}\)/, 'the exported body is scrubbed');
263+
assert.match(ext, /redactSecrets\(String\(entry\.title[\s\S]{0,140}stem/, 'and the default filename is redacted BEFORE the save path (a title token cannot leak into the dialog)');
264+
});
265+
261266
console.log('sessionsUi: ' + n + ' tests passed');

0 commit comments

Comments
 (0)