v1.88.1.0 fix: harden credential boundaries and owned state (#2942)

* fix(settings): preserve symlinked settings targets

Resolve the selected target for locking, mutation, backup, and rollback; refuse target changes and preserve private modes. Addresses #2830.

* fix(redact): bind masking to original detected spans

Inspired by #2929's anchored-span diagnosis; independently implemented using normalization offsets. Addresses #2930 and the relocation portion of #2912 without changing detection sensitivity.

* fix(evals): exclude operator credentials from prefix admission

Adapts the credential-suffix screen proposed in #2636, with real launched-child regression coverage and deliberate provider-auth exceptions.

* fix(artifacts): retain custom allowlist rules on reinitialization

Preserve the exact user-owned suffix and publish only a successfully assembled replacement. Independently implements the repair reported in #2907.

* test(cso): verify exact masked reads and unmaskable payload refusal

* fix(cso): preserve exact filesystem identities through lease recovery

Preserve 64-bit device/inode identity and nanosecond race checks. Add native NTFS lifecycle coverage for #2927; retain ambiguous legacy-state refusal without claiming Windows PID-reuse recovery is resolved.

* fix(redact): bind pre-push scans to destination and preserve seam context

Uses #2935 (bd07318) as source evidence for push-target range and slice-overlap defects. Independently implemented; no cherry-pick or release metadata adoption.

* test(ci): gate native agent ownership and settings links on macOS

* fix(browse): bind agent lifetimes and cleanup to owned generations

Uses #2931 by Chris Hutton / Claude Fable 5.1 as attributed design input; independently implemented without broad sweeps or copied code. Keep uncertain children and locks rather than deleting foreign state.

* test(ci): include concurrent shutdown controls in the native macOS gate

* v1.88.1.0 fix: harden credential boundaries and owned state

* fix(redact): preserve target provenance and scan boundary semantics

* test(artifacts): read managed rules from atomic allowlist assembly

* fix: preserve native exit observations and fixture prerequisites

* fix: preserve UTF-16 offsets through redaction normalization
This commit is contained in:
Garry Tan authored and GitHub committed 2026-09-23 08:54:53 -04:00
1 parent 636175d349
commit b9706f3635
42 files changed
+2719 -339

No files matched your search

+191 -40
View File
@@ -16,9 +16,57 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import { safeUnlink, safeKill, isProcessAlive } from './error-handling';
import * as crypto from 'crypto';
import { spawnSync } from 'child_process';
import { safeUnlink, isProcessAlive } from './error-handling';
import { restrictFilePermissions, mkdirSecure } from './file-permissions';
import { atomicWriteSync } from '../../lib/fs-atomic';
import { readPidCmdline, readPidStartTime } from './xvfb';
function agentProcessInfo(pid: number): { startTime: string; commandLine: string } {
if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' };
if (process.platform !== 'win32') return { startTime: readPidStartTime(pid), commandLine: readPidCmdline(pid) };
const script = `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object CreationDate,CommandLine | ConvertTo-Json -Compress)`;
try {
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true, timeout: 2000 });
if (result.status !== 0 || !result.stdout) return { startTime: '', commandLine: '' };
const processInfo = JSON.parse(result.stdout);
return { startTime: processInfo?.CreationDate || '', commandLine: processInfo?.CommandLine || '' };
} catch { return { startTime: '', commandLine: '' }; }
}
export function readAgentStartTime(pid: number): string {
return agentProcessInfo(pid).startTime;
}
const pendingAgentExits = new Set<any>();
export function acquireAgentStateLock(stateDir: string, waitMs = 5000): () => void {
mkdirSecure(stateDir);
const lockPath = path.join(stateDir, 'terminal-agent-pid.lock');
const deadline = Date.now() + waitMs;
let fd: number;
while (true) {
try {
fd = fs.openSync(lockPath, 'wx', 0o600);
break;
} catch (err: any) {
if (err?.code !== 'EEXIST' || Date.now() >= deadline) {
throw new Error(`terminal-agent state lock unavailable at ${lockPath}: ${err?.code || err}; inspect the owning process before manual recovery`);
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
}
}
const owned = fs.fstatSync(fd, { bigint: true });
return () => {
try {
const current = fs.statSync(lockPath, { bigint: true });
if (current.dev === owned.dev && current.ino === owned.ino) fs.unlinkSync(lockPath);
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
} finally { fs.closeSync(fd); }
};
}
/**
* Locate the terminal-agent script on disk. In dev (cli.ts running via
@@ -41,11 +89,8 @@ export function resolveTerminalAgentScript(searchHints: { metaDir?: string; exec
}
/**
* Spawn a fresh terminal-agent as a detached child. Handles the standard
* three steps: kill any prior agent recorded at `<stateDir>/terminal-agent-pid`,
* clear the stale record, then `Bun.spawn(['bun', 'run', script], ...)` with
* env wiring. Returns the PID of the new agent on success, null when the
* agent script can't be located.
* Spawn an owned terminal-agent. A prior record is retained until its exact
* process exits, and the new generation is recorded before it may bind.
*
* Used by both the CLI cold-start path (cli.ts) and the v1.44 watchdog in
* server.ts. Centralizing here removes a copy-paste between them and means
@@ -62,31 +107,82 @@ export function spawnTerminalAgent(opts: {
/** Override script lookup for tests. */
scriptPath?: string;
}): number | null {
if (!Number.isSafeInteger(opts.ownerPid) || opts.ownerPid <= 0) throw new Error('terminal-agent requires a daemon owner PID');
const stateDir = path.dirname(opts.stateFile);
const prior = readAgentRecord(stateDir);
if (prior) {
killAgentByRecord(prior, 'SIGTERM');
clearAgentRecord(stateDir);
}
const script = opts.scriptPath || resolveTerminalAgentScript();
if (!script || !fs.existsSync(script)) return null;
const proc = (Bun as any).spawn(['bun', 'run', script], {
cwd: opts.cwd || process.cwd(),
env: {
...process.env,
BROWSE_STATE_FILE: opts.stateFile,
BROWSE_SERVER_PORT: String(opts.serverPort),
BROWSE_OWNER_PID: String(opts.ownerPid),
...(opts.extraEnv || {}),
},
stdio: ['ignore', 'ignore', 'ignore'],
// Explicit for the Node fallback path (dist/bun-polyfill.cjs), where the
// host default is the opposite of Bun's. A visible console window on every
// watchdog respawn is the symptom when this is missing.
windowsHide: true,
});
proc.unref?.();
return proc.pid ?? null;
const release = acquireAgentStateLock(stateDir);
try {
const prior = readAgentRecord(stateDir);
if (prior) {
if (prior.pid === 0) {
console.warn('[browse] terminal-agent startup or failed exit remains unconfirmed; retaining its reservation');
return null;
}
if (isAgentRecordLive(prior) && !stopAgentByRecord(prior)) {
console.warn(`[browse] terminal-agent PID ${prior.pid} is still running or its identity cannot be confirmed; refusing a second agent`);
return null;
}
clearAgentRecord(stateDir, prior);
safeUnlink(path.join(stateDir, 'terminal-port'));
safeUnlink(path.join(stateDir, 'terminal-internal-token'));
}
const ownerStartTime = readAgentStartTime(opts.ownerPid);
if (!ownerStartTime) throw new Error('terminal-agent owner identity is unavailable');
const gen = crypto.randomBytes(16).toString('base64url');
const reservation: AgentRecord = { pid: 0, gen, startedAt: Date.now(), ownerPid: opts.ownerPid, ownerStartTime };
writeAgentRecord(stateDir, reservation);
let proc: any;
try {
proc = (Bun as any).spawn(['bun', 'run', script, `--agent-gen=${gen}`], {
cwd: opts.cwd || process.cwd(),
env: {
...process.env,
...(opts.extraEnv || {}),
BROWSE_STATE_FILE: opts.stateFile,
BROWSE_SERVER_PORT: String(opts.serverPort),
BROWSE_OWNER_PID: String(opts.ownerPid),
BROWSE_OWNER_START_TIME: ownerStartTime,
BROWSE_AGENT_GEN: gen,
},
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
});
} catch (err) {
clearAgentRecord(stateDir, reservation);
throw err;
}
const retainUntilExit = () => {
pendingAgentExits.add(proc);
proc.exited?.then(() => {
try {
const releasePending = acquireAgentStateLock(stateDir);
try { clearAgentRecord(stateDir, reservation); } finally { releasePending(); }
} catch (err) { console.warn('[browse] terminal-agent pending exit cleanup failed:', err); }
pendingAgentExits.delete(proc);
}, (err: unknown) => console.warn('[browse] terminal-agent exit remains unconfirmed:', err));
};
const pid = proc.pid;
const startTime = pid ? readAgentStartTime(pid) : '';
if (!pid || !startTime) {
try { proc.kill('SIGTERM'); } catch {}
retainUntilExit();
throw new Error('terminal-agent process identity is unavailable');
}
const record: AgentRecord = { pid, gen, startedAt: Date.now(), startTime, ownerPid: opts.ownerPid, ownerStartTime };
try {
writeAgentRecord(stateDir, record);
} catch (err) {
if (!stopAgentByRecord(record)) {
retainUntilExit();
throw new Error(`terminal-agent record update failed and child ${pid} exit is unconfirmed: ${err}`);
}
clearAgentRecord(stateDir, reservation);
throw err;
}
proc.unref?.();
return pid;
} finally { release(); }
}
export interface AgentRecord {
@@ -95,6 +191,9 @@ export interface AgentRecord {
gen: string;
/** ms since epoch. Reserved for future PID-reuse guards. */
startedAt: number;
startTime?: string;
ownerPid?: number;
ownerStartTime?: string;
}
export function agentRecordPath(stateDir: string): string {
@@ -124,27 +223,79 @@ export function writeAgentRecord(stateDir: string, record: AgentRecord): void {
restrictFilePermissions(target);
}
export function clearAgentRecord(stateDir: string): void {
export function clearAgentRecord(stateDir: string, expected?: AgentRecord): void {
if (expected) {
const current = readAgentRecord(stateDir);
if (!current || current.pid !== expected.pid || current.gen !== expected.gen) return;
}
safeUnlink(agentRecordPath(stateDir));
}
export function isAgentRecordLive(record: AgentRecord): boolean {
return Number.isSafeInteger(record.pid) && record.pid > 0 && isProcessAlive(record.pid);
}
function agentStatus(record: AgentRecord, ownerPid?: number): 'owned' | 'gone' | 'unknown' {
if (!isAgentRecordLive(record)) return 'gone';
if (!record.startTime || !record.ownerPid || !record.ownerStartTime) return 'unknown';
if (ownerPid !== undefined && (record.ownerPid !== ownerPid || record.ownerStartTime !== readAgentStartTime(ownerPid))) return 'unknown';
const actual = agentProcessInfo(record.pid);
if (!actual.startTime) return isAgentRecordLive(record) ? 'unknown' : 'gone';
if (actual.startTime !== record.startTime) return 'gone';
try {
let state: string | undefined;
if (process.platform === 'linux') {
state = fs.readFileSync(`/proc/${record.pid}/stat`, 'utf8').match(/^\d+ \(.*\) ([A-Z])/u)?.[1];
} else if (process.platform === 'darwin') {
const result = spawnSync('ps', ['-p', String(record.pid), '-o', 'stat='], { encoding: 'utf8', windowsHide: true, timeout: 2000 });
if (result.status === 0) state = result.stdout?.trim()?.[0];
}
if (state === 'Z') return 'gone';
} catch {}
if (!isAgentRecordLive(record)) return 'gone';
return actual.commandLine.split(/\s+/).some(arg => arg.replace(/^['"]|['"]$/g, '') === `--agent-gen=${record.gen}`)
? 'owned' : 'unknown';
}
export function isOurAgent(record: AgentRecord, ownerPid?: number): boolean {
return agentStatus(record, ownerPid) === 'owned';
}
export function isAgentRecordGone(record: AgentRecord): boolean {
return agentStatus(record) === 'gone';
}
/**
* Kill the agent identified by `record`. Signal defaults to SIGTERM (give
* the agent a chance to run its own SIGTERM cleanup). Returns true if a
* signal was actually sent to a live PID; false if the PID was already
* dead (no-op). Never throws — ESRCH is swallowed by safeKill.
*
* Validates liveness BEFORE signaling so a PID-reuse race (the recorded
* PID was reaped and a brand-new unrelated process now holds it) can't
* cause us to kill the wrong process. This is a best-effort defense:
* Linux/macOS don't expose process-start-time cheaply, and the gap
* between record-write and watchdog-tick is small (60s max).
* signal reached an exact-generation live process, false otherwise.
*/
export function killAgentByRecord(
record: AgentRecord,
signal: NodeJS.Signals = 'SIGTERM',
): boolean {
if (!isProcessAlive(record.pid)) return false;
safeKill(record.pid, signal);
return true;
if (!isOurAgent(record)) return false;
try { process.kill(record.pid, signal); return true; } catch { return false; }
}
export function stopAgentByRecord(record: AgentRecord, graceMs = 1000): boolean {
const initial = agentStatus(record);
if (initial === 'gone') return true;
if (initial !== 'owned') return false;
const waitForExit = (ms: number) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
const status = agentStatus(record);
if (status === 'gone') return true;
if (status === 'unknown') return false;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
}
return agentStatus(record) === 'gone';
};
if (!killAgentByRecord(record, 'SIGTERM')) return agentStatus(record) === 'gone';
if (waitForExit(graceMs)) return true;
const afterGrace = agentStatus(record);
if (afterGrace !== 'owned') return afterGrace === 'gone';
if (!killAgentByRecord(record, 'SIGKILL')) return agentStatus(record) === 'gone';
return waitForExit(graceMs);
}