From 91523e3afdcba578e7373263c4db97419a9504eb Mon Sep 17 00:00:00 2001 From: Alberto Martinez Date: Sat, 28 Mar 2026 21:51:41 -0700 Subject: [PATCH] security: add persistent command audit log to browse server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every command dispatched through the browse server is now logged to .gstack/browse-audit.jsonl as an append-only JSONL file. Each entry records: timestamp, command, args (truncated to 200 chars), page origin URL, duration, status (ok/error), error message, whether cookies have been imported, and connection mode (headless/headed). Unlike the in-memory ring buffers (console, network, dialog) which are capped at 50K entries and lost on restart, the audit log persists across server restarts and is never truncated by the server. This creates a forensic trail for post-incident analysis — if an agent performed unexpected actions (e.g., navigated to a sensitive domain, ran JS with imported cookies), the audit log shows exactly what happened and when. The audit log is best-effort: write failures are silently ignored and never cause command failures. The hasCookies flag makes it easy to filter for elevated-risk commands (sessions with imported browser cookies). Changes: - browse/src/audit.ts: new module with initAuditLog() and writeAuditEntry() - browse/src/config.ts: added auditLog path to BrowseConfig - browse/src/server.ts: initialize audit log, write entries on command success and error - browse/src/browser-manager.ts: added hasCookieImports() tracking - browse/src/write-commands.ts: mark cookies imported on cookie-import and cookie-import-browser Made-with: Cursor --- browse/src/audit.ts | 65 +++++++++++++++++++++++++++++++++++ browse/src/browser-manager.ts | 12 +++++++ browse/src/config.ts | 2 ++ browse/src/server.ts | 31 +++++++++++++++-- browse/src/write-commands.ts | 2 ++ 5 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 browse/src/audit.ts diff --git a/browse/src/audit.ts b/browse/src/audit.ts new file mode 100644 index 000000000..5ac59f6d4 --- /dev/null +++ b/browse/src/audit.ts @@ -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 = { + 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 + } +} diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index a6eda991b..b41644635 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -57,6 +57,9 @@ export class BrowserManager { private dialogAutoAccept: boolean = true; private dialogPromptText: string | null = null; + // ─── Cookie Import Tracking (for audit log) ─────────────── + private _hasCookieImports: boolean = false; + // ─── Handoff State ───────────────────────────────────────── private isHeaded: boolean = false; private consecutiveFailures: number = 0; @@ -521,6 +524,15 @@ export class BrowserManager { return this.dialogPromptText; } + // ─── Cookie Import Tracking ──────────────────────────────── + markCookiesImported(): void { + this._hasCookieImports = true; + } + + hasCookieImports(): boolean { + return this._hasCookieImports; + } + // ─── Viewport ────────────────────────────────────────────── async setViewport(width: number, height: number) { await this.getPage().setViewportSize({ width, height }); diff --git a/browse/src/config.ts b/browse/src/config.ts index 04f166433..002b77ab1 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -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'), }; } diff --git a/browse/src/server.ts b/browse/src/server.ts index f3f8d68dd..7c6721d37 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -23,6 +23,7 @@ import { COMMAND_DESCRIPTIONS } from './commands'; import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity'; +import { initAuditLog, writeAuditEntry } from './audit'; // Bun.spawn used instead of child_process.spawn (compiled bun binaries // fail posix_spawn on all executables including /bin/bash) import * as fs from 'fs'; @@ -33,6 +34,7 @@ import * as crypto from 'crypto'; // ─── Config ───────────────────────────────────────────────────── const config = resolveConfig(); ensureStateDir(config); +initAuditLog(config.auditLog); // ─── Auth ─────────────────────────────────────────────────────── const AUTH_TOKEN = crypto.randomUUID(); @@ -707,18 +709,30 @@ async function handleCommand(body: any): Promise { } // Activity: emit command_end (success) + const successDuration = Date.now() - startTime; emitActivity({ type: 'command_end', command, args, url: browserManager.getCurrentUrl(), - duration: Date.now() - startTime, + duration: successDuration, status: 'ok', result: result, tabs: browserManager.getTabCount(), mode: browserManager.getConnectionMode(), }); + 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(); return new Response(result, { status: 200, @@ -726,18 +740,31 @@ async function handleCommand(body: any): Promise { }); } catch (err: any) { // Activity: emit command_end (error) + const errorDuration = Date.now() - startTime; emitActivity({ type: 'command_end', command, args, url: browserManager.getCurrentUrl(), - duration: Date.now() - startTime, + duration: errorDuration, status: 'error', error: err.message, tabs: browserManager.getTabCount(), mode: browserManager.getConnectionMode(), }); + 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(); diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 02413daf8..aa5022cd0 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -314,6 +314,7 @@ export async function handleWriteCommand( } await page.context().addCookies(cookies); + bm.markCookiesImported(); return `Loaded ${cookies.length} cookies from ${filePath}`; } @@ -333,6 +334,7 @@ export async function handleWriteCommand( const result = await importCookies(browser, [domain], profile); if (result.cookies.length > 0) { await page.context().addCookies(result.cookies); + bm.markCookiesImported(); } const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`]; if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);