diff --git a/browse/src/security.ts b/browse/src/security.ts index a5d27ff2ad..2770096e6a 100644 --- a/browse/src/security.ts +++ b/browse/src/security.ts @@ -330,6 +330,33 @@ function findTelemetryBinary(): string | null { return null; } +/** + * Build the [cmd, args] tuple for invoking a bash-script telemetry binary + * in a way that works on both POSIX and Windows. + * + * POSIX: returns [bin, args] unchanged — shebang gets honored by execve. + * Win32: wraps in bash explicitly. `gstack-telemetry-log` is a shell script + * (`#!/usr/bin/env bash`) and Windows `CreateProcess` can't dispatch on a + * shebang — it tries to load the file as a PE image, fails with ENOEXEC, + * and our 'error' handler silently swallows it. Most Windows dev boxes + * running gstack ship Git Bash, which puts bash.exe on PATH; if bash is + * missing, spawn() will still fire 'error' and the same swallow path kicks + * in. Either way, the local `attempts.jsonl` write in logAttempt() keeps + * the audit trail intact. + * + * Exported for testability — resolution is a pure function of (platform, + * bin, args) so we can assert on it without actually spawning. + */ +export function buildTelemetrySpawnCommand( + bin: string, + args: string[], +): { cmd: string; cmdArgs: string[] } { + if (process.platform === 'win32') { + return { cmd: 'bash', cmdArgs: [bin, ...args] }; + } + return { cmd: bin, cmdArgs: args }; +} + /** * Fire-and-forget subprocess invocation of gstack-telemetry-log with the * attack_attempt event type. The binary handles tier gating internally @@ -343,14 +370,15 @@ function reportAttemptTelemetry(record: AttemptRecord): void { const bin = findTelemetryBinary(); if (!bin) return; try { - const child = spawn(bin, [ + const { cmd, cmdArgs } = buildTelemetrySpawnCommand(bin, [ '--event-type', 'attack_attempt', '--url-domain', record.urlDomain || '', '--payload-hash', record.payloadHash, '--confidence', String(record.confidence), '--layer', record.layer, '--verdict', record.verdict, - ], { + ]); + const child = spawn(cmd, cmdArgs, { stdio: 'ignore', detached: true, }); diff --git a/browse/test/security.test.ts b/browse/test/security.test.ts index bf8064c039..d524147ee9 100644 --- a/browse/test/security.test.ts +++ b/browse/test/security.test.ts @@ -20,6 +20,7 @@ import { readSessionState, getStatus, extractDomain, + buildTelemetrySpawnCommand, type LayerSignal, } from '../src/security'; @@ -320,3 +321,32 @@ describe('extractDomain', () => { expect(extractDomain('')).toBe(''); }); }); + +// ─── Telemetry spawn command (Windows bash wrapper) ────────── + +describe('buildTelemetrySpawnCommand', () => { + const bin = '/home/user/.claude/skills/gstack/bin/gstack-telemetry-log'; + const args = ['--event-type', 'attack_attempt', '--confidence', '0.95']; + + test('on POSIX, returns the binary path and args unchanged', () => { + if (process.platform === 'win32') return; + const out = buildTelemetrySpawnCommand(bin, args); + expect(out.cmd).toBe(bin); + expect(out.cmdArgs).toEqual(args); + }); + + test('on win32, wraps the call in bash with the script as first arg', () => { + if (process.platform !== 'win32') return; + const out = buildTelemetrySpawnCommand(bin, args); + expect(out.cmd).toBe('bash'); + // Script path must come first so bash treats it as the file to execute, + // followed by the original telemetry flags as bash's positional args. + expect(out.cmdArgs).toEqual([bin, ...args]); + }); + + test('does not mutate the caller-supplied args array', () => { + const originalArgs = [...args]; + buildTelemetrySpawnCommand(bin, args); + expect(args).toEqual(originalArgs); + }); +});