feat(security): wire logAttempt to gstack-telemetry-log (fire-and-forget)

Every local attempt.jsonl write now also triggers a subprocess call to
gstack-telemetry-log with the attack_attempt event type. The binary handles
tier gating internally (community → Supabase upload, anonymous → local
JSONL only, off → no-op), so security.ts doesn't need to re-check.

Binary resolution follows the skill preamble pattern — never relies on PATH,
which breaks in compiled-binary contexts:

  1. ~/.claude/skills/gstack/bin/gstack-telemetry-log  (global install)
  2. .claude/skills/gstack/bin/gstack-telemetry-log    (symlinked dev)
  3. bin/gstack-telemetry-log                          (in-repo dev)

Fire-and-forget:
  * spawn with stdio: 'ignore', detached: true, unref()
  * .on('error') swallows failures
  * Missing binary is non-fatal — local attempts.jsonl still gives audit trail

Never throws. Never blocks. Existing 37 security tests pass unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-19 19:16:26 +08:00
parent 28ce883ca5
commit f68fa4a9ee
+67 -2
View File
@@ -20,6 +20,7 @@
*/
import { randomBytes, createHash } from 'crypto';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -258,10 +259,74 @@ function rotateIfNeeded(): void {
}
/**
* Append an attempt to the local log. Never throws — logging failure should
* not break the sidebar. Returns true if the write succeeded.
* Try to locate the gstack-telemetry-log binary. Resolution order matches
* the existing skill preamble pattern (never relies on PATH — packaged
* binary layouts can break that).
*
* Order:
* 1. ~/.claude/skills/gstack/bin/gstack-telemetry-log (global install)
* 2. .claude/skills/gstack/bin/gstack-telemetry-log (symlinked dev)
* 3. bin/gstack-telemetry-log (in-repo dev)
*/
function findTelemetryBinary(): string | null {
const candidates = [
path.join(os.homedir(), '.claude', 'skills', 'gstack', 'bin', 'gstack-telemetry-log'),
path.resolve(process.cwd(), '.claude', 'skills', 'gstack', 'bin', 'gstack-telemetry-log'),
path.resolve(process.cwd(), 'bin', 'gstack-telemetry-log'),
];
for (const c of candidates) {
try {
fs.accessSync(c, fs.constants.X_OK);
return c;
} catch {
// try next
}
}
return null;
}
/**
* Fire-and-forget subprocess invocation of gstack-telemetry-log with the
* attack_attempt event type. The binary handles tier gating internally
* (community → upload, anonymous → local only, off → no-op), so we don't
* need to re-check here.
*
* Never throws. Never blocks. If the binary isn't found or spawn fails, the
* local attempts.jsonl write from logAttempt() still gives us the audit trail.
*/
function reportAttemptTelemetry(record: AttemptRecord): void {
const bin = findTelemetryBinary();
if (!bin) return;
try {
const child = spawn(bin, [
'--event-type', 'attack_attempt',
'--url-domain', record.urlDomain || '',
'--payload-hash', record.payloadHash,
'--confidence', String(record.confidence),
'--layer', record.layer,
'--verdict', record.verdict,
], {
stdio: 'ignore',
detached: true,
});
// unref so this subprocess doesn't hold the event loop open
child.unref();
child.on('error', () => { /* swallow — telemetry must never break sidebar */ });
} catch {
// Spawn failure is non-fatal.
}
}
/**
* Append an attempt to the local log AND fire telemetry via
* gstack-telemetry-log (which respects the user's telemetry tier setting).
* Never throws — logging failure should not break the sidebar.
* Returns true if the local write succeeded.
*/
export function logAttempt(record: AttemptRecord): boolean {
// Fire telemetry first, async — even if local write fails, we still want
// the event reported (it goes to a different directory anyway).
reportAttemptTelemetry(record);
try {
fs.mkdirSync(SECURITY_DIR, { recursive: true, mode: 0o700 });
rotateIfNeeded();