diff --git a/browse/src/audit.ts b/browse/src/audit.ts index f93b47fb0..830e9c09c 100644 --- a/browse/src/audit.ts +++ b/browse/src/audit.ts @@ -5,14 +5,21 @@ * 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 + * - timestamp, command, args (typed values redacted, then truncated), page origin * - duration, status (ok/error), error message if any * - whether cookies were imported (elevated security context) * - connection mode (headless/headed) * + * The log is plain text on disk, so every value a command types (a fill + * value, typed text, a js expression, a prompt answer, a cookie, header, + * storage or skill-arg value) is replaced with [REDACTED] before truncation, + * and the error text is scrubbed of the same values. Navigation and click + * args are kept as typed. + * * All writes are best-effort — audit failures never cause command failures. */ +import { ALL_COMMANDS, canonicalizeCommand } from './commands'; import { appendSecureFile } from './file-permissions'; export interface AuditEntry { @@ -21,7 +28,7 @@ export interface AuditEntry { /** 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; + args: string[]; origin: string; durationMs: number; status: 'ok' | 'error'; @@ -33,8 +40,132 @@ export interface AuditEntry { const MAX_ARGS_LENGTH = 200; const MAX_ERROR_LENGTH = 300; +const REDACTED = '[REDACTED]'; + let auditPath: string | null = null; +interface RedactedArgs { + args: string[]; + secrets: string[]; +} + +function redactAfterSeparator(args: string[], sep: string): RedactedArgs { + const joined = args.join(' '); + const at = joined.indexOf(sep); + if (at <= 0) return { args: [REDACTED], secrets: [joined] }; + return { args: [joined.slice(0, at + 1) + REDACTED], secrets: [joined.slice(at + 1).trim()] }; +} + +/** + * A chain line keeps only the step names that are real commands, never their + * args: every step writes its own audit line with its exact args redacted per + * command, and a second parse of the flow text here could differ from chain's. + */ +function redactChain(flow: string): RedactedArgs { + let steps: string[][]; + try { + const parsed = JSON.parse(flow); + if (!Array.isArray(parsed) || !parsed.every(step => Array.isArray(step))) return { args: [REDACTED], secrets: [flow] }; + steps = parsed.map(step => step.map(String)); + } catch { + steps = flow.split(' | ').map(seg => seg.trim().split(/\s+/)); + } + const names = steps.map(([name = '']) => (ALL_COMMANDS.has(canonicalizeCommand(name)) ? name : REDACTED)); + const secrets = steps.map((step, i) => (names[i] === REDACTED ? step : step.slice(1)).join(' ')); + return { args: [names.join(' | ')], secrets }; +} + +/** Replace the typed value of every value-carrying command with [REDACTED]. */ +export function redactCommandArgs(rawCmd: string, args: string[]): RedactedArgs { + if (args.length === 0) return { args, secrets: [] }; + const cmd = canonicalizeCommand(rawCmd); + switch (cmd) { + case 'fill': + if (args.length < 2) return { args: [REDACTED], secrets: [args.join(' ')] }; + return { args: [args[0], REDACTED], secrets: [args.slice(1).join(' ')] }; + case 'type': { + const kept: string[] = []; + let i = 0; + if (args[i] === '--selector') { kept.push(...args.slice(i, i + 2)); i += 2; } + if (args[i] === '--') { kept.push('--'); i += 1; } + const text = args.slice(i); + if (text.length === 0) return { args: [REDACTED], secrets: [args.join(' ')] }; + return { args: [...kept, REDACTED], secrets: [text.join(' ')] }; + } + case 'js': { + const kept: string[] = []; + const expr: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--out' && i + 1 < args.length) { kept.push('--out', args[++i]); continue; } + if (args[i] === '--raw') { kept.push('--raw'); continue; } + expr.push(args[i]); + } + if (expr.length === 0) return { args, secrets: [] }; + return { args: [REDACTED, ...kept], secrets: [expr.join(' ')] }; + } + case 'dialog-accept': + return { args: [REDACTED], secrets: [args.join(' ')] }; + case 'cookie': + return redactAfterSeparator(args, '='); + case 'header': + return redactAfterSeparator(args, ':'); + case 'storage': + if (args[0] !== 'set' || args.length < 2) return { args, secrets: [] }; + if (args.length < 3) return { args: [args[0], REDACTED], secrets: [args[1]] }; + return { args: [args[0], args[1], REDACTED], secrets: [args.slice(2).join(' ')] }; + case 'skill': { + if (args[0] !== 'run' || args.length < 3) return { args, secrets: [] }; + const out = args.slice(0, 2); + const secrets: string[] = []; + for (const a of args.slice(2)) { + if (a === '--arg' || a.startsWith('--timeout=')) { out.push(a); continue; } + const eq = a.indexOf('='); + out.push(eq > 0 ? a.slice(0, eq + 1) + REDACTED : REDACTED); + secrets.push(eq > 0 ? a.slice(eq + 1) : a); + } + return { args: out, secrets }; + } + case 'tab-each': { + if (!ALL_COMMANDS.has(canonicalizeCommand(args[0]))) return { args: [REDACTED], secrets: [args.join(' ')] }; + const inner = redactCommandArgs(args[0], args.slice(1)); + return { args: [args[0], ...inner.args], secrets: inner.secrets }; + } + case 'chain': + return redactChain(args[0]); + default: + return { args, secrets: [] }; + } +} + +/** + * Remove every redacted value, its JSON-escaped form and each 4+ char piece of it + * (split on whitespace and on punctuation, since a js error echoes a bare + * identifier) from text. Needles under 6 chars match only as whole tokens, so a + * short value neither shreds the error nor shows through the text around it. + */ +export function scrubSecrets(text: string, secrets: string[]): string { + const needles = new Set(); + for (const secret of secrets) { + if (!secret) continue; + needles.add(secret); + needles.add(JSON.stringify(secret).slice(1, -1)); + for (const piece of [...secret.split(/\s+/), ...secret.split(/[^A-Za-z0-9_$.-]+/)]) { + if (piece.length >= 4) { + needles.add(piece); + needles.add(JSON.stringify(piece).slice(1, -1)); + } + } + } + let out = text; + for (const needle of [...needles].sort((a, b) => b.length - a.length)) { + if (!needle) continue; + if (needle.length >= 6) { out = out.split(needle).join(REDACTED); continue; } + const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + out = out.replace(new RegExp(`(? 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 redacted = redactCommandArgs(entry.cmd, entry.args); + const args = redacted.args.join(' '); + const error = entry.error ? scrubSecrets(entry.error, redacted.secrets) : entry.error; + const truncatedArgs = args.length > MAX_ARGS_LENGTH + ? args.slice(0, MAX_ARGS_LENGTH) + '…' + : args; + const truncatedError = error && error.length > MAX_ERROR_LENGTH + ? error.slice(0, MAX_ERROR_LENGTH) + '…' + : error; const record: Record = { ts: entry.ts, diff --git a/browse/src/buffers.ts b/browse/src/buffers.ts index 27d379694..315433839 100644 --- a/browse/src/buffers.ts +++ b/browse/src/buffers.ts @@ -135,3 +135,9 @@ export function addNetworkEntry(entry: NetworkEntry) { export function addDialogEntry(entry: DialogEntry) { dialogBuffer.push(entry); } + +/** One browse-dialog.log line. The prompt response is text the agent typed (dialog-accept), so it never reaches disk. */ +export function formatDialogLogLine(e: DialogEntry): string { + const response = e.response ? ' "[REDACTED]"' : ''; + return `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${response}`; +} diff --git a/browse/src/server.ts b/browse/src/server.ts index 96e02ae09..cfcb926b4 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -608,7 +608,7 @@ function generateHelpText(): string { } // ─── Buffer (from buffers.ts) ──────────────────────────────────── -import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers'; +import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, formatDialogLogLine, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers'; export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry }; @@ -675,10 +675,7 @@ async function flushBuffers() { const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed; if (newDialogCount > 0) { const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length)); - const lines = entries.map(e => - `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` - ).join('\n') + '\n'; - appendSecureFile(DIALOG_LOG_PATH, lines); + appendSecureFile(DIALOG_LOG_PATH, entries.map(formatDialogLogLine).join('\n') + '\n'); lastDialogFlushed = dialogBuffer.totalAdded; } } catch (err: any) { @@ -1259,7 +1256,7 @@ async function handleCommandInternalImpl( ts: new Date().toISOString(), cmd: command, aliasOf: isAliased ? rawCommand : undefined, - args: args.join(' '), + args, origin: browserManager.getCurrentUrl(), durationMs: successDuration, status: 'ok', @@ -1291,7 +1288,7 @@ async function handleCommandInternalImpl( ts: new Date().toISOString(), cmd: command, aliasOf: isAliased ? rawCommand : undefined, - args: args.join(' '), + args, origin: browserManager.getCurrentUrl(), durationMs: errorDuration, status: 'error', diff --git a/browse/test/audit-redaction.test.ts b/browse/test/audit-redaction.test.ts new file mode 100644 index 000000000..bf117f54e --- /dev/null +++ b/browse/test/audit-redaction.test.ts @@ -0,0 +1,323 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { initAuditLog, writeAuditEntry } from '../src/audit'; +import { formatDialogLogLine } from '../src/buffers'; +import { handleMetaCommand } from '../src/meta-commands'; + +// GST-1631: the audit log is plain JSONL in the state dir, so every value a +// command types (fill, type, js, dialog-accept, cookie, header, storage set, +// skill --arg, and the same inside tab-each and chain) must be redacted before +// truncation and before the line is written. Navigation and click lines stay +// readable. Error text is scrubbed of the same values. + +let dir: string; +let logPath: string; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-audit-redact-')); + logPath = path.join(dir, 'browse-audit.jsonl'); + initAuditLog(logPath); +}); + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +function write(cmd: string, args: string[], error?: string) { + writeAuditEntry({ + ts: '2026-11-06T00:00:00.000Z', + cmd, + args, + origin: 'https://example.com/login', + durationMs: 1, + status: error ? 'error' : 'ok', + error, + hasCookies: false, + mode: 'launched', + }); +} + +function raw(): string { + return fs.readFileSync(logPath, 'utf8'); +} + +function line(): Record { + const lines = raw().trim().split('\n'); + return JSON.parse(lines[lines.length - 1]); +} + +describe('audit args redaction (on-disk line)', () => { + it('fill keeps the selector and hides a one-word value', () => { + write('fill', ['#password', 'hunter2']); + expect(raw()).not.toContain('hunter2'); + expect(line().args).toBe('#password [REDACTED]'); + }); + + it('fill hides a multi-word value whole', () => { + write('fill', ['input[name=pw]', 'correct', 'horse', 'battery']); + expect(raw()).not.toMatch(/correct|horse|battery/); + expect(line().args).toBe('input[name=pw] [REDACTED]'); + }); + + it('type hides the text and keeps --selector and --', () => { + write('type', ['s3cretPass']); + expect(line().args).toBe('[REDACTED]'); + write('type', ['--selector', '#pw', '--', '--s3cretPass']); + expect(raw()).not.toContain('s3cretPass'); + expect(line().args).toBe('--selector #pw -- [REDACTED]'); + }); + + it('js hides the expression and keeps --out and --raw', () => { + write('js', ['localStorage.setItem("tok","sk-live-123")']); + expect(line().args).toBe('[REDACTED]'); + write('js', ['document.cookie="sid=sk-live-123"', '--out', '/tmp/out.txt', '--raw']); + expect(raw()).not.toContain('sk-live-123'); + expect(line().args).toBe('[REDACTED] --out /tmp/out.txt --raw'); + }); + + it('dialog-accept hides the prompt text, and a bare dialog-accept stays empty', () => { + write('dialog-accept', ['my', 'pin', '4321']); + expect(raw()).not.toContain('4321'); + expect(line().args).toBe('[REDACTED]'); + write('dialog-accept', []); + expect(line().args).toBe(''); + }); + + it('cookie keeps the name and hides the value; no separator hides it whole', () => { + write('cookie', ['session=abc123xyz']); + expect(line().args).toBe('session=[REDACTED]'); + write('cookie', ['abc123xyz']); + expect(line().args).toBe('[REDACTED]'); + expect(raw()).not.toContain('abc123xyz'); + }); + + it('header keeps the name and hides the value, quoted or split into words', () => { + write('header', ['Authorization: Bearer tok_live_999']); + expect(line().args).toBe('Authorization:[REDACTED]'); + write('header', ['Authorization:', 'Bearer', 'tok_live_999']); + expect(line().args).toBe('Authorization:[REDACTED]'); + write('header', ['tok_live_999']); + expect(line().args).toBe('[REDACTED]'); + expect(raw()).not.toContain('tok_live_999'); + }); + + it('storage set keeps the key and hides the value; a storage read is unchanged', () => { + write('storage', ['set', 'authToken', 'eyJhbGciOi.payload.sig']); + expect(raw()).not.toContain('eyJhbGciOi'); + expect(line().args).toBe('set authToken [REDACTED]'); + write('storage', []); + expect(line().args).toBe(''); + }); + + it('skill run keeps the skill name, arg keys and --timeout, and hides arg values', () => { + write('skill', ['run', 'login', '--arg', 'password=pa55word!', '--arg', 'user=garry', '--timeout=30']); + expect(raw()).not.toContain('pa55word!'); + expect(line().args).toBe('run login --arg password=[REDACTED] --arg user=[REDACTED] --timeout=30'); + write('skill', ['list']); + expect(line().args).toBe('list'); + }); + + it('tab-each redacts the command it wraps', () => { + write('tab-each', ['fill', '#otp', '998877']); + expect(raw()).not.toContain('998877'); + expect(line().args).toBe('fill #otp [REDACTED]'); + write('tab-each', ['goto', 'https://example.com/a']); + expect(line().args).toBe('goto https://example.com/a'); + }); + + it('chain logs only its step names, in JSON and pipe form', () => { + write('chain', [JSON.stringify([['goto', 'https://example.com/login'], ['fill', '#pw', 'hunter2'], ['cookie', 'sid=zz9plural'], ['click', '@e3']])]); + expect(raw()).not.toMatch(/hunter2|zz9plural/); + expect(line().args).toBe('goto | fill | cookie | click'); + write('chain', ['goto https://example.com | fill #pw "two words" | click @e3']); + expect(raw()).not.toMatch(/two words|"two|words"/); + expect(line().args).toBe('goto | fill | click'); + }); + + it('a chain that is not a command list is hidden whole', () => { + write('chain', ['{"fill":"hunter2"}']); + expect(raw()).not.toContain('hunter2'); + expect(line().args).toBe('[REDACTED]'); + }); + + it('leaves navigation and click lines unchanged', () => { + write('goto', ['https://example.com/login?next=/home']); + expect(line().args).toBe('https://example.com/login?next=/home'); + write('click', ['@e3']); + expect(line().args).toBe('@e3'); + write('select', ['#country', 'United', 'States']); + expect(line().args).toBe('#country United States'); + }); +}); + +describe('redaction runs before truncation', () => { + it('a long fill value never leaks its head through the 200-char cut', () => { + const secret = 'S'.repeat(20) + 'x'.repeat(480); + write('fill', ['#password', secret]); + expect(raw()).not.toContain('SSSS'); + expect(line().args).toBe('#password [REDACTED]'); + }); + + it('a long chain is redacted first and then truncated', () => { + const steps = Array.from({ length: 30 }, (_, i) => ['fill', `#f${i}`, `secretvalue${i}`]); + write('chain', [JSON.stringify(steps)]); + const args = line().args as string; + expect(raw()).not.toContain('secretvalue'); + expect(args.length).toBeLessThanOrEqual(201); + expect(args.endsWith('…')).toBe(true); + }); +}); + +describe('error text is scrubbed of the typed values', () => { + it('scrubs a fill value echoed by the page error', () => { + write('fill', ['#password', 'hunter2'], 'locator.fill: could not fill "#password" with "hunter2"'); + expect(raw()).not.toContain('hunter2'); + expect(line().error).toBe('locator.fill: could not fill "#password" with "[REDACTED]"'); + }); + + it('scrubs each word of a multi-word value and its JSON-escaped form', () => { + write('type', ['say', '"quoted"', 'battery'], 'Timeout typing "say \\"quoted\\" battery"; last word battery'); + expect(raw()).not.toMatch(/quoted|battery/); + }); + + it('scrubs secrets from nested chain and tab-each commands', () => { + write('chain', [JSON.stringify([['header', 'X-Api-Key: k3y-abcdef']])], 'header rejected: k3y-abcdef'); + expect(raw()).not.toContain('k3y-abcdef'); + write('tab-each', ['cookie', 'sid=c00kievalue'], 'cookie c00kievalue invalid'); + expect(raw()).not.toContain('c00kievalue'); + }); + + it('scrubs before the 300-char cut, so a value straddling it leaves no prefix', () => { + write('fill', ['#pw', 'hunter2secret'], 'e'.repeat(295) + 'hunter2secret'); + expect(raw()).not.toContain('hunte'); + }); + + it('leaves errors of value-free commands unchanged', () => { + write('goto', ['https://example.com'], 'net::ERR_NAME_NOT_RESOLVED at https://example.com'); + expect(line().error).toBe('net::ERR_NAME_NOT_RESOLVED at https://example.com'); + }); + + it('pipe-form chain tokenizes quoted values like chain does, so an unquoted echo is scrubbed', () => { + write('chain', ['goto https://example.com | fill #pw "correct horse"'], 'fill failed: value correct horse rejected'); + expect(raw()).not.toMatch(/correct|horse/); + expect(line().args).toBe('goto | fill'); + }); + + it('pipe-form js with a quoted inner --out keeps nothing of the expression', () => { + write('chain', ['goto https://x | js "a --out b"']); + expect(line().args).toBe('goto | js'); + }); + + it('scrubs a token a js error echoes without its surrounding punctuation', () => { + write('js', ["localStorage.setItem('t', sk_live_abcdef123)"], 'ReferenceError: sk_live_abcdef123 is not defined'); + expect(raw()).not.toContain('sk_live_abcdef123'); + }); + + it('an empty quoted selector in pipe form cannot shift the value into a kept position', () => { + write('chain', ['fill "" "my-super-secret" | type --selector "" "my-super-secret" | storage set "" "my-super-secret"']); + expect(raw()).not.toContain('my-super-secret'); + }); + + it('a tab or newline inside a pipe-form segment never leaves the value in plain text', () => { + write('chain', ['goto https://x | fill #pw\nhunter2secret | fill\t#pw\thunter2secret | tab-each fill\t#pw\thunter2secret'], 'Unknown command: fill\t#pw\thunter2secret'); + expect(raw()).not.toContain('hunter2secret'); + }); + + it('matches a short value only as a whole token', () => { + write('type', ['a'], 'type a: failed at a step'); + expect(line().error).toBe('type [REDACTED]: failed at [REDACTED] step'); + write('fill', ['#otp', '5000'], 'locator.fill: Timeout 5000ms exceeded; value 5000 rejected'); + expect(line().error).toBe('locator.fill: Timeout 5000ms exceeded; value [REDACTED] rejected'); + }); +}); + +describe('chain line keeps only real command names (fix-check shapes)', () => { + it('an empty quoted selector before an unquoted multi-word value leaves nothing', () => { + write('chain', ['fill "" correct horse battery']); + expect(line().args).toBe('fill'); + write('chain', ['type --selector "" correct horse']); + expect(line().args).toBe('type'); + write('chain', ['storage set "" a b']); + expect(line().args).toBe('storage'); + expect(raw()).not.toMatch(/correct|horse|battery/); + }); + + it('a separator other than " | " leaves nothing of the following step', () => { + write('chain', ['goto https://x |\nfill #pw hunter2secret']); + expect(line().args).toBe('goto'); + write('chain', ['click @e1 |fill #pw hunter2secret']); + expect(line().args).toBe('click'); + expect(raw()).not.toContain('hunter2secret'); + }); + + it('a tab inside a step leaves nothing of its args', () => { + write('chain', ['goto https://x | storage set\tauthToken hunter2secret']); + expect(line().args).toBe('goto | storage'); + write('chain', ['goto https://x | skill run\tlogin password=hunter2secret']); + expect(line().args).toBe('goto | skill'); + write('chain', ['goto https://x | skill run login --timeout=30\tpassword=hunter2secret']); + expect(line().args).toBe('goto | skill'); + expect(raw()).not.toMatch(/hunter2secret|authToken/); + }); + + it('a step name that is not a command is redacted', () => { + write('chain', ['hunter2secret | goto https://x']); + expect(line().args).toBe('[REDACTED] | goto'); + write('chain', [JSON.stringify([['hunter2secret', 'x']])]); + expect(line().args).toBe('[REDACTED]'); + expect(raw()).not.toContain('hunter2secret'); + }); + + it('each step still writes its own audit line with its args redacted per command', async () => { + const bm = { getPage: () => ({ waitForLoadState: async () => {} }) } as any; + const executeCommand = async (body: { command: string; args?: string[] }) => { + write(body.command, body.args ?? []); + return { status: 200, result: 'ok' }; + }; + const flows = [ + 'goto https://example.com/login | fill #pw "correct horse" | cookie sid=zz9plural | click @e3', + JSON.stringify([['goto', 'https://example.com/login'], ['fill', '#pw', 'correct horse'], ['cookie', 'sid=zz9plural'], ['click', '@e3']]), + ]; + for (const flow of flows) { + fs.rmSync(logPath, { force: true }); + await handleMetaCommand('chain', [flow], bm, async () => {}, null, { executeCommand }); + write('chain', [flow]); + const lines = raw().trim().split('\n').map(l => JSON.parse(l)); + expect(lines.map(l => [l.cmd, l.args])).toEqual([ + ['goto', 'https://example.com/login'], + ['fill', '#pw [REDACTED]'], + ['cookie', 'sid=[REDACTED]'], + ['click', '@e3'], + ['chain', 'goto | fill | cookie | click'], + ]); + expect(raw()).not.toMatch(/correct|horse|zz9plural/); + } + }); + + it('the server hands each command its args unjoined', () => { + const src = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf8'); + const calls = src.split('writeAuditEntry({').slice(1).map(c => c.slice(0, c.indexOf('});'))); + expect(calls.length).toBe(2); + for (const call of calls) expect(call).toMatch(/\n\s+args,\n/); + }); +}); + +describe('dialog log line (browse-dialog.log)', () => { + it('does not persist the prompt response typed through dialog-accept', () => { + const out = formatDialogLogLine({ timestamp: 0, type: 'prompt', message: 'Enter PIN', action: 'accepted', response: '4321' }); + expect(out).not.toContain('4321'); + expect(out).toBe('[1970-01-01T00:00:00.000Z] [prompt] "Enter PIN" → accepted "[REDACTED]"'); + }); + + it('keeps lines without a response as before', () => { + expect(formatDialogLogLine({ timestamp: 0, type: 'alert', message: 'Saved', action: 'dismissed' })) + .toBe('[1970-01-01T00:00:00.000Z] [alert] "Saved" → dismissed'); + }); + + it('is what the server flush writes', () => { + const src = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf8'); + expect(src).toContain('appendSecureFile(DIALOG_LOG_PATH, entries.map(formatDialogLogLine)'); + }); +}); diff --git a/changelog.d/gst-1631-browse-audit-redaction.md b/changelog.d/gst-1631-browse-audit-redaction.md new file mode 100644 index 000000000..f4218b6f2 --- /dev/null +++ b/changelog.d/gst-1631-browse-audit-redaction.md @@ -0,0 +1 @@ +- **The browse audit log no longer stores typed secrets.** `.gstack/browse-audit.jsonl` wrote each command's arguments verbatim, so a password passed to `fill`, text sent with `type`, a `js` expression, a `dialog-accept` answer, a `cookie`, `header` or `storage set` value, and a `skill run --arg` value all reached disk in plain text, and an error that echoed the value stored a second copy. Each of those values, including inside `tab-each` and `chain`, is now written as `[REDACTED]` before the line is truncated, with the selector, cookie or header name, storage key, arg key and `--out` path kept, and the error text is scrubbed of the same values. Navigation and click lines are unchanged. `browse-dialog.log` no longer stores the prompt answer either. Lines written before this release still hold the old values; delete `.gstack/browse-audit.jsonl` and `.gstack/browse-dialog.log` to clear them. Reported in #4057 by @derHaken. (GST-1631)