Zion Boggan zionboggan.com ↗

Harden redaction shadow-scan, handoff brief, and JSON render; add CI workflow

6d18ed9   Zion Boggan committed on Jun 12, 2026 (1 week ago)
.github/workflows/ci.yml +1 -1
@@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- - run: node --test test/
+ - run: node --test test/treetrace.test.js
- name: CLI smoke test (fixture, fail-closed redaction)
run: |
node bin/treetrace.js --file test/fixtures/synthetic-session.jsonl --dir "$RUNNER_TEMP" --redact-auto --quiet
src/handoff.js +11 -0
@@ -1,4 +1,5 @@
import { truncate } from './util.js';
+import { analyzeTree } from './analyze.js';
/**
* --handoff: an agent-ready context pack, printed to stdout (and gated by the
@@ -11,6 +12,7 @@ import { truncate } from './util.js';
export function renderHandoff(tree, opts = {}) {
const { projectName } = opts;
const { nodes, stats } = tree;
+ const analysis = analyzeTree(tree);
const lines = [];
const root = nodes.find((n) => n.kind === 'root') || nodes[0];
@@ -73,6 +75,15 @@ export function renderHandoff(tree, opts = {}) {
lines.push('');
}
+ if (analysis.lessons.length) {
+ 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('');
+ }
+
lines.push('## First task');
lines.push('');
lines.push(
src/redact.js +52 -2
@@ -37,9 +37,9 @@ export const RULES = [
// ---- medium: context-dependent assignments ----
{ 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: /[a-z][a-z0-9+.-]*:\/\/[^/\s:@'"`]{2,}:[^/\s:@'"`]{2,}@[^\s'"`]+/gi },
+ { id: 'url-basic-auth', severity: 'medium', re: /[a-z][a-z0-9+.-]*:\/\/[^/\s:@'"`]{2,}:[^/\s@'"`]{2,}@[^\s'"`]+/gi },
{ id: 'bearer-header', severity: 'medium', re: /\bBearer\s+[A-Za-z0-9._+/=-]{20,}\b/g },
- { id: 'secret-assignment', severity: 'medium', re: /\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret)\b\s*[:=]\s*['"]?(?!\$\{|<|%|\*{3}|\.{3}|REDACTED|xxx+|placeholder|changeme|example|your[-_])[^\s'"`,;]{8,}/gi },
+ { id: 'secret-assignment', severity: 'medium', re: /\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret)\b\s*[:=]\s*(?!(?:['"]?\s*)?(?:\$\{|<|%|\*{3}|\.{3}|REDACTED|xxx+|placeholder|changeme|example|your[-_]))(?:"[^"\r\n]{8,}"|'[^'\r\n]{8,}'|[^\s'"`,;]{8,})/gi },
// ---- soft: PII and context the user may want to keep ----
{ id: 'email', severity: 'soft', re: /\b[A-Za-z0-9._%+-]+@(?!(?:users\.noreply\.github\.com|example\.(?:com|org)))[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
@@ -50,6 +50,24 @@ export const RULES = [
const HEX_RE = /^[0-9a-fA-F]+$/;
const ENTROPY_CANDIDATE_RE = /\b[A-Za-z0-9+/_=-]{32,}\b/g;
const VERSION_LIKE_RE = /^\d+[.\d-]*$/;
+const JOIN_SEPARATOR_RE = /[\s\u200B-\u200D\uFEFF]/;
+const JOINED_SCAN_RULE_IDS = new Set([
+ 'aws-access-key',
+ 'github-token',
+ 'github-fine-grained',
+ 'gitlab-token',
+ 'anthropic-key',
+ 'openai-key',
+ 'slack-token',
+ 'stripe-live-key',
+ 'npm-token',
+ 'tailscale-key',
+ 'google-api-key',
+ 'sendgrid-key',
+ 'twilio-key',
+ 'telegram-bot-token',
+ 'jwt',
+]);
export function scanText(text) {
const findings = [];
@@ -82,6 +100,38 @@ export function scanText(text) {
findings.push({ ruleId: 'high-entropy-token', severity: 'medium', match: tok, index: start });
}
+ findings.push(...scanJoinedProviderTokens(text, findings));
+ return findings;
+}
+
+function scanJoinedProviderTokens(text, existing) {
+ const chars = [];
+ const indexMap = [];
+ for (let i = 0; i < text.length; i++) {
+ if (JOIN_SEPARATOR_RE.test(text[i])) continue;
+ chars.push(text[i]);
+ indexMap.push(i);
+ }
+ if (chars.length === text.length) return [];
+
+ const joined = chars.join('');
+ const existingSpans = existing.map((f) => [f.index, f.index + f.match.length]);
+ const findings = [];
+ for (const rule of RULES) {
+ if (!JOINED_SCAN_RULE_IDS.has(rule.id)) continue;
+ rule.re.lastIndex = 0;
+ let m;
+ while ((m = rule.re.exec(joined)) !== null) {
+ 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)) continue;
+ if (original.length - m[0].length > 20) continue;
+ if (existingSpans.some(([s, e]) => start >= s && start < e)) continue;
+ findings.push({ ruleId: rule.id, severity: rule.severity, match: original, index: start });
+ if (m.index === rule.re.lastIndex) rule.re.lastIndex++;
+ }
+ }
return findings;
}
src/render-json.js +16 -2
@@ -1,7 +1,8 @@
import { REPO_URL } from './config.js';
+import { analyzeTree } from './analyze.js';
/**
- * Machine-readable export: treetrace lineage schema v0.1.
+ * Machine-readable export: TreeTrace lineage schema v0.2.
* Documented in SCHEMA.md with a mapping to the Agent Trace RFC.
*/
@@ -17,9 +18,10 @@ const RELATIONSHIP_BY_KIND = {
export function renderJson(tree, opts = {}) {
const { projectName, generatedBy = 'treetrace', version = '0.1.0' } = opts;
const { nodes, sessions, stats } = tree;
+ const analysis = analyzeTree(tree);
return {
- schemaVersion: '0.1',
+ schemaVersion: '0.2',
generator: { name: generatedBy, version, url: REPO_URL },
project: {
name: projectName,
@@ -40,6 +42,12 @@ export function renderJson(tree, opts = {}) {
firstTs: stats.firstTs,
lastTs: stats.lastTs,
},
+ analysis: {
+ failureSignals: analysis.summary.totalFailureSignals,
+ correctionChains: analysis.summary.correctionChains,
+ evalCandidates: analysis.summary.evalCandidates,
+ lessons: analysis.summary.lessons,
+ },
sessions: sessions
.filter((s) => s.prompts.length)
.map((s) => ({
@@ -62,6 +70,9 @@ export function renderJson(tree, opts = {}) {
reruns: n.reruns || 0,
session: n.sessionId,
timestamp: n.ts,
+ failureSignals: n.failureSignals || [],
+ evalCandidate: Boolean(n.evalCandidate),
+ lessonIds: n.lessonIds || [],
// source linkage for audit: the original record uuid inside the local
// session transcript (raw transcripts themselves are never exported)
sourceEventIds: n.uuid ? [n.uuid] : [],
@@ -73,5 +84,8 @@ export function renderJson(tree, opts = {}) {
to: n.id,
relationship: RELATIONSHIP_BY_KIND[n.kind] || 'refines',
})),
+ correctionChains: analysis.correctionChains,
+ lessons: analysis.lessons,
+ evalCandidates: analysis.evalCandidates,
};
}