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)`);