@raquezha/notrace 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @raquezha/notrace
2
2
 
3
+ ## 0.0.4
4
+
5
+ ### Patch Changes
6
+
7
+ - f2959b5: Harden notrace reports with default redaction, metadata-only capture support, offline CSP-protected HTML, escaped rendering, private file permissions, and `.workflow`-confined report writes.
8
+
3
9
  ## 0.0.3
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Phase 0 / POC local-first interactive HTML Trace Viewer for the Pi Coding Agent. It captures execution traces for workflow debugging — LLM calls, tool executions, token usage, costs — and writes an interactive HTML report to your active task workspace at session end.
4
4
 
5
- > **POC warning:** notrace is currently for local experimentation and RPIV observability research. It can capture prompts, tool payloads, outputs, local paths, and accidental secrets. Do not publish generated reports. Redaction, safer rendering, and configurable capture levels are planned follow-up work.
5
+ > **Security warning:** notrace is local-first and now redacts common secrets by default, escapes report rendering, blocks network access in generated reports, and writes private report files. Reports can still contain sensitive prompts, tool payloads, outputs, and local paths. Do not publish generated reports.
6
6
 
7
7
  ## Features
8
8
 
@@ -10,7 +10,8 @@ Phase 0 / POC local-first interactive HTML Trace Viewer for the Pi Coding Agent.
10
10
  - **Metrics dashboard**: Total tokens, input/output split, cache reads, cost (USD), duration
11
11
  - **Clickable `file://` link**: Report path printed to console at session end for instant browser access
12
12
  - **Active task aware**: Writes the report into `.workflow/tasks/<task>/notrace.html` when a task is active
13
- - **HTML report**: Intended to become fully self-contained/offline; Phase 0 still needs hardening
13
+ - **HTML report**: Self-contained/offline report with a restrictive CSP and no remote font/network loads
14
+ - **Safer defaults**: Secret-key/value redaction, bounded payload sizes, metadata-only mode, private file permissions, and `.workflow`-confined report writes
14
15
 
15
16
  ## Output
16
17
 
@@ -35,6 +36,15 @@ pi --dev
35
36
  npm install -g @raquezha/notrace
36
37
  ```
37
38
 
39
+ ## Capture controls
40
+
41
+ By default, notrace uses `NOTRACE_CAPTURE=redacted`: it captures useful payloads but redacts common secret keys/values and truncates very large values.
42
+
43
+ ```bash
44
+ NOTRACE_CAPTURE=metadata pi --dev # no prompt/tool payload bodies
45
+ NOTRACE_CAPTURE=full pi --dev # unsafe: raw payloads for local debugging only
46
+ ```
47
+
38
48
  ## Build
39
49
 
40
50
  ```bash
package/dist/notrace.js CHANGED
@@ -1,5 +1,78 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
2
  import * as path from "node:path";
3
+ const REDACTED = "[REDACTED by notrace]";
4
+ const MAX_STRING_LENGTH = 20_000;
5
+ const MAX_ARRAY_ITEMS = 200;
6
+ const MAX_OBJECT_KEYS = 200;
7
+ const MAX_DEPTH = 8;
8
+ const SENSITIVE_KEY_RE = /(authorization|cookie|setcookie|password|passwd|pwd|secret|token|apikey|accesskey|accesskeyid|accessid|accesstoken|privatekey|session|credential|refreshtoken|idtoken)/i;
9
+ const SENSITIVE_VALUE_RE = /(bearer\s+[a-z0-9._~+/=-]{12,}|sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9_]{16,}|xox[baprs]-[a-z0-9-]{16,}|AKIA[0-9A-Z]{16})/gi;
10
+ function getCaptureMode() {
11
+ const mode = process.env.NOTRACE_CAPTURE?.toLowerCase();
12
+ if (mode === "metadata" || mode === "full")
13
+ return mode;
14
+ return "redacted";
15
+ }
16
+ function isSensitiveKey(key) {
17
+ return SENSITIVE_KEY_RE.test(key.replace(/[^a-z0-9]/gi, ""));
18
+ }
19
+ function redactString(value) {
20
+ const redacted = value.replace(SENSITIVE_VALUE_RE, REDACTED);
21
+ if (redacted.length <= MAX_STRING_LENGTH)
22
+ return redacted;
23
+ return `${redacted.slice(0, MAX_STRING_LENGTH)}\n…[truncated ${redacted.length - MAX_STRING_LENGTH} chars by notrace]`;
24
+ }
25
+ function sanitizeTraceValue(value, depth = 0, seen = new WeakSet()) {
26
+ if (getCaptureMode() === "full")
27
+ return value;
28
+ if (value == null || typeof value === "number" || typeof value === "boolean")
29
+ return value;
30
+ if (typeof value === "string")
31
+ return redactString(value);
32
+ if (typeof value === "bigint")
33
+ return value.toString();
34
+ if (typeof value === "function" || typeof value === "symbol")
35
+ return `[${typeof value}]`;
36
+ if (depth >= MAX_DEPTH)
37
+ return "[Max depth reached by notrace]";
38
+ if (typeof value !== "object")
39
+ return String(value);
40
+ if (seen.has(value))
41
+ return "[Circular]";
42
+ seen.add(value);
43
+ if (Array.isArray(value)) {
44
+ const items = value.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitizeTraceValue(item, depth + 1, seen));
45
+ if (value.length > MAX_ARRAY_ITEMS)
46
+ items.push(`…[truncated ${value.length - MAX_ARRAY_ITEMS} items by notrace]`);
47
+ return items;
48
+ }
49
+ const output = {};
50
+ const entries = Object.entries(value);
51
+ for (const [key, item] of entries.slice(0, MAX_OBJECT_KEYS)) {
52
+ output[key] = isSensitiveKey(key) ? REDACTED : sanitizeTraceValue(item, depth + 1, seen);
53
+ }
54
+ if (entries.length > MAX_OBJECT_KEYS)
55
+ output.__notrace_truncated__ = `${entries.length - MAX_OBJECT_KEYS} keys`;
56
+ return output;
57
+ }
58
+ function safeResolveUnder(baseDir, candidate) {
59
+ const base = path.resolve(baseDir);
60
+ const resolved = path.resolve(base, candidate);
61
+ const relative = path.relative(base, resolved);
62
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved : null;
63
+ }
64
+ function escapeHtml(value) {
65
+ return String(value).replace(/[&<>'"]/g, (char) => {
66
+ switch (char) {
67
+ case "&": return "&amp;";
68
+ case "<": return "&lt;";
69
+ case ">": return "&gt;";
70
+ case "'": return "&#39;";
71
+ case '"': return "&quot;";
72
+ default: return char;
73
+ }
74
+ });
75
+ }
3
76
  /**
4
77
  * html-observability extension
5
78
  *
@@ -14,31 +87,34 @@ export default function (pi) {
14
87
  let activeLlmPayload = null;
15
88
  let llmStartTime = 0;
16
89
  const activeToolTimes = {};
17
- // Helper to extract active task path from .workflow/active_task.json
90
+ // Helper to extract active task path from .workflow/active_task.json.
91
+ // Reports are constrained to cwd/.workflow to avoid task metadata causing writes elsewhere.
18
92
  function getActiveTaskDir(cwd) {
93
+ const workflowDir = path.resolve(cwd, ".workflow");
19
94
  try {
20
- const activeTaskJsonPath = path.join(cwd, ".workflow", "active_task.json");
95
+ const activeTaskJsonPath = path.join(workflowDir, "active_task.json");
21
96
  if (existsSync(activeTaskJsonPath)) {
22
97
  const content = JSON.parse(readFileSync(activeTaskJsonPath, "utf-8"));
23
- if (content.taskPath) {
24
- return path.resolve(cwd, content.taskPath);
25
- }
26
- else if (content.active_task) {
27
- return path.resolve(cwd, ".workflow", "tasks", content.active_task);
98
+ const candidate = typeof content.taskPath === "string"
99
+ ? safeResolveUnder(cwd, content.taskPath)
100
+ : typeof content.active_task === "string"
101
+ ? safeResolveUnder(workflowDir, path.join("tasks", content.active_task))
102
+ : null;
103
+ if (candidate && safeResolveUnder(workflowDir, path.relative(workflowDir, candidate))) {
104
+ return candidate;
28
105
  }
29
106
  }
30
107
  }
31
108
  catch {
32
109
  // fallback
33
110
  }
34
- const defaultDir = path.join(cwd, ".workflow");
35
- if (!existsSync(defaultDir)) {
111
+ if (!existsSync(workflowDir)) {
36
112
  try {
37
- mkdirSync(defaultDir, { recursive: true });
113
+ mkdirSync(workflowDir, { recursive: true, mode: 0o700 });
38
114
  }
39
115
  catch { }
40
116
  }
41
- return defaultDir;
117
+ return workflowDir;
42
118
  }
43
119
  // 1. Session start
44
120
  pi.on("session_start", async (event, ctx) => {
@@ -72,7 +148,7 @@ export default function (pi) {
72
148
  type: "tool_start",
73
149
  toolCallId,
74
150
  toolName,
75
- args,
151
+ args: getCaptureMode() === "metadata" ? "[metadata-only capture]" : sanitizeTraceValue(args),
76
152
  timestamp: Date.now()
77
153
  });
78
154
  });
@@ -85,7 +161,7 @@ export default function (pi) {
85
161
  type: "tool_end",
86
162
  toolCallId,
87
163
  toolName,
88
- result,
164
+ result: getCaptureMode() === "metadata" ? "[metadata-only capture]" : sanitizeTraceValue(result),
89
165
  isError,
90
166
  durationMs,
91
167
  timestamp: Date.now()
@@ -94,7 +170,7 @@ export default function (pi) {
94
170
  });
95
171
  // 6. LLM call start (capture payload)
96
172
  pi.on("before_provider_request", async (event, ctx) => {
97
- activeLlmPayload = event.payload;
173
+ activeLlmPayload = getCaptureMode() === "metadata" ? null : sanitizeTraceValue(event.payload);
98
174
  llmStartTime = Date.now();
99
175
  });
100
176
  // 7. LLM call end (capture generation / usage)
@@ -108,8 +184,8 @@ export default function (pi) {
108
184
  model: message.model || "unknown",
109
185
  provider: message.provider || "unknown",
110
186
  inputPayload: activeLlmPayload,
111
- outputContent: message.content,
112
- usage: message.usage,
187
+ outputContent: getCaptureMode() === "metadata" ? "[metadata-only capture]" : sanitizeTraceValue(message.content),
188
+ usage: sanitizeTraceValue(message.usage),
113
189
  durationMs,
114
190
  timestamp: Date.now()
115
191
  });
@@ -160,7 +236,8 @@ export default function (pi) {
160
236
  events
161
237
  });
162
238
  try {
163
- writeFileSync(reportPath, htmlContent, "utf-8");
239
+ mkdirSync(taskDir, { recursive: true, mode: 0o700 });
240
+ writeFileSync(reportPath, htmlContent, { encoding: "utf-8", mode: 0o600 });
164
241
  // Output a nice clickable file:// link to the console for the user
165
242
  console.log(`\n📊 [notrace] Observability report generated:`);
166
243
  console.log(`👉 \x1b[36mfile://${reportPath}\x1b[0m\n`);
@@ -185,15 +262,15 @@ function safeJsonForScript(value) {
185
262
  // Returns a self-contained premium HTML template incorporating the design tokens
186
263
  function generateHtmlReport(data) {
187
264
  const serializedData = safeJsonForScript(data);
265
+ const escapedTraceId = escapeHtml(data.traceId);
188
266
  return `<!DOCTYPE html>
189
267
  <html lang="en">
190
268
  <head>
191
269
  <meta charset="UTF-8">
192
270
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
193
- <title>notrace - ${data.traceId}</title>
194
- <link rel="preconnect" href="https://fonts.googleapis.com">
195
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
196
- <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Source+Code+Pro:wght@400;500&display=swap" rel="stylesheet">
271
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; connect-src 'none'">
272
+ <meta name="referrer" content="no-referrer">
273
+ <title>notrace - ${escapedTraceId}</title>
197
274
  <style>
198
275
  :root {
199
276
  --bg: #0b0b0e;
@@ -218,7 +295,7 @@ function generateHtmlReport(data) {
218
295
  }
219
296
 
220
297
  body {
221
- font-family: 'Outfit', sans-serif;
298
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
222
299
  background-color: var(--bg);
223
300
  color: var(--text);
224
301
  line-height: 1.5;
@@ -424,7 +501,7 @@ function generateHtmlReport(data) {
424
501
  }
425
502
 
426
503
  .code-block {
427
- font-family: 'Source Code Pro', monospace;
504
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
428
505
  font-size: 0.875rem;
429
506
  background: rgba(0, 0, 0, 0.35);
430
507
  border: 1px solid var(--border);
@@ -526,18 +603,34 @@ function generateHtmlReport(data) {
526
603
  <script>
527
604
  const traceData = ${serializedData};
528
605
 
606
+ function escapeHtml(value) {
607
+ return String(value ?? "").replace(/[&<>'"]/g, (char) => ({
608
+ "&": "&amp;",
609
+ "<": "&lt;",
610
+ ">": "&gt;",
611
+ "'": "&#39;",
612
+ '"': "&quot;"
613
+ }[char]));
614
+ }
615
+
616
+ function safeClassName(value, fallback = "unknown") {
617
+ const normalized = String(value ?? fallback).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
618
+ return normalized || fallback;
619
+ }
620
+
621
+ function jsonText(value) {
622
+ return escapeHtml(typeof value === "string" ? value : JSON.stringify(value, null, 2));
623
+ }
624
+
529
625
  // Render Metrics
530
626
  document.getElementById("sess-id").textContent = traceData.traceId;
531
627
  document.getElementById("sess-time").textContent = new Date(traceData.startTime).toLocaleString();
532
628
  document.getElementById("val-duration").textContent = (traceData.durationMs / 1000).toFixed(2) + "s";
533
629
  document.getElementById("val-tokens").textContent = traceData.metrics.totalTokens.toLocaleString();
534
- document.getElementById("val-llms").textContent = traceData.metrics.toolCallCount; // Wait, actually traceData.metrics.llmCallCount
630
+ document.getElementById("val-llms").textContent = traceData.metrics.llmCallCount;
535
631
  document.getElementById("val-tools").textContent = traceData.metrics.toolCallCount;
536
632
  document.getElementById("val-cost").textContent = "$" + traceData.metrics.totalCost;
537
633
 
538
- // Correct the labels mapping if names swapped
539
- document.getElementById("val-llms").textContent = traceData.metrics.llmCallCount;
540
-
541
634
  // Process & Render Timeline
542
635
  const container = document.getElementById("timeline-container");
543
636
  const events = traceData.events;
@@ -597,19 +690,20 @@ function generateHtmlReport(data) {
597
690
  // Render cards
598
691
  renderedEvents.forEach((ev, index) => {
599
692
  const evDiv = document.createElement("div");
600
- evDiv.className = \`timeline-event \${ev.type}-start \${ev.isError ? "error" : ""}\`;
693
+ const eventType = safeClassName(ev.type);
694
+ evDiv.className = \`timeline-event \${eventType}-start \${ev.isError ? "error" : ""}\`;
601
695
 
602
696
  let cardHtml = \`
603
697
  <div class="timeline-dot"></div>
604
698
  <div class="card" id="card-\${index}">
605
699
  <div class="card-header" onclick="toggleCard(\${index})">
606
700
  <div class="card-title">
607
- <span class="card-badge badge-\${ev.type} \${ev.isError ? "error" : ""}">\${ev.type.toUpperCase()}</span>
608
- <span>\${ev.title}</span>
609
- \${ev.durationMs ? \`<span class="duration-pill">\${(ev.durationMs / 1000).toFixed(2)}s</span>\` : ""}
701
+ <span class="card-badge badge-\${eventType} \${ev.isError ? "error" : ""}">\${escapeHtml(eventType.toUpperCase())}</span>
702
+ <span>\${escapeHtml(ev.title)}</span>
703
+ \${ev.durationMs ? \`<span class="duration-pill">\${escapeHtml((ev.durationMs / 1000).toFixed(2))}s</span>\` : ""}
610
704
  </div>
611
705
  <div class="card-time">
612
- <span>\${ev.time}</span>
706
+ <span>\${escapeHtml(ev.time)}</span>
613
707
  <svg class="arrow-icon" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
614
708
  </div>
615
709
  </div>
@@ -617,13 +711,13 @@ function generateHtmlReport(data) {
617
711
  \`;
618
712
 
619
713
  if (ev.type === "session" || ev.type === "turn") {
620
- cardHtml += \`<div class="code-block">\${ev.body}</div>\`;
714
+ cardHtml += \`<div class="code-block">\${escapeHtml(ev.body)}</div>\`;
621
715
  } else if (ev.type === "tool") {
622
716
  cardHtml += \`
623
717
  <strong>Arguments:</strong>
624
- <div class="code-block">\${JSON.stringify(ev.args, null, 2)}</div>
718
+ <div class="code-block">\${jsonText(ev.args)}</div>
625
719
  <strong style="margin-top: 1rem; display: block;">Result (\${ev.isError ? "Error" : "Success"}):</strong>
626
- <div class="code-block">\${typeof ev.result === "string" ? ev.result : JSON.stringify(ev.result, null, 2)}</div>
720
+ <div class="code-block">\${jsonText(ev.result)}</div>
627
721
  \`;
628
722
  } else if (ev.type === "llm") {
629
723
  // Render system prompt and input messages if present
@@ -636,17 +730,18 @@ function generateHtmlReport(data) {
636
730
  messagesHtml += \`
637
731
  <div class="msg-row system">
638
732
  <span class="msg-role">System Instruction</span>
639
- <span class="msg-text">\${instr}</span>
733
+ <span class="msg-text">\${escapeHtml(instr)}</span>
640
734
  </div>
641
735
  \`;
642
736
  }
643
737
  if (ev.payload.messages && Array.isArray(ev.payload.messages)) {
644
738
  ev.payload.messages.forEach(m => {
645
739
  const contentText = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
740
+ const role = safeClassName(m.role, "message");
646
741
  messagesHtml += \`
647
- <div class="msg-row \${m.role}">
648
- <span class="msg-role">\${m.role}</span>
649
- <span class="msg-text">\${contentText}</span>
742
+ <div class="msg-row \${role}">
743
+ <span class="msg-role">\${escapeHtml(m.role)}</span>
744
+ <span class="msg-text">\${escapeHtml(contentText)}</span>
650
745
  </div>
651
746
  \`;
652
747
  });
@@ -658,13 +753,13 @@ function generateHtmlReport(data) {
658
753
  <strong>Context Messages:</strong>
659
754
  \${messagesHtml}
660
755
  <strong style="margin-top: 1.25rem; display: block;">Generated Response:</strong>
661
- <div class="code-block">\${JSON.stringify(ev.output, null, 2)}</div>
756
+ <div class="code-block">\${jsonText(ev.output)}</div>
662
757
  \${ev.usage ? \`
663
758
  <div style="margin-top: 1rem; font-size: 0.85rem; color: var(--text-muted); display: flex; gap: 1.5rem;">
664
- <span>Input Tokens: <strong>\${ev.usage.input}</strong></span>
665
- <span>Output Tokens: <strong>\${ev.usage.output}</strong></span>
666
- <span>Total Tokens: <strong>\${ev.usage.totalTokens}</strong></span>
667
- <span>Cost: <strong>\$\${ev.usage.cost?.total.toFixed(5) || "0.00"}</strong></span>
759
+ <span>Input Tokens: <strong>\${escapeHtml(ev.usage.input ?? 0)}</strong></span>
760
+ <span>Output Tokens: <strong>\${escapeHtml(ev.usage.output ?? 0)}</strong></span>
761
+ <span>Total Tokens: <strong>\${escapeHtml(ev.usage.totalTokens ?? 0)}</strong></span>
762
+ <span>Cost: <strong>\$\${escapeHtml(ev.usage.cost?.total?.toFixed?.(5) || "0.00")}</strong></span>
668
763
  </div>
669
764
  \` : ""}
670
765
  \`;
@@ -682,7 +777,7 @@ function generateHtmlReport(data) {
682
777
  // Expand/Collapse controller
683
778
  function toggleCard(index) {
684
779
  const card = document.getElementById(\`card-\${index}\`);
685
- card.classList.toggle("expanded");
780
+ card?.classList.toggle("expanded");
686
781
  }
687
782
  </script>
688
783
  </body>
@@ -2,6 +2,79 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
3
3
  import * as path from "node:path";
4
4
 
5
+ const REDACTED = "[REDACTED by notrace]";
6
+ const MAX_STRING_LENGTH = 20_000;
7
+ const MAX_ARRAY_ITEMS = 200;
8
+ const MAX_OBJECT_KEYS = 200;
9
+ const MAX_DEPTH = 8;
10
+
11
+ const SENSITIVE_KEY_RE = /(authorization|cookie|setcookie|password|passwd|pwd|secret|token|apikey|accesskey|accesskeyid|accessid|accesstoken|privatekey|session|credential|refreshtoken|idtoken)/i;
12
+ const SENSITIVE_VALUE_RE = /(bearer\s+[a-z0-9._~+/=-]{12,}|sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9_]{16,}|xox[baprs]-[a-z0-9-]{16,}|AKIA[0-9A-Z]{16})/gi;
13
+
14
+ type CaptureMode = "metadata" | "redacted" | "full";
15
+
16
+ function getCaptureMode(): CaptureMode {
17
+ const mode = process.env.NOTRACE_CAPTURE?.toLowerCase();
18
+ if (mode === "metadata" || mode === "full") return mode;
19
+ return "redacted";
20
+ }
21
+
22
+ function isSensitiveKey(key: string): boolean {
23
+ return SENSITIVE_KEY_RE.test(key.replace(/[^a-z0-9]/gi, ""));
24
+ }
25
+
26
+ function redactString(value: string): string {
27
+ const redacted = value.replace(SENSITIVE_VALUE_RE, REDACTED);
28
+ if (redacted.length <= MAX_STRING_LENGTH) return redacted;
29
+ return `${redacted.slice(0, MAX_STRING_LENGTH)}\n…[truncated ${redacted.length - MAX_STRING_LENGTH} chars by notrace]`;
30
+ }
31
+
32
+ function sanitizeTraceValue(value: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
33
+ if (getCaptureMode() === "full") return value;
34
+ if (value == null || typeof value === "number" || typeof value === "boolean") return value;
35
+ if (typeof value === "string") return redactString(value);
36
+ if (typeof value === "bigint") return value.toString();
37
+ if (typeof value === "function" || typeof value === "symbol") return `[${typeof value}]`;
38
+ if (depth >= MAX_DEPTH) return "[Max depth reached by notrace]";
39
+ if (typeof value !== "object") return String(value);
40
+ if (seen.has(value)) return "[Circular]";
41
+ seen.add(value);
42
+
43
+ if (Array.isArray(value)) {
44
+ const items = value.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitizeTraceValue(item, depth + 1, seen));
45
+ if (value.length > MAX_ARRAY_ITEMS) items.push(`…[truncated ${value.length - MAX_ARRAY_ITEMS} items by notrace]`);
46
+ return items;
47
+ }
48
+
49
+ const output: Record<string, unknown> = {};
50
+ const entries = Object.entries(value as Record<string, unknown>);
51
+ for (const [key, item] of entries.slice(0, MAX_OBJECT_KEYS)) {
52
+ output[key] = isSensitiveKey(key) ? REDACTED : sanitizeTraceValue(item, depth + 1, seen);
53
+ }
54
+ if (entries.length > MAX_OBJECT_KEYS) output.__notrace_truncated__ = `${entries.length - MAX_OBJECT_KEYS} keys`;
55
+ return output;
56
+ }
57
+
58
+ function safeResolveUnder(baseDir: string, candidate: string): string | null {
59
+ const base = path.resolve(baseDir);
60
+ const resolved = path.resolve(base, candidate);
61
+ const relative = path.relative(base, resolved);
62
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved : null;
63
+ }
64
+
65
+ function escapeHtml(value: unknown): string {
66
+ return String(value).replace(/[&<>'"]/g, (char) => {
67
+ switch (char) {
68
+ case "&": return "&amp;";
69
+ case "<": return "&lt;";
70
+ case ">": return "&gt;";
71
+ case "'": return "&#39;";
72
+ case '"': return "&quot;";
73
+ default: return char;
74
+ }
75
+ });
76
+ }
77
+
5
78
  /**
6
79
  * html-observability extension
7
80
  *
@@ -17,26 +90,31 @@ export default function (pi: ExtensionAPI) {
17
90
  let llmStartTime = 0;
18
91
  const activeToolTimes: Record<string, number> = {};
19
92
 
20
- // Helper to extract active task path from .workflow/active_task.json
93
+ // Helper to extract active task path from .workflow/active_task.json.
94
+ // Reports are constrained to cwd/.workflow to avoid task metadata causing writes elsewhere.
21
95
  function getActiveTaskDir(cwd: string): string {
96
+ const workflowDir = path.resolve(cwd, ".workflow");
22
97
  try {
23
- const activeTaskJsonPath = path.join(cwd, ".workflow", "active_task.json");
98
+ const activeTaskJsonPath = path.join(workflowDir, "active_task.json");
24
99
  if (existsSync(activeTaskJsonPath)) {
25
100
  const content = JSON.parse(readFileSync(activeTaskJsonPath, "utf-8"));
26
- if (content.taskPath) {
27
- return path.resolve(cwd, content.taskPath);
28
- } else if (content.active_task) {
29
- return path.resolve(cwd, ".workflow", "tasks", content.active_task);
101
+ const candidate = typeof content.taskPath === "string"
102
+ ? safeResolveUnder(cwd, content.taskPath)
103
+ : typeof content.active_task === "string"
104
+ ? safeResolveUnder(workflowDir, path.join("tasks", content.active_task))
105
+ : null;
106
+
107
+ if (candidate && safeResolveUnder(workflowDir, path.relative(workflowDir, candidate))) {
108
+ return candidate;
30
109
  }
31
110
  }
32
111
  } catch {
33
112
  // fallback
34
113
  }
35
- const defaultDir = path.join(cwd, ".workflow");
36
- if (!existsSync(defaultDir)) {
37
- try { mkdirSync(defaultDir, { recursive: true }); } catch {}
114
+ if (!existsSync(workflowDir)) {
115
+ try { mkdirSync(workflowDir, { recursive: true, mode: 0o700 }); } catch {}
38
116
  }
39
- return defaultDir;
117
+ return workflowDir;
40
118
  }
41
119
 
42
120
  // 1. Session start
@@ -74,7 +152,7 @@ export default function (pi: ExtensionAPI) {
74
152
  type: "tool_start",
75
153
  toolCallId,
76
154
  toolName,
77
- args,
155
+ args: getCaptureMode() === "metadata" ? "[metadata-only capture]" : sanitizeTraceValue(args),
78
156
  timestamp: Date.now()
79
157
  });
80
158
  });
@@ -89,7 +167,7 @@ export default function (pi: ExtensionAPI) {
89
167
  type: "tool_end",
90
168
  toolCallId,
91
169
  toolName,
92
- result,
170
+ result: getCaptureMode() === "metadata" ? "[metadata-only capture]" : sanitizeTraceValue(result),
93
171
  isError,
94
172
  durationMs,
95
173
  timestamp: Date.now()
@@ -99,7 +177,7 @@ export default function (pi: ExtensionAPI) {
99
177
 
100
178
  // 6. LLM call start (capture payload)
101
179
  pi.on("before_provider_request", async (event, ctx) => {
102
- activeLlmPayload = event.payload;
180
+ activeLlmPayload = getCaptureMode() === "metadata" ? null : sanitizeTraceValue(event.payload);
103
181
  llmStartTime = Date.now();
104
182
  });
105
183
 
@@ -115,8 +193,8 @@ export default function (pi: ExtensionAPI) {
115
193
  model: message.model || "unknown",
116
194
  provider: message.provider || "unknown",
117
195
  inputPayload: activeLlmPayload,
118
- outputContent: message.content,
119
- usage: message.usage,
196
+ outputContent: getCaptureMode() === "metadata" ? "[metadata-only capture]" : sanitizeTraceValue(message.content),
197
+ usage: sanitizeTraceValue(message.usage),
120
198
  durationMs,
121
199
  timestamp: Date.now()
122
200
  });
@@ -173,7 +251,8 @@ export default function (pi: ExtensionAPI) {
173
251
  });
174
252
 
175
253
  try {
176
- writeFileSync(reportPath, htmlContent, "utf-8");
254
+ mkdirSync(taskDir, { recursive: true, mode: 0o700 });
255
+ writeFileSync(reportPath, htmlContent, { encoding: "utf-8", mode: 0o600 });
177
256
  // Output a nice clickable file:// link to the console for the user
178
257
  console.log(`\n📊 [notrace] Observability report generated:`);
179
258
  console.log(`👉 \x1b[36mfile://${reportPath}\x1b[0m\n`);
@@ -199,16 +278,16 @@ function safeJsonForScript(value: any): string {
199
278
  // Returns a self-contained premium HTML template incorporating the design tokens
200
279
  function generateHtmlReport(data: any): string {
201
280
  const serializedData = safeJsonForScript(data);
281
+ const escapedTraceId = escapeHtml(data.traceId);
202
282
 
203
283
  return `<!DOCTYPE html>
204
284
  <html lang="en">
205
285
  <head>
206
286
  <meta charset="UTF-8">
207
287
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
208
- <title>notrace - ${data.traceId}</title>
209
- <link rel="preconnect" href="https://fonts.googleapis.com">
210
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
211
- <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Source+Code+Pro:wght@400;500&display=swap" rel="stylesheet">
288
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; connect-src 'none'">
289
+ <meta name="referrer" content="no-referrer">
290
+ <title>notrace - ${escapedTraceId}</title>
212
291
  <style>
213
292
  :root {
214
293
  --bg: #0b0b0e;
@@ -233,7 +312,7 @@ function generateHtmlReport(data: any): string {
233
312
  }
234
313
 
235
314
  body {
236
- font-family: 'Outfit', sans-serif;
315
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
237
316
  background-color: var(--bg);
238
317
  color: var(--text);
239
318
  line-height: 1.5;
@@ -439,7 +518,7 @@ function generateHtmlReport(data: any): string {
439
518
  }
440
519
 
441
520
  .code-block {
442
- font-family: 'Source Code Pro', monospace;
521
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
443
522
  font-size: 0.875rem;
444
523
  background: rgba(0, 0, 0, 0.35);
445
524
  border: 1px solid var(--border);
@@ -541,18 +620,34 @@ function generateHtmlReport(data: any): string {
541
620
  <script>
542
621
  const traceData = ${serializedData};
543
622
 
623
+ function escapeHtml(value) {
624
+ return String(value ?? "").replace(/[&<>'"]/g, (char) => ({
625
+ "&": "&amp;",
626
+ "<": "&lt;",
627
+ ">": "&gt;",
628
+ "'": "&#39;",
629
+ '"': "&quot;"
630
+ }[char]));
631
+ }
632
+
633
+ function safeClassName(value, fallback = "unknown") {
634
+ const normalized = String(value ?? fallback).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
635
+ return normalized || fallback;
636
+ }
637
+
638
+ function jsonText(value) {
639
+ return escapeHtml(typeof value === "string" ? value : JSON.stringify(value, null, 2));
640
+ }
641
+
544
642
  // Render Metrics
545
643
  document.getElementById("sess-id").textContent = traceData.traceId;
546
644
  document.getElementById("sess-time").textContent = new Date(traceData.startTime).toLocaleString();
547
645
  document.getElementById("val-duration").textContent = (traceData.durationMs / 1000).toFixed(2) + "s";
548
646
  document.getElementById("val-tokens").textContent = traceData.metrics.totalTokens.toLocaleString();
549
- document.getElementById("val-llms").textContent = traceData.metrics.toolCallCount; // Wait, actually traceData.metrics.llmCallCount
647
+ document.getElementById("val-llms").textContent = traceData.metrics.llmCallCount;
550
648
  document.getElementById("val-tools").textContent = traceData.metrics.toolCallCount;
551
649
  document.getElementById("val-cost").textContent = "$" + traceData.metrics.totalCost;
552
650
 
553
- // Correct the labels mapping if names swapped
554
- document.getElementById("val-llms").textContent = traceData.metrics.llmCallCount;
555
-
556
651
  // Process & Render Timeline
557
652
  const container = document.getElementById("timeline-container");
558
653
  const events = traceData.events;
@@ -612,19 +707,20 @@ function generateHtmlReport(data: any): string {
612
707
  // Render cards
613
708
  renderedEvents.forEach((ev, index) => {
614
709
  const evDiv = document.createElement("div");
615
- evDiv.className = \`timeline-event \${ev.type}-start \${ev.isError ? "error" : ""}\`;
710
+ const eventType = safeClassName(ev.type);
711
+ evDiv.className = \`timeline-event \${eventType}-start \${ev.isError ? "error" : ""}\`;
616
712
 
617
713
  let cardHtml = \`
618
714
  <div class="timeline-dot"></div>
619
715
  <div class="card" id="card-\${index}">
620
716
  <div class="card-header" onclick="toggleCard(\${index})">
621
717
  <div class="card-title">
622
- <span class="card-badge badge-\${ev.type} \${ev.isError ? "error" : ""}">\${ev.type.toUpperCase()}</span>
623
- <span>\${ev.title}</span>
624
- \${ev.durationMs ? \`<span class="duration-pill">\${(ev.durationMs / 1000).toFixed(2)}s</span>\` : ""}
718
+ <span class="card-badge badge-\${eventType} \${ev.isError ? "error" : ""}">\${escapeHtml(eventType.toUpperCase())}</span>
719
+ <span>\${escapeHtml(ev.title)}</span>
720
+ \${ev.durationMs ? \`<span class="duration-pill">\${escapeHtml((ev.durationMs / 1000).toFixed(2))}s</span>\` : ""}
625
721
  </div>
626
722
  <div class="card-time">
627
- <span>\${ev.time}</span>
723
+ <span>\${escapeHtml(ev.time)}</span>
628
724
  <svg class="arrow-icon" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
629
725
  </div>
630
726
  </div>
@@ -632,13 +728,13 @@ function generateHtmlReport(data: any): string {
632
728
  \`;
633
729
 
634
730
  if (ev.type === "session" || ev.type === "turn") {
635
- cardHtml += \`<div class="code-block">\${ev.body}</div>\`;
731
+ cardHtml += \`<div class="code-block">\${escapeHtml(ev.body)}</div>\`;
636
732
  } else if (ev.type === "tool") {
637
733
  cardHtml += \`
638
734
  <strong>Arguments:</strong>
639
- <div class="code-block">\${JSON.stringify(ev.args, null, 2)}</div>
735
+ <div class="code-block">\${jsonText(ev.args)}</div>
640
736
  <strong style="margin-top: 1rem; display: block;">Result (\${ev.isError ? "Error" : "Success"}):</strong>
641
- <div class="code-block">\${typeof ev.result === "string" ? ev.result : JSON.stringify(ev.result, null, 2)}</div>
737
+ <div class="code-block">\${jsonText(ev.result)}</div>
642
738
  \`;
643
739
  } else if (ev.type === "llm") {
644
740
  // Render system prompt and input messages if present
@@ -651,17 +747,18 @@ function generateHtmlReport(data: any): string {
651
747
  messagesHtml += \`
652
748
  <div class="msg-row system">
653
749
  <span class="msg-role">System Instruction</span>
654
- <span class="msg-text">\${instr}</span>
750
+ <span class="msg-text">\${escapeHtml(instr)}</span>
655
751
  </div>
656
752
  \`;
657
753
  }
658
754
  if (ev.payload.messages && Array.isArray(ev.payload.messages)) {
659
755
  ev.payload.messages.forEach(m => {
660
756
  const contentText = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
757
+ const role = safeClassName(m.role, "message");
661
758
  messagesHtml += \`
662
- <div class="msg-row \${m.role}">
663
- <span class="msg-role">\${m.role}</span>
664
- <span class="msg-text">\${contentText}</span>
759
+ <div class="msg-row \${role}">
760
+ <span class="msg-role">\${escapeHtml(m.role)}</span>
761
+ <span class="msg-text">\${escapeHtml(contentText)}</span>
665
762
  </div>
666
763
  \`;
667
764
  });
@@ -673,13 +770,13 @@ function generateHtmlReport(data: any): string {
673
770
  <strong>Context Messages:</strong>
674
771
  \${messagesHtml}
675
772
  <strong style="margin-top: 1.25rem; display: block;">Generated Response:</strong>
676
- <div class="code-block">\${JSON.stringify(ev.output, null, 2)}</div>
773
+ <div class="code-block">\${jsonText(ev.output)}</div>
677
774
  \${ev.usage ? \`
678
775
  <div style="margin-top: 1rem; font-size: 0.85rem; color: var(--text-muted); display: flex; gap: 1.5rem;">
679
- <span>Input Tokens: <strong>\${ev.usage.input}</strong></span>
680
- <span>Output Tokens: <strong>\${ev.usage.output}</strong></span>
681
- <span>Total Tokens: <strong>\${ev.usage.totalTokens}</strong></span>
682
- <span>Cost: <strong>\$\${ev.usage.cost?.total.toFixed(5) || "0.00"}</strong></span>
776
+ <span>Input Tokens: <strong>\${escapeHtml(ev.usage.input ?? 0)}</strong></span>
777
+ <span>Output Tokens: <strong>\${escapeHtml(ev.usage.output ?? 0)}</strong></span>
778
+ <span>Total Tokens: <strong>\${escapeHtml(ev.usage.totalTokens ?? 0)}</strong></span>
779
+ <span>Cost: <strong>\$\${escapeHtml(ev.usage.cost?.total?.toFixed?.(5) || "0.00")}</strong></span>
683
780
  </div>
684
781
  \` : ""}
685
782
  \`;
@@ -697,7 +794,7 @@ function generateHtmlReport(data: any): string {
697
794
  // Expand/Collapse controller
698
795
  function toggleCard(index) {
699
796
  const card = document.getElementById(\`card-\${index}\`);
700
- card.classList.toggle("expanded");
797
+ card?.classList.toggle("expanded");
701
798
  }
702
799
  </script>
703
800
  </body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raquezha/notrace",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Zero-dependency, local-first interactive HTML Trace Viewer for the Pi Coding Agent",
5
5
  "main": "dist/notrace.js",
6
6
  "types": "dist/notrace.d.ts",