● Zion Boggan zionboggan.com ↗

Fix redaction and source provenance gaps

7a8ebb4   Zion Boggan committed on Jun 24, 2026 (1 month ago)
src/cli.js +2 -1
@@ -386,9 +386,10 @@ const SOURCE_TYPE_BY_TOOL = {
cursor: 'cursor-export',
grok: 'grok-cli',
transcript: 'transcript',
+ mixed: 'mixed',
};
-function sourceTypeFor(tool) {
+export function sourceTypeFor(tool) {
return SOURCE_TYPE_BY_TOOL[tool] || 'claude-code-jsonl';
}
src/mcp.js +13 -3
@@ -1,6 +1,6 @@
import { createInterface } from 'node:readline';
import { resolve } from 'node:path';
-import { parseArgs, loadRedactedTree, detectProjectName, assertClean } from './cli.js';
+import { parseArgs, loadRedactedTree, detectProjectName, assertClean, sourceTypeFor } from './cli.js';
import { renderHandoff } from './handoff.js';
import { renderLessonsMarkdown, analyzeTree, renderRejectionsJson } from './analyze.js';
import { renderSecurityReport } from './security-report.js';
@@ -65,8 +65,18 @@ export async function startMcpServer({ argv, version }, io = {}) {
if (cache) return cache;
if (!inFlight) {
inFlight = (async () => {
- const { tree, decisions } = await loadRedactedTree(opts, projectDir, projectName, () => {}, { forceAuto: true });
- cache = { tree, decisions, renderOpts: { projectName, version, projectDir, generatedAt: new Date().toISOString() } };
+ const { tree, decisions, sourceTool } = await loadRedactedTree(opts, projectDir, projectName, () => {}, { forceAuto: true });
+ cache = {
+ tree,
+ decisions,
+ renderOpts: {
+ projectName,
+ version,
+ projectDir,
+ generatedAt: new Date().toISOString(),
+ sourceType: sourceTypeFor(sourceTool),
+ },
+ };
return cache;
})().finally(() => {
inFlight = null;
src/redact.js +16 -1
@@ -143,7 +143,14 @@ export function scanText(text) {
if (HEX_RE.test(tok) || VERSION_LIKE_RE.test(tok)) continue;
const classes = (/[A-Z]/.test(tok) ? 1 : 0) + (/[a-z]/.test(tok) ? 1 : 0) + (/[0-9]/.test(tok) ? 1 : 0);
if (classes < 2) continue;
- if (shannonEntropy(tok) < 4.4) continue;
+ const entropy = shannonEntropy(tok);
+ if (entropy < 4.4) {
+ if (!looksLikeLowercaseDigitSecret(tok, entropy)) continue;
+ const start = m.index;
+ if (seenSpans.some(([s, e]) => start >= s && start < e)) continue;
+ findings.push({ ruleId: 'lowercase-digit-token', severity: 'medium', match: tok, index: start });
+ continue;
+ }
const start = m.index;
if (seenSpans.some(([s, e]) => start >= s && start < e)) continue;
findings.push({ ruleId: 'high-entropy-token', severity: 'medium', match: tok, index: start });
@@ -153,6 +160,14 @@ export function scanText(text) {
return findings;
}
+function looksLikeLowercaseDigitSecret(tok, entropy) {
+ if (tok.length < 32) return false;
+ if (!/^[a-z0-9_-]+$/.test(tok)) return false;
+ if (!/[a-z]/.test(tok) || !/[0-9]/.test(tok)) return false;
+ if (VERSION_LIKE_RE.test(tok)) return false;
+ return entropy >= 3.0;
+}
+
function scanJoinedProviderTokens(scanInput, existing, original = scanInput) {
const chars = [];
const indexMap = [];
test/treetrace.test.js +61 -0
@@ -191,6 +191,17 @@ test('redaction: high-entropy lowercase-and-digit token (no uppercase) is caught
assert.ok(hits.includes('high-entropy-token'), `high-entropy token missed (got ${hits})`);
});
+test('redaction: structured lowercase-and-digit token is caught fail-closed', async () => {
+ const token = 'lowercase9lowercase9lowercase9lowercase9';
+ const findings = scanText(`session token ${token}`).filter((f) => f.severity !== 'soft');
+ assert.ok(findings.some((f) => f.ruleId === 'lowercase-digit-token'), `lowercase-digit token missed: ${JSON.stringify(findings)}`);
+
+ const { decisions } = await resolveFindings(findings, {}, { interactive: false, autoRedact: true });
+ const cleaned = applyDecisions(`session token ${token}`, findings, decisions);
+ assert.ok(!cleaned.includes(token), 'structured lowercase token leaked after redaction');
+ assert.equal(shadowScan(cleaned, {}).length, 0, 'shadow scan should be clean after lowercase-digit redaction');
+});
+
test('redaction: uuids and long lowercase identifiers are not flagged as high-entropy', () => {
for (const benign of [
'8400e29b-1d4f-4a6c-9b2e-7f3a1c5d8e90',
@@ -736,6 +747,27 @@ test('cli: a copilot import records a per-adapter sourceType, not claude-code-js
}
});
+test('cli: mixed imports record mixed sourceType instead of falling back to claude', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'treetrace-mixed-src-'));
+ const plain = join(dir, 'plain.txt');
+ const chatgpt = join(dir, 'chatgpt.json');
+ writeFileSync(plain, 'User: build a CLI\nAssistant: ok\n');
+ writeFileSync(chatgpt, JSON.stringify([{
+ title: 'demo',
+ mapping: {
+ r: { message: null, parent: null, children: ['u'] },
+ u: { message: { author: { role: 'user' }, content: { parts: ['build a dashboard'] }, create_time: 1.0 }, parent: 'r', children: [] },
+ },
+ }]));
+ try {
+ await main(['--file', plain, chatgpt, '--dir', dir, '--redact-auto', '--quiet']);
+ const tree = JSON.parse(readFileSync(join(dir, '.treetrace/tree.json'), 'utf8'));
+ assert.equal(tree.project.sourceType, 'mixed');
+ } 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');
@@ -1102,6 +1134,35 @@ test('mcp: initialize, tools/list, and tools/call return well-formed JSON-RPC',
}
});
+test('mcp: tree tool reports the imported source type', async () => {
+ const dir = tempProject();
+ const transcript = join(dir, 'plain.txt');
+ writeFileSync(transcript, 'User: build a CLI\nAssistant: ok\n');
+ const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'treetrace.js');
+ try {
+ const responses = await new Promise((resolveP, rejectP) => {
+ const child = spawn('node', [bin, 'mcp', '--from', 'transcript', '--file', transcript, '--dir', dir], {
+ stdio: ['pipe', 'pipe', 'ignore'],
+ });
+ let buf = '';
+ child.stdout.on('data', (d) => { buf += d; });
+ child.on('error', rejectP);
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'tree', arguments: {} } }) + '\n');
+ setTimeout(() => {
+ child.stdin.end();
+ child.kill();
+ resolveP(buf.split('\n').filter(Boolean).map((l) => JSON.parse(l)));
+ }, 2000);
+ });
+
+ const treeResponse = responses.find((r) => r.id === 1);
+ const tree = JSON.parse(treeResponse.result.content[0].text);
+ assert.equal(tree.project.sourceType, 'transcript');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
import { recordedCwd } from '../src/discover.js';
test('redaction: JSON-style, quoted, backtick, and multiline secret assignments are caught', () => {