Zion Boggan zionboggan.com ↗

Release 0.4.1: fix redaction hex-secret leak and tighten security-signal precision

Driven by an adversarial end-to-end test pass across every adapter on real sessions.

- Redact bare hexadecimal secrets of 32 or more characters, which previously could reach a written artifact even with automatic redaction
- Bound the entropy scan so a single very large token cannot overflow the regex stack and abort the run
- Require word boundaries and a corroborating signal before a security flag verifies, so benign long flags and files named like tokens no longer mint high confidence credential signals
- Record the real source tool in tree.json instead of always claude-code-jsonl
- Pick the latest accepted direction chronologically in the handoff brief
- Honor --from with --stdin, write both analysis and reports together, guard adapters against malformed JSON, stamp the version in markdown footers, stop escaping command evidence, and tighten Grok detection
- Add TESTING.md documenting the test method and the real-capture versus schema-only coverage per adapter
17a9eda   Zion Boggan committed on Jun 13, 2026 (1 week ago)
CHANGELOG.md +17 -0
@@ -2,6 +2,23 @@
Notable changes to TreeTrace. The format follows Keep a Changelog, and the project uses semantic versioning.
+## 0.4.1 - 2026-06-13
+
+A fix release driven by an adversarial end-to-end test pass across every adapter on real sessions. See [TESTING.md](TESTING.md) for the method and coverage.
+
+### Fixed
+
+- Security: bare hexadecimal secrets of 32 characters or more, a common shape for framework secret keys and HMAC signing keys, are now redacted. They were previously not detected and could reach a written artifact even with `--redact-auto`. A 40 character git commit hash is also redacted, which is the safe default for a privacy tool. Covered by a regression test and the leak self-test.
+- Redaction no longer aborts on a single very large token. A multi-megabyte pasted blob used to overflow the regex stack and end the run with an internal error. The scan is now bounded.
+- Security signal precision. A benign long flag such as `--force-device-scale-factor` and a user interface file named like `semantic-tokens.ts` no longer mint a high confidence credential signal. A single bare keyword with no corroborating evidence is down tiered below verified. Real credential files and secret-handling commands still verify.
+- `tree.json` records the actual source tool (`codex-rollout`, `chatgpt-export`, `gemini-cli`, `copilot-chat`, `cursor-export`, `grok-cli`, `claude-code-jsonl`, `transcript`) instead of always reporting `claude-code-jsonl`.
+- The handoff brief picks the latest accepted direction chronologically rather than by insertion order, so a multi-session export no longer names a stale topic.
+- `--from` is honored together with `--stdin`.
+- `--analysis` combined with `--report` writes both the analysis files and the reports instead of silently dropping the tree and reports.
+- The Copilot and Cursor adapters fail gracefully on malformed or empty JSON instead of throwing a raw error.
+- Markdown footers stamp the tool version, and command evidence inside code blocks is no longer HTML escaped.
+- Grok format detection requires a Grok specific signal, so a Cursor style export is no longer at risk of being routed to the Grok parser.
+
## 0.4.0 - 2026-06-13
This release rebuilds the analysis layer so the output holds up on a real session, and turns the security positioning into something the tool actually produces.
TESTING.md +49 -0
@@ -0,0 +1,49 @@
+# Testing
+
+TreeTrace ships with a zero-dependency test suite and is put through an adversarial end-to-end pass before each release. This page documents what is actually tested, on what data, and what is not, so you can judge the coverage yourself rather than take a claim on faith.
+
+## Run the suite
+
+```
+node --test test/treetrace.test.js test/adapters.test.js
+```
+
+No dependencies and no network. The suite covers parsing, prompt classification, tree building, the redaction gate, the analysis layer, and every import adapter, including regression tests for each issue listed in the changelog.
+
+## The three invariants
+
+Every release is checked against three properties:
+
+1. Ingestion fidelity. Each tool's real export parses correctly, and format auto-detection selects the right tool and not the others.
+2. Safety. No secret from any source reaches any written artifact. The redaction gate fails closed: outside an interactive terminal every detected secret is redacted automatically, and a shadow scan refuses to write a file if anything unresolved remains.
+3. Analysis honesty. Failure signals, lessons, evals, and security flags must correspond to real events in the session. The tool never invents a failure, and it never asks a model to judge your code. Every signal is a transparent heuristic with evidence and node ids you can check.
+
+## Adversarial pre-release pass
+
+Before a release, TreeTrace is run end to end against a corpus of real sessions, not synthetic fixtures, and probed by a set of adversarial checks:
+
+- A secret-leak self-test injects many credential formats (provider keys, tokens, private keys, basic-auth URLs, and bare high-entropy and hexadecimal strings) into every field of every adapter format, runs the tool, then greps every written artifact. The requirement is zero leaks across every adapter.
+- An auto-detection matrix confirms each real export is recognized as exactly one tool, with no false positives against the other adapters.
+- Fuzz and robustness checks feed truncated, malformed, empty, oversized, and unicode inputs. The tool must fail with a clear message and a non-zero exit, never a stack trace or a partial artifact.
+- Determinism and memory checks confirm repeat runs are identical except for the timestamp, and that a large real session stays within a sane time and memory budget.
+- A ground-truth analysis audit checks the security and failure signals against what actually happened in a known session, looking for both invented signals and missed ones.
+
+## Corpus honesty
+
+What "tested on real data" means for each adapter:
+
+| Source | Tested on |
+| --- | --- |
+| Claude Code | Live captured sessions |
+| Codex CLI | Live captured rollout |
+| ChatGPT export | Real published account export |
+| Gemini CLI | Real published session |
+| Copilot Chat | Real published session |
+| Cursor | Documented export schema only |
+| Grok | Documented export schema only |
+
+Cursor and Grok keep history in a SQLite database rather than a JSON file on disk, so their adapters are validated against the documented export schema, not a captured live session. We say so plainly here and in the adapter notes until that changes.
+
+## Found and fixed
+
+The most recent pre-release pass caught a redaction gap: a bare hexadecimal string of 32 characters or more, a common shape for framework secret keys and signing keys, was not detected and could reach an artifact even with automatic redaction. It is fixed, covered by a regression test, and the leak self-test now passes for every format across every adapter. See [CHANGELOG.md](CHANGELOG.md) for the full list from that pass.
package.json +1 -1
@@ -1,6 +1,6 @@
{
"name": "treetrace",
- "version": "0.4.0",
+ "version": "0.4.1",
"description": "Turn AI coding sessions into regression-ready prompt lineage, failure analysis, eval cases, and handoff memory.",
"keywords": [
"claude-code",
src/adapters/copilot.js +3 -0
@@ -43,6 +43,9 @@ function ingestResponse(session, response, model) {
}
export function parseCopilot(parsed, path, sessionId) {
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Array.isArray(parsed.requests)) {
+ return finalizeSession(newSession(path, sessionId));
+ }
const session = newSession(path, parsed.sessionId || sessionId);
let turn = 0;
for (const req of parsed.requests) {
src/adapters/cursor.js +3 -0
@@ -119,6 +119,9 @@ export function detectCursor(parsed) {
}
export function parseCursor(parsed, path, sessionId) {
+ if (!parsed || typeof parsed !== 'object') {
+ return finalizeSession(newSession(path, sessionId));
+ }
const session = newSession(path, (parsed && parsed.composerId) || (parsed && parsed.sessionId) || sessionId);
if (parsed && parsed.title) session.title = parsed.title;
let turn = 0;
src/adapters/grok.js +10 -1
@@ -12,11 +12,20 @@ function grokMessages(parsed) {
return null;
}
+function hasGrokSignal(parsed) {
+ if (Array.isArray(parsed)) return false;
+ if (typeof parsed.model === 'string' && /^grok/i.test(parsed.model)) return true;
+ if (typeof parsed.tool === 'string' && /grok/i.test(parsed.tool)) return true;
+ if (parsed.grok !== undefined || parsed.xai !== undefined) return true;
+ return false;
+}
+
export function detectGrok(parsed) {
if (!parsed || typeof parsed !== 'object') return false;
const messages = grokMessages(parsed);
if (!Array.isArray(messages) || !messages.length) return false;
- return messages.every((m) => m && typeof m === 'object' && 'role' in m && 'content' in m);
+ if (!messages.every((m) => m && typeof m === 'object' && 'role' in m && 'content' in m)) return false;
+ return hasGrokSignal(parsed);
}
export function parseGrok(parsed, path, sessionId) {
src/analyze.js +39 -11
@@ -78,20 +78,39 @@ const APOLOGY_RE = /\b(?:i'?m sorry|im sorry|sorry|my bad|my fault|oops|whoops)\
const REMEDIATION_RE = new RegExp(`${DESTRUCTIVE_RE.source}|${RECOVERY_RE.source}`, 'i');
const SECURITY_FILE_RE = /(?:^|[\\/])(?:\.env[^\\/]*|[^\\/]*(?:auth|session|middleware|login|signin|signup|permission|rbac|access[-_]?control|secur|crypto|jwt|oauth|passwd|password|secret|credential|token)[^\\/]*)$/i;
-const RISKY_CMD_RE = /(?:\brm\s+-rf\b|\bchmod\s+777\b|curl[^|]*\|\s*(?:sh|bash)|wget[^|]*\|\s*(?:sh|bash)|--no-verify\b|--force\b|\bDROP\s+TABLE\b|\bTRUNCATE\s+TABLE\b)/i;
+const SECURITY_FILE_EXCLUDE_RE = /(?:^|[\\/])(?:[^\\/]*tokens?\.[a-z]+|tokenizer[^\\/]*|[^\\/]*[-_.]?token(?:izer|s)?\.(?:tsx?|jsx?|css|scss|json|svg)|semantic[-_]?tokens?[^\\/]*|design[-_]?tokens?[^\\/]*)$/i;
+const RISKY_CMD_RE = /(?:\brm\s+-rf\b|\bchmod\s+777\b|curl[^|]*\|\s*(?:sh|bash)|wget[^|]*\|\s*(?:sh|bash)|--no-verify\b|--force(?![\w-])|\bDROP\s+TABLE\b|\bTRUNCATE\s+TABLE\b)/i;
const SECRET_CONTENT_RE = /(?:\bsource\s+[^\n]*\.env\b|(?:^|[;&|]|\s)\.\s+[^\n]*\.env\b|\.env\.(?:secrets|local|prod|production)\b|\bexport\s+[A-Z0-9_]*(?:_API_KEY|_TOKEN|_SECRET|_PASSWORD|API_KEY|SECRET_KEY|ACCESS_KEY|PRIVATE_KEY)\b|\b(?:wrangler|doppler|vault)\b|\bgh\s+auth\b|\baws\s+configure\b|\bgcloud\s+auth\b|\bkubectl\s+config\s+set-credentials\b)/i;
-const ACCESS_CONTROL_CONTENT_RE = /\b(?:rbac|access[-_]?control|grant\s+(?:select|insert|update|delete|all)\b|setfacl|chmod\s+[0-7]{3,4}\b)/i;
+const ACCESS_CONTROL_CONTENT_RE = /\b(?:grant\s+(?:select|insert|update|delete|all)\b|setfacl|chmod\s+[0-7]{3,4}\b)/i;
+const ACCESS_CONTROL_WEAK_RE = /\b(?:rbac|access[-_]?control)\b/i;
+
+function isCredentialFile(file) {
+ if (!file || !SECURITY_FILE_RE.test(file)) return false;
+ if (SECURITY_FILE_EXCLUDE_RE.test(file)) return false;
+ return true;
+}
function securityActions(node) {
const out = [];
for (const a of node.actions || []) {
const body = `${a.command || ''} ${a.input || ''}`;
let kind = null;
- if (a.file && SECURITY_FILE_RE.test(a.file)) kind = 'file';
- else if (SECRET_CONTENT_RE.test(body)) kind = 'credential';
- else if (ACCESS_CONTROL_CONTENT_RE.test(body)) kind = 'access-control';
- else if (a.command && RISKY_CMD_RE.test(a.command)) kind = 'risky-command';
- if (kind) out.push({ action: a, kind });
+ let strong = false;
+ if (SECRET_CONTENT_RE.test(body)) {
+ kind = 'credential';
+ strong = true;
+ } else if (a.file && isCredentialFile(a.file)) {
+ kind = 'file';
+ strong = true;
+ } else if (ACCESS_CONTROL_CONTENT_RE.test(body)) {
+ kind = 'access-control';
+ strong = true;
+ } else if (a.command && RISKY_CMD_RE.test(a.command)) {
+ kind = 'risky-command';
+ } else if (ACCESS_CONTROL_WEAK_RE.test(body)) {
+ kind = 'access-control';
+ }
+ if (kind) out.push({ action: a, kind, strong });
}
return out;
}
@@ -254,9 +273,9 @@ export function analyzeTree(tree) {
tree.nodes.forEach((node, index) => {
const secActs = securityActions(node);
if (secActs.length) {
- const hasCredential = secActs.some((s) => s.kind === 'credential' || s.kind === 'access-control' || s.kind === 'file');
- const tier = hasCredential ? 'verified' : 'high';
- const confidence = hasCredential ? 0.95 : 0.84;
+ const hasStrong = secActs.some((s) => s.strong);
+ const tier = hasStrong ? 'verified' : 'high';
+ const confidence = hasStrong ? 0.95 : 0.84;
const targets = uniq(secActs.map((s) => s.action.file || s.action.command || s.action.input)).slice(0, 3);
const kinds = uniq(secActs.map((s) => s.kind));
addFailure({
@@ -476,7 +495,7 @@ export function renderMemoryMarkdown(tree, opts = {}) {
(n.kind === 'root' || n.kind === 'direction' || n.kind === 'scope-change') &&
isStrategicDirection(n)
);
- const latest = strategic[strategic.length - 1];
+ const latest = latestByTime(strategic);
if (latest) {
lines.push(`- Continue the most recent accepted direction: ${escapeMd(truncate(latest.title, 140))}`);
} else {
@@ -491,6 +510,15 @@ export function renderMemoryMarkdown(tree, opts = {}) {
return lines.join('\n');
}
+export function latestByTime(nodes) {
+ if (!nodes || !nodes.length) return null;
+ const timed = nodes.filter((n) => tsOf(n) !== null);
+ if (timed.length) {
+ return timed.reduce((best, n) => (tsOf(n) >= tsOf(best) ? n : best), timed[0]);
+ }
+ return nodes[nodes.length - 1];
+}
+
export function isStrategicDirection(node) {
const text = String(node.text || '').trim();
if (!text) return false;
src/cli.js +45 -12
@@ -20,7 +20,7 @@ import {
import { makeTitle } from './extract.js';
import { c, plural, truncate } from './util.js';
-const VERSION = '0.3.0';
+const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
const HELP = `TreeTrace - turn AI coding sessions into regression-ready prompt lineage
@@ -64,13 +64,25 @@ export async function main(argv) {
const log = opts.quiet ? () => {} : (msg) => process.stderr.write(`${msg}\n`);
let sessions = [];
+ let sourceTool = 'claude';
if (opts.stdin) {
const text = readFileSync(0, 'utf8');
- sessions = [parsePlainTranscript(text)];
+ if (opts.from && opts.from !== 'transcript') {
+ const { sessions: adapted, tool } = ingestText(opts.from, text, 'stdin', log);
+ sessions = adapted;
+ sourceTool = tool;
+ } else {
+ sessions = [parsePlainTranscript(text)];
+ sourceTool = 'transcript';
+ }
} else if (opts.files.length) {
+ const tools = new Set();
for (const file of opts.files) {
- sessions.push(...(await ingestFile(file, opts.from, log)));
+ const { sessions: fileSessions, tool } = await ingestFile(file, opts.from, log);
+ sessions.push(...fileSessions);
+ tools.add(tool);
}
+ sourceTool = tools.size === 1 ? [...tools][0] : 'mixed';
} else {
const found = discoverSessions(projectDir);
const filtered = opts.since
@@ -154,7 +166,7 @@ export async function main(argv) {
analyzeTree(tree);
const generatedAt = new Date().toISOString();
- const renderOpts = { projectName, titlesOnly: opts.titlesOnly, version: VERSION, generatedAt };
+ const renderOpts = { projectName, titlesOnly: opts.titlesOnly, version: VERSION, generatedAt, sourceType: sourceTypeFor(sourceTool) };
if (opts.handoff) {
const pack = renderHandoff(tree, renderOpts);
@@ -173,7 +185,7 @@ export async function main(argv) {
const report = renderReportMarkdown(tree, renderOpts);
const requested = requestedArtifacts(opts, artifacts);
- if (requested.length) {
+ if (requested.length && !opts.report) {
for (const artifact of requested) assertClean(artifact.text, decisions, artifact.label);
mkdirSync(projectDir, { recursive: true });
mkdirSync(ttDir, { recursive: true });
@@ -217,16 +229,37 @@ export async function main(argv) {
if (asked) log(c.dim(` ${plural(asked, 'redaction decision')} saved to .treetrace/redactions.json`));
}
+const SOURCE_TYPE_BY_TOOL = {
+ claude: 'claude-code-jsonl',
+ codex: 'codex-rollout',
+ chatgpt: 'chatgpt-export',
+ gemini: 'gemini-cli',
+ copilot: 'copilot-chat',
+ cursor: 'cursor-export',
+ grok: 'grok-cli',
+ transcript: 'transcript',
+};
+
+function sourceTypeFor(tool) {
+ return SOURCE_TYPE_BY_TOOL[tool] || 'claude-code-jsonl';
+}
+
+function ingestText(from, text, label, log) {
+ const sessions = adaptFrom(from, text, label);
+ log(c.dim(` read ${from} format from ${label}`));
+ return { sessions, tool: from };
+}
+
async function ingestFile(file, from, log) {
if (from && from !== 'claude' && from !== 'transcript') {
const text = readFileSync(file, 'utf8');
- return adaptFrom(from, text, file);
+ return { sessions: adaptFrom(from, text, file), tool: from };
}
if (from === 'claude') {
- return [await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })];
+ return { sessions: [await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })], tool: 'claude' };
}
if (from === 'transcript') {
- return [parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))];
+ return { sessions: [parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))], tool: 'transcript' };
}
if (file.endsWith('.jsonl')) {
@@ -234,9 +267,9 @@ async function ingestFile(file, from, log) {
const adapted = autoAdapt(text, file);
if (adapted && adapted.sessions.some((s) => s.prompts.length)) {
log(c.dim(` detected ${adapted.tool} format in ${basename(file)}`));
- return adapted.sessions;
+ return { sessions: adapted.sessions, tool: adapted.tool };
}
- return [await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })];
+ return { sessions: [await parseSessionFile(file, { sessionId: basename(file, '.jsonl') })], tool: 'claude' };
}
if (file.endsWith('.json')) {
@@ -244,11 +277,11 @@ async function ingestFile(file, from, log) {
const adapted = autoAdapt(text, file);
if (adapted && adapted.sessions.some((s) => s.prompts.length)) {
log(c.dim(` detected ${adapted.tool} format in ${basename(file)}`));
- return adapted.sessions;
+ return { sessions: adapted.sessions, tool: adapted.tool };
}
}
- return [parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))];
+ return { sessions: [parsePlainTranscript(readFileSync(file, 'utf8'), basename(file))], tool: 'transcript' };
}
function analysisArtifacts(ttDir, tree, renderOpts) {
src/handoff.js +12 -12
@@ -1,5 +1,5 @@
-import { truncate, escapeMd } from './util.js';
-import { analyzeTree, isStrategicDirection } from './analyze.js';
+import { truncate, escapeMdTags } from './util.js';
+import { analyzeTree, isStrategicDirection, latestByTime } from './analyze.js';
export function renderHandoff(tree, opts = {}) {
const { projectName } = opts;
@@ -9,10 +9,10 @@ export function renderHandoff(tree, opts = {}) {
const root = nodes.find((n) => n.kind === 'root') || nodes[0];
const accepted = nodes.filter((n) => n.status !== 'abandoned');
- const lastCheckpoint = [...accepted].reverse().find((n) => n.kind === 'checkpoint');
- const lastAccepted = accepted.at(-1);
+ const lastCheckpoint = latestByTime(accepted.filter((n) => n.kind === 'checkpoint'));
+ const lastAccepted = latestByTime(accepted);
- lines.push(`# Handoff brief: ${escapeMd(projectName)}`);
+ lines.push(`# Handoff brief: ${escapeMdTags(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(escapeMd(root.text.trim()));
+ lines.push(escapeMdTags(root.text.trim()));
lines.push('');
}
lines.push('## Where things stand');
lines.push('');
if (lastCheckpoint) {
- lines.push(`Last checkpoint: ${escapeMd(lastCheckpoint.text.trim())}`);
+ lines.push(`Last checkpoint: ${escapeMdTags(lastCheckpoint.text.trim())}`);
}
if (lastAccepted && lastAccepted !== lastCheckpoint) {
lines.push('');
- lines.push(`Most recent accepted direction: ${escapeMd(lastAccepted.text.trim())}`);
+ lines.push(`Most recent accepted direction: ${escapeMdTags(lastAccepted.text.trim())}`);
}
lines.push('');
@@ -43,7 +43,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}. ${escapeMd(truncate(n.text.replace(/\s+/g, ' '), 360))}`));
+ decisions.forEach((n, i) => lines.push(`${i + 1}. ${escapeMdTags(truncate(n.text.replace(/\s+/g, ' '), 360))}`));
lines.push('');
}
@@ -53,7 +53,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(`- ${escapeMd(truncate(n.text.replace(/\s+/g, ' '), 300))}`));
+ corrections.forEach((n) => lines.push(`- ${escapeMdTags(truncate(n.text.replace(/\s+/g, ' '), 300))}`));
lines.push('');
}
@@ -65,7 +65,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(`- ${escapeMd(truncate(n.text.replace(/\s+/g, ' '), 300))}`));
+ abandoned.forEach((n) => lines.push(`- ${escapeMdTags(truncate(n.text.replace(/\s+/g, ' '), 300))}`));
lines.push('');
}
@@ -73,7 +73,7 @@ export function renderHandoff(tree, opts = {}) {
lines.push('## Agent memory lessons');
lines.push('');
analysis.lessons.slice(0, 6).forEach((lesson) => {
- lines.push(`- ${escapeMd(truncate(lesson.text.replace(/\s+/g, ' '), 320))}`);
+ lines.push(`- ${escapeMdTags(truncate(lesson.text.replace(/\s+/g, ' '), 320))}`);
});
lines.push('');
}
src/redact.js +49 -12
@@ -21,6 +21,7 @@ export const RULES = [
{ id: 'discord-webhook', severity: 'high', re: /https:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/g },
{ id: 'jwt', severity: 'high', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g },
+ { id: 'hex-token', severity: 'medium', re: /\b[0-9a-fA-F]{32,512}\b/g },
{ id: 'wireguard-key', severity: 'medium', re: /\b(PrivateKey|PresharedKey)\s*=\s*[A-Za-z0-9+/]{42,44}=?/g },
{ id: 'url-basic-auth', severity: 'medium', re: /\b[a-z][a-z0-9+.-]{0,30}:\/\/[^/\s:@'"`]{2,256}:[^/\s@'"`]{2,256}@[^\s'"`]{1,512}/gi },
{ id: 'bearer-header', severity: 'medium', re: /\bBearer\s+[A-Za-z0-9._+/=-]{20,}\b/g },
@@ -32,7 +33,9 @@ export const RULES = [
];
const HEX_RE = /^[0-9a-fA-F]+$/;
-const ENTROPY_CANDIDATE_RE = /\b[A-Za-z0-9+/_=-]{32,}\b/g;
+const ENTROPY_CANDIDATE_RE = /\b[A-Za-z0-9+/_=-]{32,4096}\b/g;
+const MAX_TOKEN_LEN = 4096;
+const TOKEN_CHAR_RE = /[A-Za-z0-9+/_=-]/;
const VERSION_LIKE_RE = /^\d+[.\d-]*$/;
const JOIN_SEPARATOR_RE = /[\s\u200B-\u200D\uFEFF]/;
const JOINED_SCAN_RULE_IDS = new Set([
@@ -62,12 +65,46 @@ const LOOSE_RULES = RULES.filter((r) => JOINED_SCAN_RULE_IDS.has(r.id)).map((r)
),
}));
+function findOversizedRuns(text) {
+ const runs = [];
+ let start = -1;
+ for (let i = 0; i <= text.length; i++) {
+ const isTok = i < text.length && TOKEN_CHAR_RE.test(text[i]);
+ if (isTok) {
+ if (start === -1) start = i;
+ } else if (start !== -1) {
+ if (i - start > MAX_TOKEN_LEN) runs.push([start, i]);
+ start = -1;
+ }
+ }
+ return runs;
+}
+
export function scanText(text) {
+ const oversized = text.length > MAX_TOKEN_LEN ? findOversizedRuns(text) : [];
+ let scanInput = text;
+ if (oversized.length) {
+ const chars = text.split('');
+ for (const [s, e] of oversized) {
+ for (let i = s; i < e; i++) chars[i] = '\n';
+ }
+ scanInput = chars.join('');
+ }
+
const findings = [];
+ for (const [s, e] of oversized) {
+ findings.push({
+ ruleId: 'oversized-token',
+ severity: 'medium',
+ match: text.slice(s, e),
+ index: s,
+ });
+ }
+
for (const rule of RULES) {
rule.re.lastIndex = 0;
let m;
- while ((m = rule.re.exec(text)) !== null) {
+ while ((m = rule.re.exec(scanInput)) !== null) {
findings.push({
ruleId: rule.id,
severity: rule.severity,
@@ -81,7 +118,7 @@ export function scanText(text) {
const seenSpans = findings.map((f) => [f.index, f.index + f.match.length]);
ENTROPY_CANDIDATE_RE.lastIndex = 0;
let m;
- while ((m = ENTROPY_CANDIDATE_RE.exec(text)) !== null) {
+ while ((m = ENTROPY_CANDIDATE_RE.exec(scanInput)) !== null) {
const tok = m[0];
if (HEX_RE.test(tok) || VERSION_LIKE_RE.test(tok)) continue;
if (!/[A-Z]/.test(tok) || !/[a-z]/.test(tok) || !/[0-9]/.test(tok)) continue;
@@ -91,19 +128,19 @@ export function scanText(text) {
findings.push({ ruleId: 'high-entropy-token', severity: 'medium', match: tok, index: start });
}
- findings.push(...scanJoinedProviderTokens(text, findings));
+ findings.push(...scanJoinedProviderTokens(scanInput, findings, text));
return findings;
}
-function scanJoinedProviderTokens(text, existing) {
+function scanJoinedProviderTokens(scanInput, existing, original = scanInput) {
const chars = [];
const indexMap = [];
- for (let i = 0; i < text.length; i++) {
- if (JOIN_SEPARATOR_RE.test(text[i])) continue;
- chars.push(text[i]);
+ for (let i = 0; i < scanInput.length; i++) {
+ if (JOIN_SEPARATOR_RE.test(scanInput[i])) continue;
+ chars.push(scanInput[i]);
indexMap.push(i);
}
- if (chars.length === text.length) return [];
+ if (chars.length === scanInput.length) return [];
const joined = chars.join('');
const existingSpans = existing.map((f) => [f.index, f.index + f.match.length]);
@@ -115,9 +152,9 @@ function scanJoinedProviderTokens(text, existing) {
if (m[0].length <= 256) {
const start = indexMap[m.index];
const end = indexMap[m.index + m[0].length - 1] + 1;
- const original = text.slice(start, end);
- if (JOIN_SEPARATOR_RE.test(original) && !existingSpans.some(([s, e]) => start >= s && start < e)) {
- findings.push({ ruleId: rule.id, severity: rule.severity, match: original, index: start });
+ const slice = original.slice(start, end);
+ if (JOIN_SEPARATOR_RE.test(slice) && !existingSpans.some(([s, e]) => start >= s && start < e)) {
+ findings.push({ ruleId: rule.id, severity: rule.severity, match: slice, index: start });
}
}
if (m.index === rule.re.lastIndex) rule.re.lastIndex++;
src/render-json.js +2 -2
@@ -11,7 +11,7 @@ const RELATIONSHIP_BY_KIND = {
};
export function renderJson(tree, opts = {}) {
- const { projectName, generatedBy = 'treetrace', version = '0.1.0' } = opts;
+ const { projectName, generatedBy = 'treetrace', version = '0.1.0', sourceType = 'claude-code-jsonl' } = opts;
const { nodes, sessions, stats } = tree;
const analysis = analyzeTree(tree);
@@ -21,7 +21,7 @@ export function renderJson(tree, opts = {}) {
project: {
name: projectName,
generatedAt: opts.generatedAt || null,
- sourceType: 'claude-code-jsonl',
+ sourceType,
},
stats: {
prompts: stats.promptCount,
src/render-md.js +2 -2
@@ -13,7 +13,7 @@ const ICONS = {
const MAX_NODE_TEXT = 1500;
export function renderMarkdown(tree, opts = {}) {
- const { projectName, titlesOnly = false } = opts;
+ const { projectName, titlesOnly = false, version } = opts;
const { stats, roots, nodes, sessions } = tree;
const lines = [];
@@ -111,7 +111,7 @@ export function renderMarkdown(tree, opts = {}) {
lines.push('---');
lines.push('');
lines.push(
- `*Generated by [treetrace](${REPO_URL}) ยท ${plural(stats.promptCount, 'prompt')} across ${plural(
+ `*Generated by [treetrace](${REPO_URL})${version ? ` ยท v${version}` : ''} ยท ${plural(stats.promptCount, 'prompt')} across ${plural(
stats.sessionCount,
'session'
)} ยท machine-readable lineage in \`.treetrace/tree.json\` ([schema](${REPO_URL}/blob/main/SCHEMA.md))*`
src/report.js +3 -3
@@ -1,4 +1,4 @@
-import { analyzeTree, renderLessonsMarkdown, renderMemoryMarkdown } from './analyze.js';
+import { analyzeTree, renderLessonsMarkdown, renderMemoryMarkdown, latestByTime } from './analyze.js';
import { renderHandoff } from './handoff.js';
import { renderMarkdown } from './render-md.js';
import { plural, truncate, escapeMd } from './util.js';
@@ -124,7 +124,7 @@ export function renderReportMarkdown(tree, opts = {}) {
lines.push('---');
lines.push('');
- lines.push(`Generated by [treetrace](${REPO_URL}).`);
+ lines.push(`Generated by [treetrace](${REPO_URL})${opts.version ? ` v${opts.version}` : ''}.`);
lines.push('');
return lines.join('\n');
@@ -134,7 +134,7 @@ export function renderTerminalSummary(tree, opts = {}) {
const projectName = opts.projectName || 'project';
const analysis = analyzeTree(tree);
const accepted = tree.nodes.filter((n) => n.status !== 'abandoned');
- const lastAccepted = accepted.at(-1);
+ const lastAccepted = latestByTime(accepted);
const lines = [];
lines.push(`TreeTrace summary - ${projectName}`);
src/util.js +6 -0
@@ -79,3 +79,9 @@ export function escapeMd(text) {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
+
+export function escapeMdTags(text) {
+ return String(text == null ? '' : text)
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+}
test/adapters.test.js +46 -0
@@ -8,6 +8,11 @@ import { adaptFrom, autoAdapt, TOOLS } from '../src/adapters/index.js';
import { classifyPrompts } from '../src/extract.js';
import { buildTree } from '../src/tree.js';
import { analyzeTree } from '../src/analyze.js';
+import { detectChatGPT } from '../src/adapters/chatgpt.js';
+import { detectCopilot } from '../src/adapters/copilot.js';
+import { detectGeminiJson } from '../src/adapters/gemini.js';
+import { detectCursor } from '../src/adapters/cursor.js';
+import { detectGrok } from '../src/adapters/grok.js';
const DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'adapters');
const fx = (name) => join(DIR, name);
@@ -132,6 +137,47 @@ test('adapter output flows through the full classify/tree pipeline', () => {
}
});
+test('detectors: exactly one JSON detector fires per fixture (no cursor/grok overlap)', () => {
+ const jsonDetectors = {
+ chatgpt: detectChatGPT,
+ copilot: detectCopilot,
+ gemini: detectGeminiJson,
+ cursor: detectCursor,
+ grok: detectGrok,
+ };
+ const expected = {
+ 'chatgpt-conversations.json': 'chatgpt',
+ 'copilot-chatsession.json': 'copilot',
+ 'gemini-session.json': 'gemini',
+ 'cursor-export.json': 'cursor',
+ 'grok-session.json': 'grok',
+ };
+ for (const [name, want] of Object.entries(expected)) {
+ const parsed = JSON.parse(read(name));
+ const fired = Object.entries(jsonDetectors).filter(([, fn]) => fn(parsed)).map(([k]) => k);
+ assert.deepEqual(fired, [want], `${name} should fire exactly [${want}], got [${fired.join(', ')}]`);
+ }
+});
+
+test('detectGrok requires a grok-specific signal, not just role/content messages', () => {
+ const generic = { messages: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }] };
+ assert.equal(detectGrok(generic), false, 'generic role/content dump must not be claimed by grok');
+ assert.equal(detectGrok({ model: 'grok-4', messages: generic.messages }), true, 'a grok model marker should be detected');
+});
+
+test('copilot and cursor adapters handle null/primitive JSON without throwing', () => {
+ for (const bad of ['null', '42', 'true', '"hi"']) {
+ assert.doesNotThrow(() => {
+ const c = adaptFrom('copilot', bad, fx('bad.json'));
+ assert.equal(c[0].prompts.length, 0);
+ }, `copilot threw on ${bad}`);
+ assert.doesNotThrow(() => {
+ const c = adaptFrom('cursor', bad, fx('bad.json'));
+ assert.equal(c[0].prompts.length, 0);
+ }, `cursor threw on ${bad}`);
+ }
+});
+
test('adaptFrom rejects an unknown tool name', () => {
assert.throws(() => adaptFrom('notatool', '{}', 'x.json'), /unknown/);
assert.ok(TOOLS.includes('codex') && TOOLS.includes('cursor'));
test/treetrace.test.js +195 -2
@@ -12,7 +12,7 @@ import { scanText, applyDecisions, shadowScan, maskFor, resolveFindings } from '
import { renderMarkdown, promptPack } from '../src/render-md.js';
import { renderJson } from '../src/render-json.js';
import { renderHandoff } from '../src/handoff.js';
-import { renderReportMarkdown } from '../src/report.js';
+import { renderReportMarkdown, renderTerminalSummary } from '../src/report.js';
import {
analyzeTree,
renderFailuresJson,
@@ -117,6 +117,66 @@ test('redaction: rule coverage on known formats', () => {
}
});
+test('redaction: bare hex tokens (32+ chars) are detected, lower and upper case', async () => {
+ const lower = '6881f8290266f4cc939959917f893a2a88787eb24bbcb6b9c37594c72bf448c3';
+ const upper = lower.toUpperCase();
+ const half = lower.slice(0, 32);
+ for (const hex of [lower, upper, half]) {
+ const hits = scanText(`my key is session_hex=${hex} ok`).map((f) => f.ruleId);
+ assert.ok(hits.includes('hex-token'), `hex-token missed for ${hex} (got ${hits})`);
+ }
+ const findings = scanText(`session_hex=${lower}`);
+ const { decisions } = await resolveFindings(findings, {}, { interactive: false, autoRedact: true });
+ const cleaned = applyDecisions(`session_hex=${lower}`, findings, decisions);
+ assert.ok(!cleaned.includes(lower), 'raw hex leaked after redaction');
+ assert.equal(shadowScan(cleaned, {}).length, 0, 'shadow scan should be clean after hex redaction');
+});
+
+test('redaction: end-to-end hex secret leaves no raw hex in any artifact', async () => {
+ const lower = '6881f8290266f4cc939959917f893a2a88787eb24bbcb6b9c37594c72bf448c3';
+ const upper = lower.toUpperCase();
+ const dir = mkdtempSync(join(tmpdir(), 'treetrace-hex-'));
+ const file = join(dir, 'hexconv.json');
+ const convo = [{
+ mapping: {
+ r: { message: null, parent: null, children: ['u'] },
+ u: { message: { author: { role: 'user' }, content: { parts: [`my key is session_hex=${lower} and HEX=${upper} ok`] }, create_time: 1.0 }, parent: 'r', children: ['a'] },
+ a: { message: { author: { role: 'assistant' }, content: { parts: ['got it'] }, create_time: 2.0 }, parent: 'u', children: [] },
+ },
+ }];
+ writeFileSync(file, JSON.stringify(convo));
+ try {
+ await main(['--from', 'chatgpt', '--file', file, '--dir', dir, '--report', '--analysis', '--redact-auto', '--quiet']);
+ const artifacts = [
+ 'PROMPT_TREE.md', 'TREETRACE_REPORT.md', '.treetrace/tree.json',
+ '.treetrace/failures.json', '.treetrace/lessons.md', '.treetrace/evals.jsonl', '.treetrace/agent-memory.md',
+ ].filter((f) => existsSync(join(dir, f))).map((f) => readFileSync(join(dir, f), 'utf8')).join('\n');
+ assert.ok(!artifacts.includes(lower), 'lowercase hex secret leaked into an artifact');
+ assert.ok(!artifacts.includes(upper), 'uppercase hex secret leaked into an artifact');
+ assert.ok(artifacts.includes('[REDACTED:hex-token]'), 'expected a hex-token redaction marker');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('redaction: a single 12MB token completes without throwing and stays safe', () => {
+ const giant = 'A'.repeat(12 * 1024 * 1024);
+ const text = `prefix ${giant} suffix`;
+ let findings;
+ assert.doesNotThrow(() => { findings = scanText(text); }, 'oversized token must not overflow the regex stack');
+ assert.ok(findings.some((f) => f.ruleId === 'oversized-token'), 'oversized token should be flagged');
+ const normal = scanText('store ghp_0123456789abcdefghijklmnopqrstuvwxyzAB and more');
+ assert.ok(normal.some((f) => f.ruleId === 'github-token'), 'normal-size secrets still caught alongside the guard');
+ const { decisions } = applyDecisionsRoundTrip(text, findings);
+ assert.equal(shadowScan(decisions, {}).length, 0, 'oversized token should be cleaned after redaction');
+});
+
+function applyDecisionsRoundTrip(text, findings) {
+ const map = {};
+ for (const f of findings) map[sha256(f.match)] = { action: 'redact', replacement: maskFor(f), ruleId: f.ruleId };
+ return { decisions: applyDecisions(text, findings, map) };
+}
+
test('redaction: split provider tokens are caught before shadow scan', () => {
const dirty = 'token sk-proj-abcdefghijklmnop\nqrstu1234567890ABCDE end';
const findings = scanText(dirty);
@@ -153,7 +213,7 @@ test('redaction: scan stays fast on long benign input (ReDoS guard)', () => {
test('redaction: benign text produces no high/medium findings', () => {
const benign =
- 'Refactor the parser in src/parse.js to handle commit 3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a and bump to v2.1.0-beta.3. The README.md needs a section on CONTRIBUTING.';
+ 'Refactor the parser in src/parse.js to handle commit 3f2a1b9 and bump to v2.1.0-beta.3. The README.md needs a section on CONTRIBUTING.';
const hard = scanText(benign).filter((f) => f.severity !== 'soft');
assert.deepEqual(hard, []);
});
@@ -202,6 +262,14 @@ test('renderers: markdown, json, handoff are consistent and footer-credited', as
assert.ok(report.includes('TREETRACE_REPORT.md'));
});
+test('rendering: markdown footer stamps the tool version when provided', async () => {
+ const { tree } = await fixtureTree();
+ const md = renderMarkdown(tree, { projectName: 'demo', version: '0.4.0' });
+ assert.ok(md.includes('v0.4.0'), 'PROMPT_TREE.md footer should stamp the version');
+ const report = renderReportMarkdown(tree, { projectName: 'demo', version: '0.4.0', generatedAt: '2026-01-01T00:00:00.000Z' });
+ assert.ok(report.includes('v0.4.0'), 'TREETRACE_REPORT.md footer should stamp the version');
+});
+
test('analysis renderers produce failures, lessons, evals, and memory', async () => {
const { tree } = await fixtureTree();
const failures = renderFailuresJson(tree, { projectName: 'demo', generatedAt: '2026-01-01T00:00:00.000Z' });
@@ -272,6 +340,57 @@ test('analysis: a credential-handling Bash action produces a verified security s
assert.ok(analysis.summary.tierCounts.verified >= 1);
});
+test('analysis: benign --force-* chrome flag does not mint a verified security signal', () => {
+ const root = {
+ id: 'node_001', text: 'capture a screenshot of the page', title: 'capture a screenshot',
+ kind: 'root', status: 'accepted', parent: null,
+ actions: [{ tool: 'Bash', file: null, command: 'chrome --headless --force-device-scale-factor=1 --screenshot=out.png', model: 'm' }],
+ };
+ const analysis = analyzeTree({ nodes: [root] });
+ const sec = analysis.failures.filter((f) => f.type === 'security_or_privacy_risk');
+ assert.equal(sec.length, 0, '--force-device-scale-factor must not fire as a security risk');
+});
+
+test('analysis: a token-named UI file does not mint a verified credential signal', () => {
+ for (const file of ['src/ui/semantic-tokens.ts', 'src/lexer/tokenizer.ts', 'theme/design-tokens.json']) {
+ const root = {
+ id: 'node_001', text: 'edit the theme', title: 'edit the theme',
+ kind: 'root', status: 'accepted', parent: null,
+ actions: [{ tool: 'Edit', file, command: null, model: 'm' }],
+ };
+ const analysis = analyzeTree({ nodes: [root] });
+ const verified = analysis.failures.filter((f) => f.type === 'security_or_privacy_risk' && f.tier === 'verified');
+ assert.equal(verified.length, 0, `${file} must not produce a verified credential signal`);
+ }
+});
+
+test('analysis: a bare rbac keyword in a non-credential edit is down-tiered below verified', () => {
+ const root = {
+ id: 'node_001', text: 'edit the detector', title: 'edit the detector',
+ kind: 'root', status: 'accepted', parent: null,
+ actions: [{ tool: 'Edit', file: 'src/analyze.js', input: 'const ACCESS = /rbac/i;', command: null, model: 'm' }],
+ };
+ const analysis = analyzeTree({ nodes: [root] });
+ const sec = analysis.failures.filter((f) => f.type === 'security_or_privacy_risk');
+ assert.ok(sec.every((f) => f.tier !== 'verified' && f.confidence < 0.95), 'bare rbac keyword must not be verified/0.95');
+});
+
+test('analysis: a real credential file and a real secret command still verify at 0.95', () => {
+ const fileNode = {
+ id: 'node_001', text: 'harden auth', title: 'harden auth', kind: 'root', status: 'accepted', parent: null,
+ actions: [{ tool: 'Edit', file: 'src/auth/session.ts', command: null, model: 'm' }],
+ };
+ const fileSec = analyzeTree({ nodes: [fileNode] }).failures.find((f) => f.type === 'security_or_privacy_risk');
+ assert.ok(fileSec && fileSec.tier === 'verified' && fileSec.confidence === 0.95, 'a genuine auth file must stay verified');
+
+ const cmdNode = {
+ id: 'node_001', text: 'deploy', title: 'deploy', kind: 'root', status: 'accepted', parent: null,
+ actions: [{ tool: 'Bash', file: null, command: '. /srv/app/.env; wrangler pages deploy', input: '. /srv/app/.env; wrangler pages deploy', model: 'm' }],
+ };
+ const cmdSec = analyzeTree({ nodes: [cmdNode] }).failures.find((f) => f.type === 'security_or_privacy_risk');
+ assert.ok(cmdSec && cmdSec.tier === 'verified', 'a genuine credential command must stay verified');
+});
+
test('analysis: a PAT-update prompt produces an inferred security signal even with no action', () => {
const root = { id: 'node_001', text: 'build the cli', title: 'build the cli', kind: 'root', status: 'accepted', parent: null, actions: [] };
const intent = {
@@ -384,6 +503,36 @@ test('analysis: a single benign prompt does not yield multiple failure types', (
}
});
+test('analysis: latest accepted direction is chronological, not insertion order', () => {
+ const root = {
+ id: 'node_001', text: 'pick a research topic', title: 'pick a research topic',
+ kind: 'root', status: 'accepted', parent: null, ts: '2026-01-01T00:00:00.000Z', actions: [],
+ };
+ const newest = {
+ id: 'node_002', text: 'lets dig into Amazon Nova and the Karunanidhi essay direction',
+ title: 'Amazon Nova and Karunanidhi', kind: 'direction', status: 'accepted', parent: root,
+ ts: '2026-03-01T00:00:00.000Z', actions: [],
+ };
+ const stale = {
+ id: 'node_003', text: 'lets explore the Seoul travel itinerary in depth for the trip',
+ title: 'Seoul travel itinerary', kind: 'direction', status: 'accepted', parent: newest,
+ ts: '2026-02-01T00:00:00.000Z', actions: [],
+ };
+ const nodes = [root, newest, stale];
+ const tree = { nodes, stats: { promptCount: 3, sessionCount: 2 } };
+ const summary = renderTerminalSummary(tree, { projectName: 'demo' });
+ assert.ok(/Amazon Nova/i.test(summary), 'terminal summary should name the chronologically newest direction');
+ assert.ok(!/Seoul/i.test(summary.split('Latest accepted direction:')[1] || ''), 'must not name the stale Seoul session as latest');
+
+ const handoff = renderHandoff(tree, { projectName: 'demo' });
+ const stand = handoff.split('## Where things stand')[1].split('##')[0];
+ assert.ok(/Amazon Nova/i.test(stand), 'handoff should name the chronologically newest accepted direction');
+
+ const memory = renderMemoryMarkdown(tree, { projectName: 'demo' });
+ const next = memory.slice(memory.indexOf('## Preferred next work'));
+ assert.ok(/Amazon Nova/i.test(next), 'agent memory should point at the chronologically newest direction');
+});
+
test('analysis: a corrector is never linked with an earlier timestamp than its failure', () => {
const failure = {
id: 'node_001', text: 'i do not see the deck, just the index file showing text',
@@ -454,6 +603,34 @@ test('cli: default run writes analysis artifacts with redaction', async () => {
}
});
+test('cli: --analysis combined with --report writes both analysis files and the reports', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'treetrace-both-'));
+ try {
+ await main(['--file', FIXTURE, '--dir', dir, '--analysis', '--report', '--redact-auto', '--quiet']);
+ for (const file of [
+ 'TREETRACE_REPORT.md', 'PROMPT_TREE.md', '.treetrace/tree.json',
+ '.treetrace/failures.json', '.treetrace/lessons.md', '.treetrace/evals.jsonl', '.treetrace/agent-memory.md',
+ ]) {
+ assert.ok(existsSync(join(dir, file)), `${file} missing when --analysis and --report combined`);
+ }
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('cli: a copilot import records a per-adapter sourceType, not claude-code-jsonl', async () => {
+ const fixture = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'adapters', 'copilot-chatsession.json');
+ const dir = mkdtempSync(join(tmpdir(), 'treetrace-src-'));
+ try {
+ await main(['--from', 'copilot', '--file', fixture, '--dir', dir, '--redact-auto', '--quiet']);
+ const tree = JSON.parse(readFileSync(join(dir, '.treetrace/tree.json'), 'utf8'));
+ assert.equal(tree.project.sourceType, 'copilot-chat', 'sourceType should reflect the copilot adapter');
+ assert.notEqual(tree.project.sourceType, 'claude-code-jsonl');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
test('cli: creates the output directory and .treetrace subdirectory when missing', async () => {
const base = mkdtempSync(join(tmpdir(), 'treetrace-'));
const dir = join(base, 'does', 'not', 'exist', 'yet');
@@ -524,6 +701,22 @@ test('redaction: a token inside a Bash action body is redacted end to end', asyn
}
});
+test('handoff: command operators are not HTML-escaped in the brief', () => {
+ const root = {
+ id: 'node_001', text: 'run rm -rf build && mkdir build to reset the workspace',
+ title: 'reset the workspace', kind: 'root', status: 'accepted', parent: null, actions: [],
+ };
+ const handoff = renderHandoff({ nodes: [root], stats: { promptCount: 1, sessionCount: 1 } }, { projectName: 'demo' });
+ assert.ok(handoff.includes('rm -rf build && mkdir build'), 'command should keep raw && in the handoff brief');
+ assert.ok(!handoff.includes('&amp;&amp;'), 'handoff must not HTML-escape && to &amp;&amp;');
+ const inject = {
+ id: 'node_001', text: 'do not run <script>alert(1)</script> ever',
+ title: 'no scripts', kind: 'root', status: 'accepted', parent: null, actions: [],
+ };
+ const handoff2 = renderHandoff({ nodes: [inject], stats: { promptCount: 1, sessionCount: 1 } }, { projectName: 'demo' });
+ assert.ok(!handoff2.includes('<script>'), 'angle-bracket tags should still be neutralized in the handoff brief');
+});
+
test('plain transcript fallback parses User:/Assistant: markers', () => {
const session = parsePlainTranscript(
'User: build me a snake game in python\nAssistant: sure, here is the code...\nUser: make the snake blue\nAssistant: done',