Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions browse/src/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
});
Expand Down
30 changes: 30 additions & 0 deletions browse/test/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
readSessionState,
getStatus,
extractDomain,
buildTelemetrySpawnCommand,
type LayerSignal,
} from '../src/security';

Expand Down Expand Up @@ -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);
});
});