browse: redact typed values from the audit and dialog logs (GST-1631) (#4073)

* browse: redact typed values from the audit and dialog logs (GST-1631)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Review-Finding: p1/specialist-batched#1 reader=security
Review-Finding: p1/red-team#4 reader=red-team
Review-Finding: p1/red-team#2 reader=red-team
Review-Finding: p1/red-team#3 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: redact malformed and unknown chain steps in the audit log

Review-Finding: v2/outside-voice#1 reader=outside-voice
Review-Finding: v2/adversarial-native#1 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: chain audit line logs only real command names (GST-1631)

Review-Finding: fc/fix-check#1 reader=fix-check

Review-Finding: fc/fix-check#2 reader=fix-check

Review-Finding: fc/fix-check#3 reader=fix-check
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-06 15:29:40 +00:00
1 parent 8a3d809384
commit b10cc47efe
5 files changed
+476 -15

No files matched your search

+142 -8
View File
@@ -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<string>();
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(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, 'g'), REDACTED);
}
return out;
}
export function initAuditLog(logPath: string): void {
auditPath = logPath;
}
@@ -42,12 +173,15 @@ export function initAuditLog(logPath: string): void {
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 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<string, unknown> = {
ts: entry.ts,
+6
View File
@@ -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}`;
}
+4 -7
View File
@@ -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',
+323
View File
@@ -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<string, any> {
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)');
});
});
@@ -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)