Files
gstack/browse/src/audit.ts
Garry Tan c258e03125 fix: pre-landing review fixes (9 findings from specialist + adversarial review)
Adversarial review (Claude subagent + Codex) surfaced 9 bugs across
CRITICAL/HIGH severity. All fixed:

1. tab-session.ts:setTabContent — state mutation moved AFTER the setContent
   await. Prior order left phantom HTML in replay metadata if setContent
   threw (timeout, browser crash), which a later viewport --scale would
   silently replay. Now loadedHtml is only recorded on successful load.

2. browser-manager.ts:setDeviceScaleFactor — rollback now forces a second
   recreateContext after restoring the old fields. The fallback path in
   the original recreateContext builds a blank context using whatever
   this.deviceScaleFactor/currentViewport hold at that moment (which were
   the NEW values we were trying to apply). Rolling back the fields without
   a second recreate left the live context at new-scale while state tracked
   old-scale. Now: restore fields, force re-recreate with old values, only
   if that ALSO fails do we return a combined error.

3. commands.ts:buildUnknownCommandError — Levenshtein tiebreak simplified
   to 'd <= 2 && d < bestDist' (strict less). Candidates are pre-sorted
   alphabetically, so first equal-distance wins by default. The prior
   '(d === bestDist && best !== undefined && cand < best)' clause was dead
   code.

4. tab-session.ts:onMainFrameNavigated — now clears loadedHtml, not just
   refs + frame. Without this, a user who load-html'd then clicked a link
   (or had a form submit / JS redirect / OAuth flow) would retain the stale
   replay metadata. The next viewport --scale would silently revert the
   tab to the ORIGINAL loaded HTML, losing whatever the post-navigation
   content was. Silent data corruption. Browser-emitted navigations trigger
   this path via wirePageEvents.

5. browser-manager.ts:saveState + restoreState — tab ownership now flows
   through BrowserState.owner. Without this, a scoped agent's viewport
   --scale would strand them: tab IDs change during recreate, ownership
   map held stale IDs, owner lookup failed. New IDs had no owner, so
   writes without tabId were denied (DoS). Worse, if the agent sent a
   stale tabId the server's swallowed-tab-switch-error path would let the
   command hit whatever tab was currently active (cross-tab authz bypass).
   Now: clear ownership before restore, re-add per-tab with new IDs.

6. meta-commands.ts:state load — disk-loaded state.pages is now explicit
   allowlist (url, isActive, storage:null) instead of object spread.
   Spreading accepted loadedHtml, loadedHtmlWaitUntil, and owner from a
   user-writable state file, letting a tampered state.json smuggle HTML
   past load-html's safe-dirs / extension / magic-byte / 50MB-cap
   validators, or forge tab ownership. Now stripped at the boundary.

7. url-validation.ts:normalizeFileUrl — preserves query string + fragment
   across normalization. file://./app.html?route=home#login previously
   resolved to a filesystem path that URL-encoded '?' as %3F and '#' as
   %23, or (for absolute forms) pathToFileURL dropped them entirely. SPAs
   and fixture URLs with query params 404'd or loaded the wrong route.
   Now: split on ?/# before path resolution, reattach after.

8. url-validation.ts:validateNavigationUrl — reattaches parsed.search +
   parsed.hash to the normalized file:// URL. Same fix at the main
   validator for absolute paths that go through fileURLToPath round-trip.

9. server.ts:writeAuditEntry — audit entries now include aliasOf when the
   user typed an alias ('setcontent' → cmd: 'load-html', aliasOf:
   'setcontent'). Previously the isAliased variable was computed but
   dropped, losing the raw input from the forensic trail. Completes the
   plan's codex v3 P2 requirement.

Also added bm.getCurrentViewport() and switched 'viewport --scale'-
without-size to read from it (more reliable than page.viewportSize() on
headed/transition contexts).

Tests pass: exit 0, no failures. Build clean.
2026-04-18 23:05:23 +08:00

70 lines
2.1 KiB
TypeScript

/**
* 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;
/** If the agent typed an alias (e.g. 'setcontent'), the raw input is preserved here
* while `cmd` holds the canonical name ('load-html'). Omitted when cmd === rawCmd. */
aliasOf?: 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 (entry.aliasOf) record.aliasOf = entry.aliasOf;
if (truncatedError) record.error = truncatedError;
fs.appendFileSync(auditPath, JSON.stringify(record) + '\n');
} catch {
// Audit write failures are silent — never block command execution
}
}