Root cause
Path & link handling introduced
feat(v2): statusline architecture - Phase 1 complete
How AI contributed: Direct introductionPublished May 18, 2026 in
jarrodwatts/claude-hudAll 2 findings in this repository
Claude HUD through 0.0.12, patched in commit 234d9aa, contains a path traversal vulnerability that allows attackers to read arbitrary files by supplying an unvalidated transcript_path value via stdin JSON. Attackers can access any file readable by the process and the file metadata is written to a persistent cache file with insufficient permissions, creating a forensic record of accessed paths that survives process exit.
The rewritten reader in src/transcript.ts took transcript_path straight from caller-supplied stdin JSON, so stat, cache lookup, and stream creation all followed an arbitrary path, exposing any file the process could read and caching its metadata with permissions wider than the owner's. The fix canonicalizes the path before those operations and writes cache files owner-private.
Root cause
feat(v2): statusline architecture - Phase 1 complete
Fix
fix(security): harden links and Windows version lookup (#487)
Fix by Jarrod Watts · no AI marker found
Case facts
@@ -1,307 +0,0 @@-import * as fs from 'fs';-import type { ContextHealth, ContextBreakdown, HudEvent } from './types.js';-import { logger } from './logger.js';-import {- COMPACTION_THRESHOLD,- WARNING_THRESHOLD,- CHARS_PER_TOKEN,- DEFAULT_MAX_TOKENS,- TOKEN_HISTORY_SIZE,- BURN_RATE_SAMPLE_SIZE,-} from './constants.js';--const SPARKLINE_SAMPLES = 20;--interface TokenSample {- tokens: number;- timestamp: number;-}--interface TranscriptUsage {- input_tokens?: number;- output_tokens?: number;- cache_creation_input_tokens?: number;- cache_read_input_tokens?: number;-}--interface TranscriptMessage {- message?: {- model?: string;- usage?: TranscriptUsage;- };- type?: string;-}--const MODEL_CONTEXT_LIMITS: Record<string, number> = {- 'claude-opus-4-5': DEFAULT_MAX_TOKENS,- 'claude-sonnet-4': DEFAULT_MAX_TOKENS,- 'claude-haiku-3-5': DEFAULT_MAX_TOKENS,- default: DEFAULT_MAX_TOKENS,-};--function getContextLimit(model: string | null): number {- if (!model) return MODEL_CONTEXT_LIMITS['default'];- for (const [key, limit] of Object.entries(MODEL_CONTEXT_LIMITS)) {- if (model.includes(key)) return limit;- }- return MODEL_CONTEXT_LIMITS['default'];-}--export class UnifiedContextTracker {- private transcriptPath: string | null = null;- private transcriptModified: number = 0;- private model: string | null = null;- private realTokens: number = 0;- private estimatedDelta: number = 0;- private breakdown: ContextBreakdown = {- toolOutputs: 0,- toolInputs: 0,- messages: 0,- other: 0,- };- private tokenHistory: TokenSample[] = [];- private sessionStart: number;- private lastUpdate: number;- private compactionCount: number = 0;- private transcriptOffset = 0;- private transcriptRemainder = '';- private transcriptUsage: TranscriptUsage | null = null;-- constructor() {- this.sessionStart = Date.now();- this.lastUpdate = this.sessionStart;- }-- setTranscriptPath(path: string): void {- if (this.transcriptPath !== path) {- this.transcriptPath = path;- this.resetTranscriptState();- this.readTranscript();- }- }-- private estimateTokens(text: string): number {- if (!text) return 0;- return Math.ceil(text.length / CHARS_PER_TOKEN);- }-- processEvent(event: HudEvent): void {- this.lastUpdate = Date.now();@@ -1,335 +0,0 @@-import { describe, it, expect, beforeEach } from 'vitest';-import * as fs from 'fs';-import * as os from 'os';-import * as path from 'path';-import { UnifiedContextTracker } from './unified-context-tracker.js';-import type { HudEvent } from './types.js';--function createEvent(overrides: Partial<HudEvent> = {}): HudEvent {- return {- event: 'PostToolUse',- tool: 'Read',- toolUseId: `tool-${Date.now()}`,- input: { file_path: '/test/file.ts' },- response: { content: 'file content here' },- session: 'test-session',- ts: Math.floor(Date.now() / 1000),- ...overrides,- };-}--describe('UnifiedContextTracker', () => {- let tracker: UnifiedContextTracker;-- beforeEach(() => {- tracker = new UnifiedContextTracker();- });-- describe('getHealth', () => {- it('returns initial health with zero tokens', () => {- const health = tracker.getHealth();-- expect(health.tokens).toBe(0);- expect(health.percent).toBe(0);- expect(health.remaining).toBe(200000);- expect(health.maxTokens).toBe(200000);- expect(health.status).toBe('healthy');- expect(health.shouldCompact).toBe(false);- });- });-- describe('processEvent', () => {- it('estimates tokens from PostToolUse events', () => {- const event = createEvent({- input: { content: 'a'.repeat(400) },- response: { result: 'b'.repeat(400) },- });-- tracker.processEvent(event);- const health = tracker.getHealth();-- expect(health.tokens).toBeGreaterThan(0);- expect(health.breakdown.toolInputs).toBeGreaterThan(0);- expect(health.breakdown.toolOutputs).toBeGreaterThan(0);- });-- it('tracks compaction count from PreCompact events', () => {- expect(tracker.getCompactionCount()).toBe(0);-- tracker.processEvent(createEvent({ event: 'PreCompact' }));- expect(tracker.getCompactionCount()).toBe(1);-- tracker.processEvent(createEvent({ event: 'PreCompact' }));- expect(tracker.getCompactionCount()).toBe(2);- });-- it('reads new content even when mtime unchanged but file grew', () => {- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-hud-mtime-'));- const transcriptPath = path.join(tmpDir, 'transcript.jsonl');-- const first = {- type: 'assistant',- message: { model: 'claude-sonnet-4', usage: { input_tokens: 100, output_tokens: 50 } },- };- fs.writeFileSync(transcriptPath, `${JSON.stringify(first)}\n`, 'utf-8');-- tracker.setTranscriptPath(transcriptPath);- tracker.processEvent(createEvent({ event: 'Stop' }));-- const firstHealth = tracker.getHealth();- expect(firstHealth.tokens).toBe(150);-- // Append new content without changing mtime (simulates rapid writes)- const second = {- type: 'assistant',- message: { model: 'claude-sonnet-4', usage: { input_tokens: 300, output_tokens: 200 } },- };- fs.appendFileSync(transcriptPath, `${JSON.stringify(second)}\n`, 'utf-8');-- // Force mtime to stay the same (simulates filesystem granularity issue)@@ -1,341 +0,0 @@-import { describe, expect, it } from 'vitest';-import { createInitialHudState } from './hud-state.js';-import { reduceHudState } from './hud-reducer.js';-import type { HudEvent } from '../lib/types.js';--function createState() {- return createInitialHudState({- context: {- tokens: 0,- percent: 0,- remaining: 0,- maxTokens: 0,- burnRate: 0,- status: 'healthy',- shouldCompact: false,- breakdown: { toolOutputs: 0, toolInputs: 0, messages: 0, other: 0 },- sessionStart: 0,- lastUpdate: 0,- tokenHistory: [],- },- cost: { inputCost: 0, outputCost: 0, totalCost: 0, inputTokens: 0, outputTokens: 0 },- });-}--describe('reduceHudState', () => {- it('tracks tool lifecycle from PreToolUse to PostToolUse', () => {- const state = createState();- const preEvent: HudEvent = {- schemaVersion: 1,- event: 'PreToolUse',- tool: 'Read',- toolUseId: 'tool-1',- input: { file_path: 'README.md' },- response: null,- session: 's1',- ts: 100,- };-- const afterPre = reduceHudState(state, { type: 'event', event: preEvent, now: 1000 });- expect(afterPre.tools).toHaveLength(1);- expect(afterPre.tools[0]?.status).toBe('running');- expect(afterPre.sessionInfo.isIdle).toBe(false);- expect(afterPre.runningTools.has('tool-1')).toBe(true);-- const postEvent: HudEvent = {- schemaVersion: 1,- event: 'PostToolUse',- tool: 'Read',- toolUseId: 'tool-1',- input: null,- response: { duration_ms: 250 },- session: 's1',- ts: 100,- };-- const afterPost = reduceHudState(afterPre, { type: 'event', event: postEvent, now: 1300 });- expect(afterPost.tools[0]?.status).toBe('complete');- expect(afterPost.runningTools.has('tool-1')).toBe(false);- expect(afterPost.tools[0]?.duration).toBe(250);- });-- it('applies todo updates and agent lifecycle', () => {- const state = createState();- const todoEvent: HudEvent = {- schemaVersion: 1,- event: 'PreToolUse',- tool: 'TodoWrite',- toolUseId: 'todo-1',- input: { todos: [{ content: 'Ship HUD tests', status: 'in_progress' }] },- response: null,- session: 's1',- ts: 200,- };- const afterTodo = reduceHudState(state, { type: 'event', event: todoEvent, now: 2000 });- expect(afterTodo.todos).toHaveLength(1);-- const taskEvent: HudEvent = {- schemaVersion: 1,- event: 'PreToolUse',- tool: 'Task',- toolUseId: 'agent-1',- input: { subagent_type: 'Research', description: 'Check HUD output' },- response: null,- session: 's1',- ts: 300,- };- const afterTask = reduceHudState(afterTodo, { type: 'event', event: taskEvent, now: 3000 });- expect(afterTask.agents).toHaveLength(1);- expect(afterTask.agents[0]?.status).toBe('running');@@ -94,6 +94,14 @@ function getTranscriptCachePath(transcriptPath: string, homeDir: string): string return path.join(getHudPluginDir(homeDir), 'transcript-cache', `${hash}.json`); } +function canonicalizeTranscriptPath(transcriptPath: string): string | null {+ try {+ return fs.realpathSync(transcriptPath);+ } catch {+ return null;+ }+}+ function readTranscriptFileState(transcriptPath: string): TranscriptFileState | null { try { const stat = fs.statSync(transcriptPath);@@ -181,7 +189,7 @@ function writeTranscriptCache(transcriptPath: string, state: TranscriptFileState transcriptState: state, data: serializeTranscriptData(data), };- fs.writeFileSync(cachePath, JSON.stringify(payload), 'utf8');+ fs.writeFileSync(cachePath, JSON.stringify(payload), { encoding: 'utf8', mode: 0o600 }); } catch { // Cache failures are non-fatal; fall back to fresh parsing next time. }@@ -198,12 +206,17 @@ export async function parseTranscript(transcriptPath: string): Promise<Transcrip return result; } - const transcriptState = readTranscriptFileState(transcriptPath);+ const canonicalTranscriptPath = canonicalizeTranscriptPath(transcriptPath);+ if (!canonicalTranscriptPath) {+ return result;+ }++ const transcriptState = readTranscriptFileState(canonicalTranscriptPath); if (!transcriptState) { return result; } - const cached = readTranscriptCache(transcriptPath, transcriptState);+ const cached = readTranscriptCache(canonicalTranscriptPath, transcriptState); if (cached) { return cached; }@@ -224,7 +237,7 @@ export async function parseTranscript(transcriptPath: string): Promise<Transcrip let parsedCleanly = false; try {- const fileStream = createReadStreamImpl(transcriptPath);+ const fileStream = createReadStreamImpl(canonicalTranscriptPath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity,@@ -265,7 +278,7 @@ export async function parseTranscript(transcriptPath: string): Promise<Transcrip result.sessionName = customTitle ?? latestSlug; result.sessionTokens = sessionTokens; if (parsedCleanly) {- writeTranscriptCache(transcriptPath, transcriptState, result);+ writeTranscriptCache(canonicalTranscriptPath, transcriptState, result); } return result;@@ -1,18 +1,60 @@ import * as fs from 'node:fs'; import * as path from 'node:path';+import { pathToFileURL } from 'node:url'; import type { RenderContext } from '../../types.js'; import { getModelName, formatModelName, getProviderLabel } from '../../stdin.js'; import { getOutputSpeed } from '../../speed-tracker.js'; import { git as gitColor, gitBranch as gitBranchColor, warning as warningColor, critical as criticalColor, label, model as modelColor, project as projectColor, red, green, yellow, dim, custom as customColor } from '../colors.js'; import { t } from '../../i18n/index.js'; import { renderCostEstimate } from './cost.js'; +const CONTROL_AND_BIDI_PATTERN = /[\^@-\^_\u007F-\^_\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069\u206A-\u206F]/g;+ function hyperlink(uri: string, text: string): string { const esc = '\x1b'; const st = '\\'; return `${esc}]8;;${uri}${esc}${st}${text}${esc}]8;;${esc}${st}`; } +function sanitizeDisplayText(value: string): string {+ return value.replace(CONTROL_AND_BIDI_PATTERN, '');+}++function getFileHref(filePath: string): string | null {+ try {+ return pathToFileURL(path.resolve(filePath)).toString();+ } catch {+ return null;+ }+}++function resolvePathWithinCwd(cwd: string, candidatePath: string): string | null {+ const resolvedCwd = path.resolve(cwd);+ const resolvedPath = path.resolve(cwd, candidatePath);+ const relative = path.relative(resolvedCwd, resolvedPath);+ if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {+ return resolvedPath;+ }+ return null;+}++function safeHyperlink(uri: string | undefined | null, text: string): string {+ if (!uri) {+ return text;+ }++ const sanitizedUri = sanitizeDisplayText(uri);+ try {+ const parsed = new URL(sanitizedUri);+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'file:') {+ return text;+ }+ return hyperlink(parsed.toString(), text);+ } catch {+ return text;+ }+}+ export function renderProjectLine(ctx: RenderContext): string | null { const display = ctx.config?.display; const colors = ctx.config?.colors;@@ -35,9 +77,9 @@ export function renderProjectLine(ctx: RenderContext): string | null { if (display?.showProject !== false && ctx.stdin.cwd) { const segments = ctx.stdin.cwd.split(/[/\\]/).filter(Boolean); const pathLevels = ctx.config?.pathLevels ?? 1;- const projectPath = segments.length > 0 ? segments.slice(-pathLevels).join('/') : '/';+ const projectPath = sanitizeDisplayText(segments.length > 0 ? segments.slice(-pathLevels).join('/') : '/'); const coloredProject = projectColor(projectPath, colors);- projectPart = hyperlink(`file://${ctx.stdin.cwd}`, coloredProject);+ projectPart = safeHyperlink(getFileHref(ctx.stdin.cwd), coloredProject); } let gitPart = '';@@ -46,9 +88,11 @@ export function renderProjectLine(ctx: RenderContext): string | null { const branchOverflow = gitConfig?.branchOverflow ?? 'truncate'; if (showGit && ctx.gitStatus) {- const branchText = ctx.gitStatus.branch + ((gitConfig?.showDirty ?? true) && ctx.gitStatus.isDirty ? '*' : '');+ const branchText = sanitizeDisplayText(+ ctx.gitStatus.branch + ((gitConfig?.showDirty ?? true) && ctx.gitStatus.isDirty ? '*' : '')+ ); const coloredBranch = gitBranchColor(branchText, colors);- const linkedBranch = ctx.gitStatus.branchUrl ? hyperlink(ctx.gitStatus.branchUrl, coloredBranch) : coloredBranch;+ const linkedBranch = safeHyperlink(ctx.gitStatus.branchUrl, coloredBranch); const gitInner: string[] = [linkedBranch]; if (gitConfig?.showAheadBehind) {@@ -156,8 +200,10 @@ export function renderGitFilesLine(ctx: RenderContext, terminalWidth: number | n const cwd = ctx.stdin.cwd; const sorted = [...trackedFiles].sort((a, b) => {@@ -41,7 +41,7 @@ const defaultExecFile: ExecFileImpl = promisify(execFile) as ExecFileImpl; let execFileImpl: ExecFileImpl = defaultExecFile; let resolveClaudeBinaryImpl: () => ClaudeBinaryInfo | null = resolveClaudeBinaryFromPath; let platformImpl: () => NodeJS.Platform = () => process.platform;-let comspecImpl: () => string | undefined = () => process.env.COMSPEC;+let windowsCmdImpl: () => string = () => 'C:\\Windows\\System32\\cmd.exe'; let cachedBinaryKey: string | undefined; let cachedVersion: string | undefined; let hasResolved = false;@@ -199,13 +199,13 @@ export function _parseClaudeCodeVersion(output: string): string | undefined { export function _getClaudeVersionInvocation( binaryPath: string, platform: NodeJS.Platform = platformImpl(),- comspec: string | undefined = comspecImpl()+ windowsCmd: string = windowsCmdImpl() ): ClaudeVersionInvocation { const ext = path.extname(binaryPath).toLowerCase(); if (platform === 'win32' && (ext === '.cmd' || ext === '.bat')) { const command = [quoteForCmd(binaryPath), '--version'].join(' '); return {- file: comspec || 'cmd.exe',+ file: windowsCmd, args: ['/d', '/s', '/c', `"${command}"`], }; }@@ -297,8 +297,8 @@ export function _setResolveClaudeBinaryForTests(impl: (() => ClaudeBinaryInfo | export function _setVersionInvocationEnvForTests( platformGetter: (() => NodeJS.Platform) | null,- comspecGetter: (() => string | undefined) | null+ windowsCmdGetter: (() => string) | null ): void { platformImpl = platformGetter ?? (() => process.platform);- comspecImpl = comspecGetter ?? (() => process.env.COMSPEC);+ windowsCmdImpl = windowsCmdGetter ?? (() => 'C:\\Windows\\System32\\cmd.exe'); }AI-assisted change 312405a91fe18d8ff08230dea3f9ec8c65dcdaa77a1051679067fe2317a2af60 · Fix 224e09de6005e4c67fe1064834d3437dde151954a43dfddd36b1ceb4f35a4ded