Zion Boggan zionboggan.com ↗

Escape untrusted content in Markdown output (prevent HTML injection)

Prompt text, titles, the project name, session titles, failure summaries,
and lessons are HTML-escaped before they are written into Markdown
artifacts, so a crafted prompt or package.json name cannot inject
<script>, break out of the details widget, or smuggle event handlers into
a committed report. The reusable prompt pack now uses a dynamic-length code
fence so embedded backticks cannot break out. Release 0.2.3.
d1df111   Zion Boggan committed on Jun 12, 2026 (1 week ago)
package.json +1 -1
@@ -1,6 +1,6 @@
{
"name": "treetrace",
- "version": "0.2.2",
+ "version": "0.2.3",
"description": "Turn AI coding sessions into regression-ready prompt lineage, failure analysis, eval cases, and handoff memory.",
"keywords": [
"claude-code",
src/analyze.js +9 -9
@@ -1,4 +1,4 @@
-import { truncate } from './util.js';
+import { truncate, escapeMd } from './util.js';
const FAILURE_TYPES = new Set([
'ignored_constraint',
@@ -217,9 +217,9 @@ export function renderLessonsMarkdown(tree, opts = {}) {
return lines.join('\n');
}
analysis.lessons.forEach((lesson, i) => {
- lines.push(`## ${i + 1}. ${lesson.title}`);
+ lines.push(`## ${i + 1}. ${escapeMd(lesson.title)}`);
lines.push('');
- lines.push(lesson.text);
+ lines.push(escapeMd(lesson.text));
lines.push('');
lines.push(`Source nodes: ${lesson.nodeIds.join(', ')}`);
lines.push('');
@@ -237,13 +237,13 @@ export function renderMemoryMarkdown(tree, opts = {}) {
const projectName = opts.projectName || 'this project';
const nodes = tree.nodes || [];
const live = (n) => n.status !== 'abandoned';
- const lines = [`# TreeTrace Agent Memory`, '', `Project: ${projectName}`, ''];
+ const lines = [`# TreeTrace Agent Memory`, '', `Project: ${escapeMd(projectName)}`, ''];
lines.push('## Constraints the user enforced');
lines.push('');
const constraints = nodes.filter((n) => live(n) && (n.kind === 'correction' || n.kind === 'scope-change'));
if (constraints.length) {
- for (const n of constraints.slice(0, 8)) lines.push(`- ${truncate(n.title, 140)}`);
+ for (const n of constraints.slice(0, 8)) lines.push(`- ${escapeMd(truncate(n.title, 140))}`);
} else {
lines.push('- No explicit constraints were flagged. Follow the accepted decisions in the handoff brief.');
}
@@ -252,7 +252,7 @@ export function renderMemoryMarkdown(tree, opts = {}) {
lines.push('## Lessons from this lineage');
lines.push('');
if (analysis.lessons.length) {
- for (const lesson of analysis.lessons.slice(0, 8)) lines.push(`- ${lesson.text}`);
+ for (const lesson of analysis.lessons.slice(0, 8)) lines.push(`- ${escapeMd(lesson.text)}`);
} else {
lines.push('- No high-confidence failure lessons were detected yet.');
}
@@ -262,7 +262,7 @@ export function renderMemoryMarkdown(tree, opts = {}) {
lines.push('');
const badPaths = analysis.failures.filter((f) => f.type === 'abandoned_path').slice(0, 6);
if (badPaths.length) {
- for (const failure of badPaths) lines.push(`- ${failure.summary}`);
+ for (const failure of badPaths) lines.push(`- ${escapeMd(failure.summary)}`);
} else {
lines.push('- No abandoned paths were detected in this session.');
}
@@ -272,9 +272,9 @@ export function renderMemoryMarkdown(tree, opts = {}) {
lines.push('');
const accepted = nodes.filter((n) => live(n) && (n.kind === 'root' || n.kind === 'direction' || n.kind === 'scope-change'));
const latest = accepted[accepted.length - 1];
- if (latest) lines.push(`- Continue the most recent accepted direction: ${truncate(latest.title, 140)}`);
+ if (latest) lines.push(`- Continue the most recent accepted direction: ${escapeMd(truncate(latest.title, 140))}`);
const openCorrections = nodes.filter((n) => live(n) && n.kind === 'correction').slice(-3);
- for (const n of openCorrections) lines.push(`- Keep this correction satisfied: ${truncate(n.title, 120)}`);
+ for (const n of openCorrections) lines.push(`- Keep this correction satisfied: ${escapeMd(truncate(n.title, 120))}`);
if (!latest && !openCorrections.length) {
lines.push('- Continue from the accepted decisions above and confirm scope with the user.');
}
src/handoff.js +9 -9
@@ -1,4 +1,4 @@
-import { truncate } from './util.js';
+import { truncate, escapeMd } from './util.js';
import { analyzeTree } from './analyze.js';
export function renderHandoff(tree, opts = {}) {
@@ -12,7 +12,7 @@ export function renderHandoff(tree, opts = {}) {
const lastCheckpoint = [...accepted].reverse().find((n) => n.kind === 'checkpoint');
const lastAccepted = accepted.at(-1);
- lines.push(`# Handoff brief: ${projectName}`);
+ lines.push(`# Handoff brief: ${escapeMd(projectName)}`);
lines.push('');
lines.push(
`You are taking over an AI-assisted project. This brief was distilled from the real prompt lineage (${stats.promptCount} prompts, ${stats.sessionCount} sessions). Read it fully before acting.`
@@ -22,18 +22,18 @@ export function renderHandoff(tree, opts = {}) {
if (root) {
lines.push('## Original goal');
lines.push('');
- lines.push(root.text.trim());
+ lines.push(escapeMd(root.text.trim()));
lines.push('');
}
lines.push('## Where things stand');
lines.push('');
if (lastCheckpoint) {
- lines.push(`Last checkpoint: ${lastCheckpoint.text.trim()}`);
+ lines.push(`Last checkpoint: ${escapeMd(lastCheckpoint.text.trim())}`);
}
if (lastAccepted && lastAccepted !== lastCheckpoint) {
lines.push('');
- lines.push(`Most recent accepted direction: ${lastAccepted.text.trim()}`);
+ lines.push(`Most recent accepted direction: ${escapeMd(lastAccepted.text.trim())}`);
}
lines.push('');
@@ -41,7 +41,7 @@ export function renderHandoff(tree, opts = {}) {
if (decisions.length) {
lines.push('## Accepted decisions (in order)');
lines.push('');
- decisions.forEach((n, i) => lines.push(`${i + 1}. ${truncate(n.text.replace(/\s+/g, ' '), 360)}`));
+ decisions.forEach((n, i) => lines.push(`${i + 1}. ${escapeMd(truncate(n.text.replace(/\s+/g, ' '), 360))}`));
lines.push('');
}
@@ -51,7 +51,7 @@ export function renderHandoff(tree, opts = {}) {
lines.push('');
lines.push('These corrections were issued during the build. Do not repeat the mistakes they fixed:');
lines.push('');
- corrections.forEach((n) => lines.push(`- ${truncate(n.text.replace(/\s+/g, ' '), 300)}`));
+ corrections.forEach((n) => lines.push(`- ${escapeMd(truncate(n.text.replace(/\s+/g, ' '), 300))}`));
lines.push('');
}
@@ -63,7 +63,7 @@ export function renderHandoff(tree, opts = {}) {
lines.push('');
lines.push('These approaches were tried and abandoned. Avoid unless told otherwise:');
lines.push('');
- abandoned.forEach((n) => lines.push(`- ${truncate(n.text.replace(/\s+/g, ' '), 300)}`));
+ abandoned.forEach((n) => lines.push(`- ${escapeMd(truncate(n.text.replace(/\s+/g, ' '), 300))}`));
lines.push('');
}
@@ -71,7 +71,7 @@ export function renderHandoff(tree, opts = {}) {
lines.push('## Agent memory lessons');
lines.push('');
analysis.lessons.slice(0, 6).forEach((lesson) => {
- lines.push(`- ${truncate(lesson.text.replace(/\s+/g, ' '), 320)}`);
+ lines.push(`- ${escapeMd(truncate(lesson.text.replace(/\s+/g, ' '), 320))}`);
});
lines.push('');
}
src/render-md.js +23 -11
@@ -1,4 +1,4 @@
-import { truncate, plural, formatDay, mdEscapePipe } from './util.js';
+import { truncate, plural, formatDay, mdEscapePipe, escapeMd } from './util.js';
import { REPO_URL } from './config.js';
const ICONS = {
@@ -17,7 +17,7 @@ export function renderMarkdown(tree, opts = {}) {
const { stats, roots, nodes, sessions } = tree;
const lines = [];
- lines.push(`# ๐ŸŒณ Prompt Tree: ${projectName}`);
+ lines.push(`# ๐ŸŒณ Prompt Tree: ${escapeMd(projectName)}`);
lines.push('');
lines.push(`> ${banner(stats)}`);
lines.push('>');
@@ -52,7 +52,7 @@ export function renderMarkdown(tree, opts = {}) {
active.forEach((s, i) => {
lines.push(
`| ${i + 1} | ${formatDay(s.firstTs) || ''} | ${s.prompts.length} | ${mdEscapePipe(
- s.title || s.sessionId || ''
+ escapeMd(s.title || s.sessionId || '')
)} |`
);
});
@@ -70,7 +70,7 @@ export function renderMarkdown(tree, opts = {}) {
lines.push(`**${plural(abandoned.length, 'abandoned branch', 'abandoned branches')}:**`);
lines.push('');
for (const n of abandoned) {
- lines.push(`- โœ— ${truncate(n.title, 110)}`);
+ lines.push(`- โœ— ${escapeMd(truncate(n.title, 110))}`);
}
lines.push('');
}
@@ -78,7 +78,7 @@ export function renderMarkdown(tree, opts = {}) {
lines.push(`**${plural(corrections.length, 'correction')} along the way:**`);
lines.push('');
for (const n of corrections) {
- lines.push(`- โ†ฉ ${truncate(n.title, 110)}`);
+ lines.push(`- โ†ฉ ${escapeMd(truncate(n.title, 110))}`);
}
lines.push('');
}
@@ -90,9 +90,11 @@ export function renderMarkdown(tree, opts = {}) {
'A distilled, replayable version of the accepted path. Paste into a fresh agent to rebuild something like this:'
);
lines.push('');
- lines.push('```text');
- lines.push(promptPack(nodes));
- lines.push('```');
+ const pack = promptPack(nodes);
+ const fence = '`'.repeat(Math.max(3, longestRun(pack, '`') + 1));
+ lines.push(`${fence}text`);
+ lines.push(pack);
+ lines.push(fence);
lines.push('');
lines.push('---');
@@ -140,7 +142,8 @@ function emitNode(node, depth, lines, { titlesOnly }) {
const indent = ' '.repeat(depth);
const icon = ICONS[node.kind] || 'โ†’';
const dead = node.status === 'abandoned';
- const title = dead ? `~~${node.title}~~ โœ—` : node.kind === 'root' ? `**${node.title}**` : node.title;
+ const safe = escapeMd(node.title);
+ const title = dead ? `~~${safe}~~ โœ—` : node.kind === 'root' ? `**${safe}**` : safe;
const session = node.sessionBoundary ? ` ${dim(`(new session${node.ts ? `, ${formatDay(node.ts)}` : ''})`)}` : '';
const nudges = node.nudges > 1 ? ` ${dim(`(+${node.nudges} nudges)`)}` : '';
const reruns = node.reruns ? ` ${dim(`(re-issued ร—${node.reruns + 1})`)}` : '';
@@ -167,8 +170,8 @@ function blockquote(text, indent = '') {
}
function clip(text, max) {
- if (text.length <= max) return text;
- return `${text.slice(0, max).trimEnd()}\n\n*[...trimmed, ${text.length - max} more chars]*`;
+ if (text.length <= max) return escapeMd(text);
+ return `${escapeMd(text.slice(0, max).trimEnd())}\n\n*[...trimmed, ${text.length - max} more chars]*`;
}
export function promptPack(nodes) {
@@ -192,3 +195,12 @@ export function promptPack(nodes) {
function condense(text, max = 420) {
return truncate(text.replace(/\s+/g, ' '), max);
}
+
+function longestRun(text, ch) {
+ let max = 0;
+ let cur = 0;
+ for (const c of text) {
+ if (c === ch) { cur++; if (cur > max) max = cur; } else cur = 0;
+ }
+ return max;
+}
src/report.js +3 -3
@@ -1,7 +1,7 @@
import { analyzeTree, renderLessonsMarkdown, renderMemoryMarkdown } from './analyze.js';
import { renderHandoff } from './handoff.js';
import { renderMarkdown } from './render-md.js';
-import { plural, truncate } from './util.js';
+import { plural, truncate, escapeMd } from './util.js';
import { REPO_URL } from './config.js';
export function renderReportMarkdown(tree, opts = {}) {
@@ -10,7 +10,7 @@ export function renderReportMarkdown(tree, opts = {}) {
const analysis = analyzeTree(tree);
const lines = [];
- lines.push(`# TreeTrace Report - ${projectName}`);
+ lines.push(`# TreeTrace Report - ${escapeMd(projectName)}`);
lines.push('');
lines.push(`Generated: ${generatedAt}`);
lines.push('');
@@ -63,7 +63,7 @@ export function renderReportMarkdown(tree, opts = {}) {
}
lines.push('');
for (const failure of analysis.failures.slice(0, 8)) {
- lines.push(`- ${failure.id} (${failure.type}, ${confidencePct(failure.confidence)}): ${failure.summary}`);
+ lines.push(`- ${failure.id} (${failure.type}, ${confidencePct(failure.confidence)}): ${escapeMd(failure.summary)}`);
}
if (analysis.failures.length > 8) {
lines.push(`- ... ${analysis.failures.length - 8} more in .treetrace/failures.json`);
src/util.js +7 -0
@@ -72,3 +72,10 @@ export function shannonEntropy(s) {
export function mdEscapePipe(s) {
return String(s).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
}
+
+export function escapeMd(text) {
+ return String(text == null ? '' : text)
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+}
test/treetrace.test.js +12 -1
@@ -22,7 +22,7 @@ import {
} from '../src/analyze.js';
import { main } from '../src/cli.js';
import { mungePath } from '../src/discover.js';
-import { sha256 } from '../src/util.js';
+import { sha256, escapeMd } from '../src/util.js';
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'synthetic-session.jsonl');
@@ -158,6 +158,17 @@ test('redaction: benign text produces no high/medium findings', () => {
assert.deepEqual(hard, []);
});
+test('escapeMd neutralizes HTML-sensitive characters', () => {
+ assert.equal(escapeMd('a<script>b</script>&c>'), 'a&lt;script&gt;b&lt;/script&gt;&amp;c&gt;');
+});
+
+test('rendering escapes injection in project name and content', async () => {
+ const { tree } = await fixtureTree();
+ const md = renderMarkdown(tree, { projectName: 'x</summary></details><script>alert(1)</script>' });
+ assert.ok(md.includes('# ๐ŸŒณ Prompt Tree: x&lt;/summary&gt;&lt;/details&gt;&lt;script&gt;'), 'project name not escaped');
+ assert.ok(!md.includes('Prompt Tree: x</summary>'), 'raw HTML in project name');
+});
+
test('renderers: markdown, json, handoff are consistent and footer-credited', async () => {
const { tree } = await fixtureTree();
analyzeTree(tree);