feat(security): add persistent command audit log

Append-only JSONL audit trail for all browse server commands. Unlike
in-memory ring buffers, the audit log persists across restarts and is
never truncated. Each entry records: timestamp, command, args (truncated
to 200 chars), page origin, duration, status, error (truncated to 300
chars), hasCookies flag, connection mode.

All writes are best-effort — audit failures never block command execution.
Log stored at ~/.gstack/.browse/browse-audit.jsonl.

Closes #617

Co-Authored-By: Alberto Martinez <halbert04@users.noreply.github.com>
This commit is contained in:
Garry Tan
2026-04-13 09:40:42 -07:00
parent daf13d822c
commit f93ffe56cc
3 changed files with 96 additions and 2 deletions
+65
View File
@@ -0,0 +1,65 @@
/**
* Persistent command audit log — forensic trail for all browse server commands.
*
* Writes append-only JSONL to .gstack/browse-audit.jsonl. Unlike the in-memory
* ring buffers (console, network, dialog), the audit log persists across server
* restarts and is never truncated by the server. Each entry records:
*
* - timestamp, command, args (truncated), page origin
* - duration, status (ok/error), error message if any
* - whether cookies were imported (elevated security context)
* - connection mode (headless/headed)
*
* All writes are best-effort — audit failures never cause command failures.
*/
import * as fs from 'fs';
export interface AuditEntry {
ts: string;
cmd: string;
args: string;
origin: string;
durationMs: number;
status: 'ok' | 'error';
error?: string;
hasCookies: boolean;
mode: 'launched' | 'headed';
}
const MAX_ARGS_LENGTH = 200;
const MAX_ERROR_LENGTH = 300;
let auditPath: string | null = null;
export function initAuditLog(logPath: string): void {
auditPath = logPath;
}
export function writeAuditEntry(entry: AuditEntry): void {
if (!auditPath) return;
try {
const truncatedArgs = entry.args.length > MAX_ARGS_LENGTH
? entry.args.slice(0, MAX_ARGS_LENGTH) + '…'
: entry.args;
const truncatedError = entry.error && entry.error.length > MAX_ERROR_LENGTH
? entry.error.slice(0, MAX_ERROR_LENGTH) + '…'
: entry.error;
const record: Record<string, unknown> = {
ts: entry.ts,
cmd: entry.cmd,
args: truncatedArgs,
origin: entry.origin,
durationMs: entry.durationMs,
status: entry.status,
hasCookies: entry.hasCookies,
mode: entry.mode,
};
if (truncatedError) record.error = truncatedError;
fs.appendFileSync(auditPath, JSON.stringify(record) + '\n');
} catch {
// Audit write failures are silent — never block command execution
}
}
+2
View File
@@ -20,6 +20,7 @@ export interface BrowseConfig {
consoleLog: string;
networkLog: string;
dialogLog: string;
auditLog: string;
}
/**
@@ -70,6 +71,7 @@ export function resolveConfig(
consoleLog: path.join(stateDir, 'browse-console.log'),
networkLog: path.join(stateDir, 'browse-network.log'),
dialogLog: path.join(stateDir, 'browse-dialog.log'),
auditLog: path.join(stateDir, 'browse-audit.jsonl'),
};
}
+29 -2
View File
@@ -35,6 +35,7 @@ import {
import { validateTempPath } from './path-security';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
import { initAuditLog, writeAuditEntry } from './audit';
import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector';
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
// fail posix_spawn on all executables including /bin/bash)
@@ -47,6 +48,7 @@ import * as crypto from 'crypto';
// ─── Config ─────────────────────────────────────────────────────
const config = resolveConfig();
ensureStateDir(config);
initAuditLog(config.auditLog);
// ─── Auth ───────────────────────────────────────────────────────
const AUTH_TOKEN = crypto.randomUUID();
@@ -1088,13 +1090,14 @@ async function handleCommandInternal(
}
// Activity: emit command_end (skipped for chain subcommands)
const successDuration = Date.now() - startTime;
if (!opts?.skipActivity) {
emitActivity({
type: 'command_end',
command,
args,
url: browserManager.getCurrentUrl(),
duration: Date.now() - startTime,
duration: successDuration,
status: 'ok',
result: result,
tabs: browserManager.getTabCount(),
@@ -1103,6 +1106,17 @@ async function handleCommandInternal(
});
}
writeAuditEntry({
ts: new Date().toISOString(),
cmd: command,
args: args.join(' '),
origin: browserManager.getCurrentUrl(),
durationMs: successDuration,
status: 'ok',
hasCookies: browserManager.hasCookieImports(),
mode: browserManager.getConnectionMode(),
});
browserManager.resetFailures();
// Restore original active tab if we pinned to a specific one
if (savedTabId !== null) {
@@ -1120,13 +1134,14 @@ async function handleCommandInternal(
}
// Activity: emit command_end (error) — skipped for chain subcommands
const errorDuration = Date.now() - startTime;
if (!opts?.skipActivity) {
emitActivity({
type: 'command_end',
command,
args,
url: browserManager.getCurrentUrl(),
duration: Date.now() - startTime,
duration: errorDuration,
status: 'error',
error: err.message,
tabs: browserManager.getTabCount(),
@@ -1135,6 +1150,18 @@ async function handleCommandInternal(
});
}
writeAuditEntry({
ts: new Date().toISOString(),
cmd: command,
args: args.join(' '),
origin: browserManager.getCurrentUrl(),
durationMs: errorDuration,
status: 'error',
error: err.message,
hasCookies: browserManager.hasCookieImports(),
mode: browserManager.getConnectionMode(),
});
browserManager.incrementFailures();
let errorMsg = wrapError(err);
const hint = browserManager.getFailureHint();