diff --git a/browse/src/bun-polyfill.cjs b/browse/src/bun-polyfill.cjs index 3b60cdd3f..665b8fd8b 100644 --- a/browse/src/bun-polyfill.cjs +++ b/browse/src/bun-polyfill.cjs @@ -140,6 +140,9 @@ globalThis.Bun = { // respawn until this was forwarded. Forwarded, not hardcoded, so an // explicit windowsHide:false survives. windowsHide: options.windowsHide !== false, + // Without it a Windows child joins the parent's kill-on-close job and + // dies with the parent (#2637). + detached: options.detached === true, }); // Drain stdout/stderr eagerly into in-memory buffers. Bun's spawn buffers diff --git a/browse/src/file-permissions.ts b/browse/src/file-permissions.ts index 60e3c91a4..6ee896650 100644 --- a/browse/src/file-permissions.ts +++ b/browse/src/file-permissions.ts @@ -323,6 +323,14 @@ export function repairBrokenDacl(dirPath: string): void { } } +function isExistingDirectory(dirPath: string): boolean { + try { + return fs.statSync(dirPath).isDirectory(); + } catch { + return false; + } +} + /** * `mkdir -p` with owner-only directory permissions, cross-platform. * Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL. @@ -335,7 +343,14 @@ export function repairBrokenDacl(dirPath: string): void { * still listable by this process and repairs a broken DACL (#1605) if not. */ export function mkdirSecure(dirPath: string): void { - fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); + try { + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); + } catch (err: any) { + // Bun on Windows has thrown EEXIST from a recursive mkdir of an existing + // directory (#2048, #2635); recursive mkdir is idempotent, so only a + // non-directory at the path is a real conflict. + if (err?.code !== 'EEXIST' || !isExistingDirectory(dirPath)) throw err; + } restrictDirectoryPermissions(dirPath); if (process.platform === 'win32' && !canListDir(dirPath)) { repairBrokenDacl(dirPath); diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts index 04072356f..0b0ad1ac4 100644 --- a/browse/src/terminal-agent-control.ts +++ b/browse/src/terminal-agent-control.ts @@ -210,6 +210,10 @@ export function spawnTerminalAgent(opts: { }, stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true, + // A non-detached Windows child is killed with its parent's job, so an + // agent started by `browse connect` died when the CLI exited (#2637). + // The owner watchdog (BROWSE_OWNER_PID) still ends it with the daemon. + detached: process.platform === 'win32', }); } catch (err) { clearAgentRecord(stateDir, reservation); diff --git a/browse/test/bun-polyfill.test.ts b/browse/test/bun-polyfill.test.ts index 0b852da7b..86e3c8e66 100644 --- a/browse/test/bun-polyfill.test.ts +++ b/browse/test/bun-polyfill.test.ts @@ -371,6 +371,22 @@ describe('bun-polyfill', () => { expect(result.stdout.toString().trim()).toBe('windowsHide:true'); }); + // #2637: a non-detached Windows child is killed with its parent's job, so + // the shim must forward detached instead of dropping it. + test('Bun.spawn forwards detached and leaves it off by default', () => { + const result = Bun.spawnSync(['node', '-e', ` + const cp = require('child_process'); + const orig = cp.spawn; + const seen = []; + cp.spawn = (c, a, o) => { seen.push(o.detached); return orig(c, a, o); }; + require(${JSON.stringify(polyfillPath)}); + Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'], detached: true }); + Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'] }); + console.log('detached:' + seen.join(',')); + `], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); + expect(result.stdout.toString().trim()).toBe('detached:true,false'); + }); + test('an explicit windowsHide:false is honored', () => { const result = Bun.spawnSync(['node', '-e', ` const cp = require('child_process'); diff --git a/browse/test/file-permissions.test.ts b/browse/test/file-permissions.test.ts index 6a86e7f4d..72133ab58 100644 --- a/browse/test/file-permissions.test.ts +++ b/browse/test/file-permissions.test.ts @@ -16,7 +16,7 @@ * Mode / admin can't create symlinks, and the test skips gracefully. */ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -260,6 +260,27 @@ describe('mkdirSecure', () => { expect(fs.readdirSync(d)).toContain('browse.json.lock'); }); + // #2048: Bun on Windows threw EEXIST from a recursive mkdir of an existing + // .gstack, which made every browse command fatal. Simulated here so every + // platform proves the contract; a file at the path is still a conflict. + test('tolerates a runtime EEXIST for an existing directory, never for a file (#2048)', () => { + const d = path.join(tmpDir, 'state', '.gstack'); + mkdirSecure(d); + const file = path.join(tmpDir, 'not-a-dir'); + fs.writeFileSync(file, 'x'); + const original = fs.mkdirSync; + const eexist = spyOn(fs, 'mkdirSync').mockImplementation(((target: fs.PathLike, options?: any) => { + if (fs.existsSync(target)) throw Object.assign(new Error(`EEXIST: file already exists, mkdir '${target}'`), { code: 'EEXIST' }); + return original(target, options); + }) as typeof fs.mkdirSync); + try { + expect(() => mkdirSecure(d)).not.toThrow(); + expect(() => mkdirSecure(file)).toThrow('EEXIST'); + } finally { + eexist.mockRestore(); + } + }); + test('recursive behavior: creates intermediate directories', () => { const d = path.join(tmpDir, 'a', 'b', 'c'); mkdirSecure(d); diff --git a/browse/test/windows-spawn-hide.test.ts b/browse/test/windows-spawn-hide.test.ts index cfdddf205..6b4806829 100644 --- a/browse/test/windows-spawn-hide.test.ts +++ b/browse/test/windows-spawn-hide.test.ts @@ -66,6 +66,12 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => { expectHideNearEvery(SRC('terminal-agent-control.ts'), '(Bun as any).spawn(', 700); }); + test('terminal-agent spawn is detached on Windows so it outlives the CLI that started it (#2637)', () => { + const control = SRC('terminal-agent-control.ts'); + const at = control.indexOf('(Bun as any).spawn('); + expect(control.slice(at, at + 1000)).toContain("detached: process.platform === 'win32'"); + }); + test('SWEEP: every direct child_process call in src/ passes windowsHide (#2160, #2415)', () => { // Full-census tripwire: a NEW child_process call site without windowsHide // fails CI. Each exemption carries a reason — an interactive console diff --git a/cso/SKILL.md b/cso/SKILL.md index e8fdfa52b..41a3c91e3 100644 --- a/cso/SKILL.md +++ b/cso/SKILL.md @@ -115,9 +115,9 @@ sections. Read a section in full before doing its step; do not work from memory. Use the snapshot inventory and redacted source to map stacks, **actors, assets, entrypoints, tenant boundaries, sensitive operations, and security invariants**, including build/deploy and async paths. Record input control and sink credentials/capabilities; corroborate repository claims in callers/configuration. -Use stack detection to prioritize nested services and high-impact cross-language paths. Static assessment remains available for all languages. Comprehensive setup supports Node, Bun, Python, and Rails only with a matching qualified runtime profile. Missing profiles, runtimes, or tools are execution prerequisites, not vulnerabilities. Reduce coverage only for unfinished assessment work. +Use stack detection to prioritize nested services and cross-language paths. Comprehensive setup supports Node, Bun, Python, and Rails only with a matching qualified runtime profile. Missing profiles, runtimes, or tools are execution prerequisites, not vulnerabilities. Reduce coverage only for unfinished assessment work. -In comprehensive mode, review snapshot transformations before reproduction. If sanitization removes or replaces the tested boundary, block reproduction. Never claim sanitized configuration equivalence without evidence. Readiness failures do not block independent static work. +In comprehensive mode, review snapshot transformations before reproduction. If sanitization removes or replaces the tested boundary, block reproduction. Never claim sanitized configuration equivalence without evidence. Readiness failures do not block static work. ### Phase 1: Attack surface census @@ -138,7 +138,7 @@ Daily reports contain **supported findings**: a concrete attacker-controlled ent Do not apply blanket exclusions for development dependencies, availability/resource attacks, historical secrets, user-role prompt injection, or gstack-owned skills. Analyze attacker control and impact. Likewise, UUIDs do not provide authorization; user-controlled URL paths can still cross a sensitive boundary; environment variables may originate from untrusted workflows; and safe defaults can be bypassed by framework escape hatches. Missing hardening alone needs a concrete failure scenario before becoming a finding. -For each candidate, use an already-authorized independent reviewer when available. Give it the relevant locations, invariant, and rubric without the producer's conclusion; have it inspect callers, middleware, configuration, validation, legitimate behavior, and mitigations. Use at most three workers and await them. Do not request broader tool access solely to obtain an independent reviewer. Otherwise perform a separate skeptical pass labeled **sequential challenge; independent agent unavailable**. Record dissent and assumptions. Agreement and scanner warnings do not prove runtime behavior. +For each candidate, use an already-authorized independent reviewer when available. Give it the relevant locations, invariant, and rubric without the producer's conclusion; have it inspect callers, middleware, configuration, validation, legitimate behavior, and mitigations. Use at most three; await them. Do not request broader tool access solely to obtain an independent reviewer. Otherwise perform a separate skeptical pass labeled **sequential challenge; independent agent unavailable**. Record dissent and assumptions. Agreement and scanner warnings do not prove runtime behavior. Search for root-cause variants after supporting a finding, honoring scope. Prioritize by impact, dependency reachability/exposure, known exploitation, and likely user benefit. Unknown reachability remains **unknown**, not “unreachable.” @@ -153,7 +153,7 @@ Keep finding evidence, reproduction outcome, patch validation, test-completion a ### Phase 13: Report and repair artifacts -Finish every audit through the helper, including empty, cancelled, blocked, or interrupted audits when possible. Every report begins with **complete**, **partial**, or **not assessed**, followed by scope and material gaps. Completeness is independent of finding count. For an empty supported set, say **“No supported findings in the assessed scope.”** Never infer a clean bill of health from setup failure or absent scanner output. +Finish every audit through the helper, including empty, cancelled, blocked, or interrupted audits when possible. Every report begins with **complete**, **partial**, or **not assessed**, followed by scope and material gaps. Completeness is independent of finding count. For an empty supported set, say **“No supported findings in the assessed scope.”**, or **not assessed** if nothing was. Lead incomplete reports with the helper's Status/Ran/Reason/Next lines. Never infer a clean result from setup failure or absent scanner output. Present a compact SECURITY FINDINGS table with stable finding ID, severity, confidence/rationale, evidence state, location, and impact. Each finding needs an attacker scenario, supporting references, counterevidence considered, and a concrete repair recommendation. Include coverage, transformations, scanner versions/outcomes/freshness, runtime prerequisites, timing, and proposed repair-candidate paths. Include `runtime_tested` bundle paths only when an authenticated assertion witness actually produced one. Beside every bundle, show assertion, test-completion, and review assurance exactly as recorded; never collapse those labels or imply that self-reported project tests are authenticated. Comprehensive hypotheses belong in a separate labeled appendix. diff --git a/cso/SKILL.md.tmpl b/cso/SKILL.md.tmpl index 246b16f77..52967925e 100644 --- a/cso/SKILL.md.tmpl +++ b/cso/SKILL.md.tmpl @@ -108,9 +108,9 @@ Budgets are ten minutes daily and thirty comprehensive, including one minute for Use the snapshot inventory and redacted source to map stacks, **actors, assets, entrypoints, tenant boundaries, sensitive operations, and security invariants**, including build/deploy and async paths. Record input control and sink credentials/capabilities; corroborate repository claims in callers/configuration. -Use stack detection to prioritize nested services and high-impact cross-language paths. Static assessment remains available for all languages. Comprehensive setup supports Node, Bun, Python, and Rails only with a matching qualified runtime profile. Missing profiles, runtimes, or tools are execution prerequisites, not vulnerabilities. Reduce coverage only for unfinished assessment work. +Use stack detection to prioritize nested services and cross-language paths. Comprehensive setup supports Node, Bun, Python, and Rails only with a matching qualified runtime profile. Missing profiles, runtimes, or tools are execution prerequisites, not vulnerabilities. Reduce coverage only for unfinished assessment work. -In comprehensive mode, review snapshot transformations before reproduction. If sanitization removes or replaces the tested boundary, block reproduction. Never claim sanitized configuration equivalence without evidence. Readiness failures do not block independent static work. +In comprehensive mode, review snapshot transformations before reproduction. If sanitization removes or replaces the tested boundary, block reproduction. Never claim sanitized configuration equivalence without evidence. Readiness failures do not block static work. ### Phase 1: Attack surface census @@ -130,7 +130,7 @@ Daily reports contain **supported findings**: a concrete attacker-controlled ent Do not apply blanket exclusions for development dependencies, availability/resource attacks, historical secrets, user-role prompt injection, or gstack-owned skills. Analyze attacker control and impact. Likewise, UUIDs do not provide authorization; user-controlled URL paths can still cross a sensitive boundary; environment variables may originate from untrusted workflows; and safe defaults can be bypassed by framework escape hatches. Missing hardening alone needs a concrete failure scenario before becoming a finding. -For each candidate, use an already-authorized independent reviewer when available. Give it the relevant locations, invariant, and rubric without the producer's conclusion; have it inspect callers, middleware, configuration, validation, legitimate behavior, and mitigations. Use at most three workers and await them. Do not request broader tool access solely to obtain an independent reviewer. Otherwise perform a separate skeptical pass labeled **sequential challenge; independent agent unavailable**. Record dissent and assumptions. Agreement and scanner warnings do not prove runtime behavior. +For each candidate, use an already-authorized independent reviewer when available. Give it the relevant locations, invariant, and rubric without the producer's conclusion; have it inspect callers, middleware, configuration, validation, legitimate behavior, and mitigations. Use at most three; await them. Do not request broader tool access solely to obtain an independent reviewer. Otherwise perform a separate skeptical pass labeled **sequential challenge; independent agent unavailable**. Record dissent and assumptions. Agreement and scanner warnings do not prove runtime behavior. Search for root-cause variants after supporting a finding, honoring scope. Prioritize by impact, dependency reachability/exposure, known exploitation, and likely user benefit. Unknown reachability remains **unknown**, not “unreachable.” @@ -145,7 +145,7 @@ Keep finding evidence, reproduction outcome, patch validation, test-completion a ### Phase 13: Report and repair artifacts -Finish every audit through the helper, including empty, cancelled, blocked, or interrupted audits when possible. Every report begins with **complete**, **partial**, or **not assessed**, followed by scope and material gaps. Completeness is independent of finding count. For an empty supported set, say **“No supported findings in the assessed scope.”** Never infer a clean bill of health from setup failure or absent scanner output. +Finish every audit through the helper, including empty, cancelled, blocked, or interrupted audits when possible. Every report begins with **complete**, **partial**, or **not assessed**, followed by scope and material gaps. Completeness is independent of finding count. For an empty supported set, say **“No supported findings in the assessed scope.”**, or **not assessed** if nothing was. Lead incomplete reports with the helper's Status/Ran/Reason/Next lines. Never infer a clean result from setup failure or absent scanner output. Present a compact SECURITY FINDINGS table with stable finding ID, severity, confidence/rationale, evidence state, location, and impact. Each finding needs an attacker scenario, supporting references, counterevidence considered, and a concrete repair recommendation. Include coverage, transformations, scanner versions/outcomes/freshness, runtime prerequisites, timing, and proposed repair-candidate paths. Include `runtime_tested` bundle paths only when an authenticated assertion witness actually produced one. Beside every bundle, show assertion, test-completion, and review assurance exactly as recorded; never collapse those labels or imply that self-reported project tests are authenticated. Comprehensive hypotheses belong in a separate labeled appendix. diff --git a/lib/cso/contracts.ts b/lib/cso/contracts.ts index ff3ed32fe..52610986f 100644 --- a/lib/cso/contracts.ts +++ b/lib/cso/contracts.ts @@ -883,12 +883,40 @@ export function renderReport(report: RunReportV3): string { : []), ]; const model = report.application; + // Missing required coverage is stated before any finding text, so an audit + // that did not run (or ran partly) never reads as a clean result. + const domains = report.coverage.filter( + (c) => !c.domain.startsWith('scanner:') && c.status !== 'not_applicable', + ), + ran = domains.filter((c) => c.status === 'assessed' || c.status === 'partial'), + missing = domains.filter((c) => c.status !== 'assessed'), + reasons = [...new Set([...report.gaps, ...missing.flatMap((c) => c.gaps)])], + scannersWithoutResults = report.coverage.filter( + (c) => c.domain.startsWith('scanner:') && c.status !== 'assessed' && c.status !== 'not_applicable', + ); + const status = + report.completeness === 'complete' + ? [] + : [ + report.completeness === 'not assessed' + ? 'Status: not assessed. No security domain was assessed, so this report is not a clean result.' + : 'Status: partial. Some required coverage is missing; findings below cover only the assessed part.', + `Ran: ${ran.length ? ran.map((c) => `${plain(c.domain)} (${plain(c.status)})`).join('; ') : 'nothing'}. Missing: ${missing.length ? missing.map((c) => `${plain(c.domain)} (${plain(c.status)})`).join('; ') : 'none'}.`, + `Reason: ${reasons.length ? list(reasons) : 'no assessment evidence was recorded for the missing domains'}.`, + `Next: ${report.status === 'finished' ? 'start a new /cso audit after resolving the reasons above; run `gstack-cso doctor --repo ` to check prerequisites' : `resume this run with \`gstack-cso resume ${plain(report.runId)}\`, submit evidence for each missing domain, then finish it`}.`, + ]; return [ `${report.completeness} — ${plain(report.policy.scope)}${report.policy.diff ? ` (diff against ${plain(report.policy.base)})` : ''}`, + ...status, `Run: ${plain(report.runId)}. Mode: ${plain(report.policy.mode)}.`, timing, ...(usage ? [usage] : []), `Material gaps: ${gaps.length ? list(gaps) : 'none reported'}.`, + ...(scannersWithoutResults.length + ? [ + `Optional scanners without results: ${scannersWithoutResults.map((c) => `${plain(c.domain.slice('scanner:'.length))} (${plain(c.tool?.outcome ?? c.status)}${c.gaps.length ? `: ${list(c.gaps)}` : ''})`).join('; ')}.`, + ] + : []), '', 'Application model:', `- Actors: ${list(model.actors)}.`, @@ -900,7 +928,9 @@ export function renderReport(report: RunReportV3): string { '', ...(supported.length ? ['Supported findings:', ...supported.flatMap(findingLines)] - : ['No supported findings in the assessed scope.']), + : report.completeness === 'not assessed' + ? ['No findings: nothing was assessed.'] + : ['No supported findings in the assessed scope.']), ...(report.policy.mode === 'comprehensive' ? [ '', diff --git a/lib/cso/process-identity.ts b/lib/cso/process-identity.ts new file mode 100644 index 000000000..ccae55012 --- /dev/null +++ b/lib/cso/process-identity.ts @@ -0,0 +1,108 @@ +import * as fs from 'node:fs'; +import { dlopen, FFIType, ptr } from 'bun:ffi'; + +/** + * Process identity is `:` for a live PID. A recorded + * identity that differs from the PID's current identity proves the PID was + * recycled. Linux reads /proc start ticks; Windows reads the creation FILETIME + * through GetProcessTimes; macOS reads proc_bsdinfo's start time. An + * unreadable identity is `undefined`, never a guess. + */ +export const PROCESS_IDENTITY = /^(?:linux|win32|darwin):\d{1,20}$/; +const FILETIME_UNIX_EPOCH_MS = 11_644_473_600_000n; +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; +const PROC_PIDTBSDINFO = 3; +const PROC_BSDINFO_SIZE = 136; + +type Win32Api = { + OpenProcess: (access: number, inherit: number, pid: number) => number | bigint; + GetProcessTimes: (handle: number | bigint, ...times: unknown[]) => number; + CloseHandle: (handle: number | bigint) => number; +}; +type DarwinApi = { proc_pidinfo: (...args: unknown[]) => number }; +let win32Api: Win32Api | null | undefined, darwinApi: DarwinApi | null | undefined; + +function readLinux(pid: number): string | undefined { + try { + const raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'), + tail = raw + .slice(raw.lastIndexOf(')') + 2) + .trim() + .split(/\s+/); + return /^\d+$/.test(tail[19] ?? '') ? `linux:${tail[19]}` : undefined; + } catch { + return; + } +} +function readWin32(pid: number): string | undefined { + if (win32Api === undefined) + try { + win32Api = dlopen('kernel32.dll', { + OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 }, + GetProcessTimes: { + args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], + returns: FFIType.i32, + }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + }).symbols as unknown as Win32Api; + } catch { + win32Api = null; + } + if (!win32Api) return; + const handle = win32Api.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if (!handle) return; + try { + const times = new BigUint64Array(4); + if (!win32Api.GetProcessTimes(handle, ptr(times, 0), ptr(times, 8), ptr(times, 16), ptr(times, 24))) + return; + return times[0] > 0n ? `win32:${times[0]}` : undefined; + } finally { + win32Api.CloseHandle(handle); + } +} +/** proc_bsdinfo: pbi_pid at byte 12, pbi_start_tvsec at 120, pbi_start_tvusec at 128. */ +export function darwinIdentityFromBsdInfo(info: Uint8Array, pid: number): string | undefined { + if (info.byteLength < PROC_BSDINFO_SIZE) return; + const view = new DataView(info.buffer, info.byteOffset, info.byteLength), + seconds = view.getBigUint64(120, true), + micros = view.getBigUint64(128, true); + if (view.getUint32(12, true) !== pid || seconds === 0n || micros >= 1_000_000n) return; + return `darwin:${seconds * 1_000_000n + micros}`; +} +function readDarwin(pid: number): string | undefined { + if (darwinApi === undefined) + try { + darwinApi = dlopen('/usr/lib/libSystem.B.dylib', { + proc_pidinfo: { + args: [FFIType.i32, FFIType.i32, FFIType.u64, FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + }).symbols as unknown as DarwinApi; + } catch { + darwinApi = null; + } + if (!darwinApi) return; + const info = new Uint8Array(PROC_BSDINFO_SIZE); + if (darwinApi.proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, ptr(info), PROC_BSDINFO_SIZE) !== PROC_BSDINFO_SIZE) + return; + return darwinIdentityFromBsdInfo(info, pid); +} +/** Wall-clock start (ms since the Unix epoch) encoded by a win32 or darwin identity; Linux ticks are boot-relative. */ +export function identityStartedAtMs(identity: string): number | undefined { + const match = identity.match(/^(win32|darwin):(\d{1,20})$/); + if (!match) return; + const value = BigInt(match[2]); + return Number(match[1] === 'win32' ? value / 10_000n - FILETIME_UNIX_EPOCH_MS : value / 1_000n); +} +/** Mutable so tests can substitute another platform's identity source. */ +export const processIdentitySource = { + read(pid: number): string | undefined { + if (!Number.isInteger(pid) || pid <= 1) return; + try { + if (process.platform === 'linux') return readLinux(pid); + if (process.platform === 'win32') return readWin32(pid); + if (process.platform === 'darwin') return readDarwin(pid); + } catch {} + return; + }, +}; diff --git a/lib/cso/state.ts b/lib/cso/state.ts index cfb62ab7c..017f95b67 100644 --- a/lib/cso/state.ts +++ b/lib/cso/state.ts @@ -13,6 +13,7 @@ import { } from './contracts'; import { redact, sanitizeForJson, sanitizeHelperForJson } from './process'; import { resolveStateRoot } from '../state-root'; +import { PROCESS_IDENTITY, identityStartedAtMs, processIdentitySource } from './process-identity'; const MAX_STATE_FILE = 1024 * 1024; type ExactStats = Pick< @@ -926,7 +927,6 @@ export function requireTime(report: RunReportV3): void { const LOCK_PROTOCOL = 'immutable-lease-set-v3'; const LOCK_OWNER_MAX_BYTES = 4096; const LOCK_TOKEN = /^[a-f0-9]{32}$/; -const PROCESS_IDENTITY = /^linux:\d+$/; const LEASE_PUBLICATION_TEMP = /^([a-f0-9]{32})\.(json|decision)\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/; const LEASE_BLOCKED_WAIT_MS = 250; const LEASE_ELECTION_POLL_MS = 1; @@ -961,17 +961,7 @@ function processAlive(pid: number): boolean { } } function processIdentity(pid: number): string | undefined { - if (process.platform !== 'linux') return; - try { - const raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'), - tail = raw - .slice(raw.lastIndexOf(')') + 2) - .trim() - .split(/\s+/); - return /^\d+$/.test(tail[19] ?? '') ? `linux:${tail[19]}` : undefined; - } catch { - return; - } + return processIdentitySource.read(pid); } function validateOwner(value: unknown, expectedToken?: string): LockOwner { if (!value || typeof value !== 'object' || Array.isArray(value)) @@ -1032,7 +1022,10 @@ function validateLeaseDecision(value: unknown, expectedToken?: string): LeaseDec (typeof decision.publisherProcessIdentity !== 'string' || !PROCESS_IDENTITY.test(decision.publisherProcessIdentity))) ) - throw new CsoError('UNSAFE_PATH', 'Run mutation lease decision is malformed'); + throw new CsoError( + 'UNSAFE_PATH', + `Run mutation lease decision is malformed. Reason: a lease record failed validation, so it is never reclaimed as a stale lock. ${UNVERIFIABLE_LEASE_NEXT}`, + ); return decision as LeaseDecision; } function decisionOwner(decision: LeaseDecision): LockOwner { @@ -1160,16 +1153,50 @@ function readOwner( } catch {} } } -function ownerIsAlive(owner: LockOwner): boolean { +// Clock steps between a process start and its lease write must not make a +// genuine owner look younger than its own record. +const OWNER_START_CLOCK_SLACK_MS = 2_000; +type OwnerLiveness = { alive: false } | { alive: true; reason: string }; +/** + * A recorded identity is compared exactly. A record without one (written where + * no identity source existed) is stale only when the PID's current holder + * started after the record was written; createdAt 0 means the writer recorded + * no time. Anything unproven stays live and says why. + */ +function ownerLiveness(owner: LockOwner): OwnerLiveness { const pid = owner.pid; - if (!processAlive(pid)) return false; + if (!processAlive(pid)) return { alive: false }; const current = processIdentity(pid); - return !( - typeof owner.processIdentity === 'string' && - current !== undefined && - owner.processIdentity !== current + if (current === undefined) + return { alive: true, reason: `process ${pid} is running; process identity unavailable` }; + if (owner.processIdentity !== undefined) + return owner.processIdentity === current + ? { alive: true, reason: `process ${pid} is still running` } + : { alive: false }; + const started = identityStartedAtMs(current); + if (started !== undefined && owner.createdAt > 0 && started > owner.createdAt + OWNER_START_CLOCK_SLACK_MS) + return { alive: false }; + return { + alive: true, + reason: `process ${pid} is running and the lease record predates process identity, so it cannot be proven stale`, + }; +} +function ownerIsAlive(owner: LockOwner): boolean { + return ownerLiveness(owner).alive; +} +function heldLeaseError(state: LeaseState): CsoError { + const liveness = ownerLiveness(state.owner), + reason = liveness.alive ? liveness.reason : `process ${state.owner.pid} holds the lease`; + return new CsoError( + 'INSUFFICIENT_CAPACITY', + `${state.owner.pid === process.pid ? 'Another operation in this helper is updating this run' : 'Another helper is updating this run'}. ` + + `Status: blocked; this command changed nothing. Reason: ${reason}. ` + + 'Next: wait for that session to finish or check the owning session, then retry this command.', ); } +const UNVERIFIABLE_LEASE_NEXT = + 'Status: this run is blocked; this command changed nothing and the records were kept. ' + + 'Next: start a new run with `gstack-cso start`; other runs are unaffected. Keep this run directory if you report the problem.'; function recoverLeasePublications(leases: string): void { for (let attempt = 0; attempt < 4; attempt++) { try { @@ -1661,7 +1688,10 @@ function scanRunLeases(leases: string): LeaseState[] { (!decisionMatchesIdentity(decisionRecord.decision, identity) || !decisionMatchesOwner(decisionRecord.decision, owner)) ) - throw new CsoError('UNSAFE_PATH', 'Run mutation lease decision does not match its candidate owner'); + throw new CsoError( + 'UNSAFE_PATH', + `Run mutation lease decision does not match its candidate owner. Reason: the lease cannot be verified (decisions written by gstack 1.88.0 or earlier on NTFS can carry a rounded file ID). ${UNVERIFIABLE_LEASE_NEXT}`, + ); const number = decisionRecord?.decision.kind === 'ticket' ? BigInt(`0x${decisionRecord.decision.ticket}`) @@ -1909,13 +1939,7 @@ function chooseRunLeaseTicket( recovered = true; break; } - if (state.active) - throw new CsoError( - 'INSUFFICIENT_CAPACITY', - state.owner.pid === process.pid - ? 'Another operation in this helper is updating this run' - : 'Another helper is updating this run', - ); + if (state.active) throw heldLeaseError(state); if (!state.candidate || state.decision?.kind === 'withdraw') continue; if (state.owner.pid === process.pid) throw new CsoError('INSUFFICIENT_CAPACITY', 'Another operation in this helper is updating this run'); @@ -1978,13 +2002,7 @@ function activateRunLease( retry = true; break; } - if (state.active) - throw new CsoError( - 'INSUFFICIENT_CAPACITY', - state.owner.pid === process.pid - ? 'Another operation in this helper is updating this run' - : 'Another helper is updating this run', - ); + if (state.active) throw heldLeaseError(state); if (!state.candidate || state.decision?.kind === 'withdraw') continue; if (state.owner.pid === process.pid) throw new CsoError('INSUFFICIENT_CAPACITY', 'Another operation in this helper is updating this run'); @@ -2316,13 +2334,21 @@ function repairBundleExpiry( return retained; } +// Retention never deletes a run it cannot lease. A live lease or a lease set +// that fails validation keeps that run intact (and blocked on its own) without +// failing maintenance for every other run and repository. +function unmaintainableRun(error: unknown): boolean { + return ( + error instanceof CsoError && (error.code === 'INSUFFICIENT_CAPACITY' || error.code === 'UNSAFE_PATH') + ); +} function cleanupRun(dir: string, run: string, now: number, pinned: boolean, admit: () => void): void { admit(); let lease: HeldRunLease; try { lease = acquireRunLease(dir); } catch (error) { - if (error instanceof CsoError && error.code === 'INSUFFICIENT_CAPACITY') return; + if (unmaintainableRun(error)) return; throw error; } let releasePath = lease.path, @@ -2372,7 +2398,7 @@ function cleanupRetiredRun(dir: string, admit: () => void): void { try { lease = acquireRunLease(dir); } catch (error) { - if (error instanceof CsoError && error.code === 'INSUFFICIENT_CAPACITY') return; + if (unmaintainableRun(error)) return; throw error; } let consumed = false; diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index b6cfc6c01..92b9fe78e 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -410,6 +410,17 @@ function urlPasswordIsPlaceholder(span: string): boolean { return PLACEHOLDER_STRUCTURAL.some((re) => re.test(pw)); } +/** A value span that is only an environment-variable read expression (#2912). */ +const ENV_READ_SPAN = + /^(?:os\.environ\[|os\.environ\.get\(|os\.getenv\(|getenv\(|ENV\[|process\.env\.[A-Za-z_$][\w$]*[;,)]?)$/; +function isBareEnvRead(span: string, match: RegExpExecArray): boolean { + if (!ENV_READ_SPAN.test(span)) return false; + const rest = match.input.slice(match.index + match[0].length).split("\n", 1)[0]; + for (const [, literal] of rest.matchAll(/["']([^\s'"]{8,})["']/g)) + if (!isPlaceholderSpan(literal) && shannonEntropy(literal) >= 3.0) return false; + return true; +} + export const PATTERNS: RedactPattern[] = [ // ===== HIGH — genuinely-secret credentials (block) ===== { @@ -676,11 +687,17 @@ export const PATTERNS: RedactPattern[] = [ // so name-shape checking lives in validate, not in a second group. regex: /^[ \t]*(?:export[ \t]+)?["']?[A-Za-z0-9_.-]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)["']?[ \t]*[:=][ \t]*["']?([^\s'"]{8,})["']?/i, // Only fire on credential-shaped names with high-entropy values — kills - // `FOO_KEY=changeme` and `cacheKey: ` FPs. + // `FOO_KEY=changeme` and `cacheKey: ` FPs. #2912: a value + // that is exactly an environment read (`os.environ["X"]`, + // `os.getenv("X")`, `process.env.X`, `ENV["X"]`) names a secret without + // holding one, unless the rest of its line carries a high-entropy quoted + // literal (`os.getenv("X", "")`). A literal appended to the read + // itself (`process.env.X||"…"`) is not an exact read and still fires. validate: (span, match) => isCredentialShapedEnvName(match[0]) && !isPlaceholderSpan(span) && !/^\$\{?[A-Za-z_]/.test(span) && + !isBareEnvRead(span, match) && shannonEntropy(span) >= 3.0, }, { diff --git a/test/cso-contracts.test.ts b/test/cso-contracts.test.ts index 666c01588..be474ceda 100644 --- a/test/cso-contracts.test.ts +++ b/test/cso-contracts.test.ts @@ -8,6 +8,9 @@ import { assertCanonicalStartPlan, canonicalStartPlan, canonicalTestPlan, certif import { assertionWitnessPairHash, witnessObservationHash } from '../lib/cso/witness'; import { admit, machinePoolRoot, markSupervised, release } from '../lib/cso/admission'; import { sanitizeHelperForJson } from '../lib/cso/process'; +import { spawnSync } from 'node:child_process'; +import { dispatchCsoCommand } from '../lib/cso/cli'; +import { RUNTIME_CATALOG } from '../lib/cso/runtime-catalog'; const dirs:string[]=[];const tmp=()=>{const p=fs.mkdtempSync(path.join(os.tmpdir(),'cso-contract-'));dirs.push(p);return p;};afterEach(()=>{for(const p of dirs.splice(0))fs.rmSync(p,{recursive:true,force:true});}); const CREDENTIAL_CANARY=['ghp_','abcdefghijklmnopqrstuvwxyz1234567890'].join(''); @@ -210,3 +213,51 @@ describe('tested repair certificate gate',()=>{ }); }); function tree(root:string){return sha256(JSON.stringify(fs.readdirSync(root).sort().map(p=>{const file=path.join(root,p),stat=fs.statSync(file);return[p,sha256(fs.readFileSync(file)),stat.mode&0o777];})));} + +describe('#2894 truthful completion in the report the user receives',()=>{ + const coverage=(domain:string,status:string,gaps:string[]=[],tool?:any)=>({domain,scope:'all',status,method:'trace',gaps,exclusions:[],evidence:[],...(tool?{tool}:{})}); + const report=(entries:any[],status='finished'):any=>{const base:any={coverage:entries,gaps:[]};return{...base,schemaVersion:3,runId:'1700000000000-0123456789abcdef',repoId:'x',createdAt:'2026-01-01',deadline:'2026-01-01',status,completeness:completeness(base),policy:{mode:'daily',scope:'all',diff:false,base:'main',offline:true,budgetSeconds:600,maxWorkers:3,maxRepairs:3},source:{root:'/x',snapshotHash:'s',originalHash:'o'},application:{actors:[],assets:[],entrypoints:[],tenantBoundaries:[],sensitiveOperations:[],invariants:[]},findings:[],events:[]};}; + test('not assessed names what ran, what is missing, why, and the next step, and never reads as clean',()=>{ + const markdown=renderReport(report([coverage('snapshot-inputs','assessed'),coverage('auth','not_assessed',['Investigation deadline reached']),coverage('secrets','not_assessed',['Assessment has not been submitted'])])); + const lines=markdown.split('\n'); + expect(lines[0]).toStartWith('not assessed — '); + expect(lines[1]).toBe('Status: not assessed. No security domain was assessed, so this report is not a clean result.'); + expect(lines[2]).toBe('Ran: snapshot-inputs (assessed). Missing: auth (not\\_assessed); secrets (not\\_assessed).'); + expect(lines[3]).toBe('Reason: Investigation deadline reached; Assessment has not been submitted.'); + expect(lines[4]).toStartWith('Next: start a new /cso audit'); + expect(markdown).not.toContain('No supported findings in the assessed scope.'); + expect(markdown).toContain('No findings: nothing was assessed.'); + const running=renderReport(report([coverage('auth','not_assessed')],'running')); + expect(running).toContain('Reason: no assessment evidence was recorded for the missing domains.'); + expect(running).toContain('Next: resume this run with `gstack-cso resume 1700000000000-0123456789abcdef`'); + }); + test('partial and complete keep the exact empty phrase; partial names missing coverage first and optional scanners without results',()=>{ + const partial=renderReport(report([coverage('auth','assessed'),coverage('secrets','partial',['Scanner timed out']),coverage('scanner:gitleaks','not_assessed',['Scanner catalog unavailable'],{name:'gitleaks',version:'unavailable',freshness:'not reported',outcome:'not_assessed'})])); + expect(partial).toStartWith('partial — all\nStatus: partial. Some required coverage is missing;'); + expect(partial).toContain('Ran: auth (assessed); secrets (partial). Missing: secrets (partial).'); + expect(partial).toContain('Optional scanners without results: gitleaks (not\\_assessed: Scanner catalog unavailable).'); + expect(partial).toContain('No supported findings in the assessed scope.'); + expect(partial.indexOf('Status: partial')).toBeLessThan(partial.indexOf('No supported findings')); + const complete=renderReport(report([coverage('auth','assessed')])); + expect(complete).toContain('No supported findings in the assessed scope.'); + expect(complete).not.toContain('Status:'); + }); + test('a finished run with no submitted evidence writes a not-assessed report.md',async()=>{ + const previous=process.env.GSTACK_HOME,base=tmp();process.env.GSTACK_HOME=path.join(base,'state'); + try{ + const repo=path.join(base,'repo');fs.mkdirSync(repo); + for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture']])expect(spawnSync('git',args,{cwd:repo,timeout:10_000}).status).toBe(0); + fs.writeFileSync(path.join(repo,'app.js'),'console.log("fixture")\n'); + for(const args of [['add','app.js'],['commit','-qm','fixture']])expect(spawnSync('git',args,{cwd:repo,timeout:10_000}).status).toBe(0); + const dependencies={runtimeCatalog:RUNTIME_CATALOG,catalogImageSession:async()=>{throw new Error('unused');},watchdogPath:()=>'/unused'} as any; + const started=await dispatchCsoCommand('start',['--repo',repo,'--offline'],dependencies) as any; + await dispatchCsoCommand('finish',[started.runId],dependencies); + const markdown=fs.readFileSync(path.join(base,'state','security','cso',started.repoId,started.runId,'report.md'),'utf8'); + expect(markdown).toStartWith('not assessed — '); + expect(markdown).toContain('Status: not assessed. No security domain was assessed, so this report is not a clean result.'); + expect(markdown).toContain('Missing: application-model (not\\_assessed)'); + expect(markdown).toMatch(/\nReason: [^\n]*Assessment has not been submitted[^\n]*\nNext: start a new \/cso audit/); + expect(markdown).not.toContain('No supported findings in the assessed scope.'); + }finally{if(previous===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=previous;} + },30_000); +}); diff --git a/test/cso-lease-identity.test.ts b/test/cso-lease-identity.test.ts index f368e5573..094406401 100644 --- a/test/cso-lease-identity.test.ts +++ b/test/cso-lease-identity.test.ts @@ -2,7 +2,11 @@ import { afterEach, describe, expect, spyOn, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { recoverAtomicNoReplaceJson, withLock } from '../lib/cso/state'; +import { spawnSync } from 'node:child_process'; +import { privateRoot, recoverAtomicNoReplaceJson, retention, withLock } from '../lib/cso/state'; +import { darwinIdentityFromBsdInfo, identityStartedAtMs, PROCESS_IDENTITY, processIdentitySource } from '../lib/cso/process-identity'; +import { dispatchCsoCommand } from '../lib/cso/cli'; +import { RUNTIME_CATALOG } from '../lib/cso/runtime-catalog'; const roots:string[]=[]; const tmp=()=>{const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-lease-identity-'));roots.push(root);return root;}; @@ -180,3 +184,149 @@ describe('CSO exact filesystem lease identity',()=>{ expect(fs.existsSync(temporary)).toBe(true); }); }); + +const FILETIME_UNIX_EPOCH=116444736000000000n; +const win32Identity=(ms:number)=>`win32:${BigInt(ms)*10000n+FILETIME_UNIX_EPOCH}`; +type OwnerRecord={pid:number;token:string;createdAt:number;processIdentity?:string}; +function ownedLease(dir:string,owner:OwnerRecord,active=false){ + withLock(dir,()=>0); + const leases=path.join(dir,'.mutation-lock-leases'),candidate=path.join(leases,`${owner.token}.json`),decision=path.join(leases,`${owner.token}.decision`),activePath=path.join(leases,`${owner.token}.active.0000000000000001`); + fs.writeFileSync(candidate,JSON.stringify(owner)+'\n',{mode:0o600}); + const stat=fs.lstatSync(candidate,{bigint:true}); + fs.writeFileSync(decision,JSON.stringify({schemaVersion:1,token:owner.token,kind:'ticket',ticket:'0000000000000001',candidateDev:String(stat.dev),candidateIno:String(stat.ino),ownerPid:owner.pid,...(owner.processIdentity?{ownerProcessIdentity:owner.processIdentity}:{}),ownerCreatedAt:owner.createdAt,publisherPid:owner.pid,...(owner.processIdentity?{publisherProcessIdentity:owner.processIdentity}:{}),createdAt:owner.createdAt})+'\n',{mode:0o600}); + if(active)fs.linkSync(candidate,activePath); + return {leases,records:[candidate,decision,...(active?[activePath]:[])]}; +} +function withHolder(fn:(pid:number)=>T):T{ + const holder=Bun.spawn([process.execPath,'-e','setTimeout(()=>{},60000)'],{stdio:['ignore','ignore','ignore']}); + try{return fn(holder.pid);}finally{holder.kill();} +} +function identityAs(pid:number,identity:string|undefined){ + const original=processIdentitySource.read.bind(processIdentitySource); + return spyOn(processIdentitySource,'read').mockImplementation((value:number)=>value===pid?identity:original(value)); +} +function failureOf(fn:()=>unknown):any{try{fn();}catch(error){return error;}return undefined;} + +describe('#2894 lease owner identity and stale-lease recovery',()=>{ + function unverifiableOtherRun(){ + const other=path.join(privateRoot(),'b'.repeat(24),`${Date.now()}-${'1'.repeat(16)}`);fs.mkdirSync(other,{recursive:true,mode:0o700}); + const {records}=ownedLease(other,{pid:2147483647,token:'c'.repeat(32),createdAt:1}); + const decision=JSON.parse(fs.readFileSync(records[1],'utf8'));decision.candidateIno=String(BigInt(decision.candidateIno)+1n); + fs.writeFileSync(records[1],JSON.stringify(decision)+'\n',{mode:0o600}); + return {other,records}; + } + function expectStillBlocked(other:string,records:string[]){ + const blocked=failureOf(()=>withLock(other,()=>1)); + expect(blocked).toMatchObject({code:'UNSAFE_PATH'}); + expect(blocked.message).toContain('does not match its candidate owner'); + expect(blocked.message).toContain('Next: start a new run'); + expect(blocked.message).not.toMatch(/delete|remove the lock/i); + for(const record of records)expect(fs.existsSync(record)).toBe(true); + } + function isolatedState(fn:(base:string)=>T):T{ + const previous=process.env.GSTACK_HOME,base=tmp();process.env.GSTACK_HOME=path.join(base,'state'); + const restore=()=>{if(previous===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=previous;}; + try{const value=fn(base);if(value instanceof Promise)return value.finally(restore) as T;restore();return value;}catch(error){restore();throw error;} + } + + test('maintenance skips another run whose lease decision cannot be verified and leaves it blocked',()=>isolatedState(()=>{ + const {other,records}=unverifiableOtherRun(); + expect(retention(Date.now()).complete).toBe(true); + expectStillBlocked(other,records); + })); + + test.skipIf(process.platform==='win32')('start is not blocked by another repository run whose lease decision cannot be verified',()=>isolatedState(async base=>{ + const repo=path.join(base,'repo');fs.mkdirSync(repo); + for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture']])expect(spawnSync('git',args,{cwd:repo,timeout:10_000}).status).toBe(0); + fs.writeFileSync(path.join(repo,'app.js'),'console.log("fixture")\n'); + for(const args of [['add','app.js'],['commit','-qm','fixture']])expect(spawnSync('git',args,{cwd:repo,timeout:10_000}).status).toBe(0); + const {other,records}=unverifiableOtherRun(); + const started=await dispatchCsoCommand('start',['--repo',repo,'--offline'],{runtimeCatalog:RUNTIME_CATALOG,catalogImageSession:async()=>{throw new Error('unused');},watchdogPath:()=>'/unused'} as any) as any; + expect(started.status).toBe('running'); + expectStillBlocked(other,records); + }),30_000); + + test('identity decoding: Windows FILETIME and macOS proc_bsdinfo start times',()=>{ + expect(identityStartedAtMs(win32Identity(1_700_000_000_123))).toBe(1_700_000_000_123); + expect(identityStartedAtMs(`win32:${FILETIME_UNIX_EPOCH}`)).toBe(0); + expect(identityStartedAtMs('darwin:1700000000123456')).toBe(1_700_000_000_123); + expect(identityStartedAtMs('linux:123456')).toBeUndefined(); + const info=new Uint8Array(136),view=new DataView(info.buffer); + view.setUint32(12,4242,true);view.setBigUint64(120,1_700_000_000n,true);view.setBigUint64(128,123_456n,true); + expect(darwinIdentityFromBsdInfo(info,4242)).toBe('darwin:1700000000123456'); + expect(darwinIdentityFromBsdInfo(info,4243)).toBeUndefined(); + expect(darwinIdentityFromBsdInfo(info.subarray(0,135),4242)).toBeUndefined(); + for(const value of ['linux:1','win32:133000000000000000','darwin:1700000000123456'])expect(PROCESS_IDENTITY.test(value)).toBe(true); + for(const value of ['win32:','macos:1','win32:-1','linux:1 '])expect(PROCESS_IDENTITY.test(value)).toBe(false); + }); + + test.skipIf(!['linux','darwin','win32'].includes(process.platform))('the live identity source distinguishes this process from a child',()=>{ + const own=processIdentitySource.read(process.pid); + expect(own).toMatch(PROCESS_IDENTITY);expect(processIdentitySource.read(process.pid)).toBe(own!); + withHolder(pid=>{const child=processIdentitySource.read(pid);expect(child).toMatch(PROCESS_IDENTITY);expect(child).not.toBe(own!);}); + }); + + test('a recycled PID on Windows or macOS (holder started after the record) is reclaimed',()=>withHolder(pid=>{ + const dir=tmp(),createdAt=Date.now()-60_000,{leases,records}=ownedLease(dir,{pid,token:'a'.repeat(32),createdAt}); + for(const identity of [win32Identity(Date.now()-1_000),`darwin:${BigInt(Date.now()-1_000)*1000n}`]){ + const spy=identityAs(pid,identity); + try{expect(withLock(dir,()=>7)).toBe(7);}finally{spy.mockRestore();} + for(const record of records)expect(fs.existsSync(record)).toBe(false); + expect(fs.readdirSync(leases)).toEqual([]); + ownedLease(dir,{pid,token:'a'.repeat(32),createdAt}); + } + })); + + test('a holder that started before the record, or a record with no creation time, is never stolen',()=>withHolder(pid=>{ + for(const [createdAt,started] of [[Date.now()-1_000,Date.now()-60_000],[0,Date.now()-1_000]]){ + const dir=tmp(),{records}=ownedLease(dir,{pid,token:'b'.repeat(32),createdAt},true),spy=identityAs(pid,win32Identity(started)); + let failure:any;try{failure=failureOf(()=>withLock(dir,()=>1));}finally{spy.mockRestore();} + expect(failure).toMatchObject({code:'INSUFFICIENT_CAPACITY'}); + expect(failure.message).toContain(`process ${pid} is running and the lease record predates process identity`); + for(const record of records)expect(fs.existsSync(record)).toBe(true); + } + })); + + test('an unknown identity stays conservative and names the owner pid with a safe next step',()=>withHolder(pid=>{ + const dir=tmp(),{records}=ownedLease(dir,{pid,token:'d'.repeat(32),createdAt:Date.now(),processIdentity:win32Identity(Date.now()-5_000)},true),spy=identityAs(pid,undefined); + let failure:any;try{failure=failureOf(()=>withLock(dir,()=>1));}finally{spy.mockRestore();} + expect(failure).toMatchObject({code:'INSUFFICIENT_CAPACITY'}); + expect(failure.message).toContain('Another helper is updating this run'); + expect(failure.message).toContain(`process ${pid} is running; process identity unavailable`); + expect(failure.message).toContain('Status: blocked; this command changed nothing'); + expect(failure.message).toContain('wait for that session to finish or check the owning session'); + expect(failure.message).not.toMatch(/delete|remove the lock/i); + for(const record of records)expect(fs.existsSync(record)).toBe(true); + })); + + // A second helper process runs while this one is suspended at one point of its + // reclaim. Whatever the interleaving, exactly one helper acquires the lease and + // the recycled-PID records are consumed once. + const contender=(dir:string,script:string)=>{ + const result=spawnSync(process.execPath,[script,dir],{encoding:'utf8',timeout:30_000}); + expect(result.status).toBe(0);return JSON.parse(result.stdout.trim()); + }; + test.skipIf(process.platform!=='linux').each([ + ['while the first helper is reclaiming','unlinkSync','.json','second'], + ['after the first helper holds its ticket','linkSync','.active.','first'], + ] as const)('two helpers racing for a recycled-PID lease: %s',(_name,hook,marker,winner)=>withHolder(pid=>{ + const dir=tmp(),script=path.join(dir,'..',`${path.basename(dir)}-contender.ts`);roots.push(script); + fs.writeFileSync(script,`import {withLock} from ${JSON.stringify(path.resolve(import.meta.dir,'../lib/cso/state'))}; +try{withLock(process.argv[2],()=>0);console.log(JSON.stringify({won:true}));}catch(error:any){console.log(JSON.stringify({won:false,code:error?.code,message:error?.message}));}`); + const {leases,records}=ownedLease(dir,{pid,token:'e'.repeat(32),createdAt:Date.now(),processIdentity:'linux:1'}); + const original=(fs as any)[hook].bind(fs);let second:any; + const spy=spyOn(fs as any,hook).mockImplementation((...args:any[])=>{ + const target=String(args[hook==='linkSync'?1:0]); + if(!second&&target.includes(marker)&&(hook==='linkSync'||target===records[0]))second=contender(dir,script); + return original(...args); + }); + let first:any; + try{try{first={won:withLock(dir,()=>true)};}catch(error:any){first={won:false,code:error?.code,message:error?.message};}}finally{spy.mockRestore();} + expect(second).toBeDefined(); + expect([first.won,second.won].filter(Boolean)).toHaveLength(1); + expect(winner==='first'?first.won:second.won).toBe(true); + expect((winner==='first'?second:first).code).toBe('INSUFFICIENT_CAPACITY'); + for(const record of records)expect(fs.existsSync(record)).toBe(false); + expect(fs.readdirSync(leases)).toEqual([]); + })); +}); diff --git a/test/cso-macos-launcher.test.ts b/test/cso-macos-launcher.test.ts index 995124a57..289a8eeae 100644 --- a/test/cso-macos-launcher.test.ts +++ b/test/cso-macos-launcher.test.ts @@ -2,7 +2,8 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { identityStartedAtMs, processIdentitySource } from '../lib/cso/process-identity'; const ROOT = path.resolve(import.meta.dir, '..'); const macos = process.platform === 'darwin'; @@ -38,6 +39,23 @@ describe('CSO native macOS build contract', () => { }); (macos ? describe : describe.skip)('CSO native macOS startup', () => { + test('#2894 the darwin process identity source reads real process start times', async () => { + const own = processIdentitySource.read(process.pid), started = identityStartedAtMs(own ?? ''); + expect(own).toMatch(/^darwin:\d+$/); + expect(processIdentitySource.read(process.pid)).toBe(own!); + expect(started!).toBeLessThanOrEqual(Date.now()); + expect(started!).toBeGreaterThan(Date.now() - 3_600_000); + await Bun.sleep(20); + const child = spawn(process.execPath, ['-e', 'setTimeout(()=>{},30000)'], { stdio: 'ignore' }); + try { + const identity = processIdentitySource.read(child.pid!); + expect(identity).toMatch(/^darwin:\d+$/); + expect(identity).not.toBe(own!); + expect(identityStartedAtMs(identity!)!).toBeGreaterThanOrEqual(started!); + } finally { child.kill(); } + expect(processIdentitySource.read(2147483647)).toBeUndefined(); + }); + test('the public launcher has a valid hardened-runtime signature', () => { const launcher = path.join(ROOT, 'bin', 'gstack-cso-launcher'); const verified = spawnSync('/usr/bin/codesign', ['--verify', '--strict', launcher], { encoding: 'utf8', timeout: 30_000 }); diff --git a/test/cso-windows-launcher.test.ts b/test/cso-windows-launcher.test.ts index 533a447ae..e0f10eb5e 100644 --- a/test/cso-windows-launcher.test.ts +++ b/test/cso-windows-launcher.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { createHash } from 'node:crypto'; import { spawn, spawnSync } from 'node:child_process'; +import { identityStartedAtMs, processIdentitySource } from '../lib/cso/process-identity'; import { createPrecisionLossCandidate } from './helpers/cso-ntfs-fixture'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -92,6 +93,34 @@ function expectSuccessfulProcess(result: ReturnType, label: st })}`); } +// Diagnostic snapshot of every lease artifact under a CSO state root. A failed +// native lease command embeds it in the assertion and keeps a copy outside the +// fixture so the exact owner/decision records survive cleanup. +function leaseRecords(root: string): string { + const records: unknown[] = []; + for (const leases of filesNamed(root, '.mutation-lock').map(lock => path.join(path.dirname(lock), '.mutation-lock-leases'))) { + let names: string[] = []; + try { names = fs.readdirSync(leases).sort(); } catch { continue; } + for (const name of names) { + const file = path.join(leases, name); + try { + const stat = fs.lstatSync(file, { bigint: true }), text = stat.size <= 4096n ? fs.readFileSync(file, 'utf8') : ''; + let pidAlive: boolean | undefined; + try { const value = JSON.parse(text), pid = value.ownerPid ?? value.pid; if (Number.isInteger(pid)) { try { process.kill(pid, 0); pidAlive = true; } catch (error: any) { pidAlive = error?.code === 'EPERM'; } } } catch {} + records.push({ file: path.relative(root, file), dev: String(stat.dev), ino: String(stat.ino), nlink: String(stat.nlink), size: String(stat.size), text, pidAlive }); + } catch (error: any) { records.push({ file: path.relative(root, file), error: error?.code ?? String(error) }); } + } + } + const serialized = JSON.stringify(records), keep = path.join(process.env.RUNNER_TEMP ?? os.tmpdir(), 'cso-lease-diagnostics'); + try { fs.mkdirSync(keep, { recursive: true }); fs.writeFileSync(path.join(keep, `${Date.now()}-${process.pid}.json`), serialized); } catch {} + return serialized.slice(0, 16_384); +} + +function expectLeaseCommand(result: ReturnType, label: string, root: string) { + try { expectSuccessfulProcess(result, label); } + catch (error: any) { throw new Error(`${error.message}\nlease records: ${leaseRecords(root)}`); } +} + function filesNamed(root: string, name: string): string[] { const found: string[] = []; const walk = (directory: string) => { @@ -369,6 +398,69 @@ describe('CSO native Windows build contract', () => { } }, 180_000); + test('#2894 the win32 process identity source reads real process start times', async () => { + const own = processIdentitySource.read(process.pid), started = identityStartedAtMs(own ?? ''); + expect(own).toMatch(/^win32:\d+$/); + expect(processIdentitySource.read(process.pid)).toBe(own!); + expect(started!).toBeLessThanOrEqual(Date.now()); + expect(started!).toBeGreaterThan(Date.now() - 3_600_000); + await Bun.sleep(20); + const child = spawn(process.execPath, ['-e', 'setTimeout(()=>{},30000)'], { stdio: 'ignore' }); + try { + const identity = processIdentitySource.read(child.pid!); + expect(identity).toMatch(/^win32:\d+$/); + expect(identity).not.toBe(own!); + expect(identityStartedAtMs(identity!)!).toBeGreaterThanOrEqual(started!); + } finally { child.kill(); } + expect(processIdentitySource.read(2147483647)).toBeUndefined(); + }); + + test('#2894 lease sequence: repeated commands, a legacy record elsewhere, and recycled or dead owners never brick start', async () => { + const repository=path.join(temporary,'issue 2894 repository'),other=path.join(temporary,'issue 2894 other repository'),profile=path.join(temporary,'issue 2894 profile'); + for(const directory of [repository,other,profile])fs.mkdirSync(directory); + const git='C:\\Program Files\\Git\\cmd\\git.exe',gitEnv={...process.env,HOME:profile}; + for(const repo of [repository,other]){ + for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repo,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);} + fs.writeFileSync(path.join(repo,'app.js'),'console.log("fixture")\n');fs.writeFileSync(path.join(repo,'other.js'),'console.log("other")\n'); + for(const args of [['add','app.js','other.js'],['commit','-qm','fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repo,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);} + } + const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary},state=path.join(profile,'.gstack','security','cso'); + const command=(args:string[],cwd=repository)=>spawnSync(actual,args,{cwd,encoding:'utf8',env,timeout:30_000}); + const step=(args:string[],label:string,cwd=repository)=>{const result=command(args,cwd);expectLeaseCommand(result,label,state);return result;}; + const started=JSON.parse(step(['start','--repo',repository,'--offline'],'start').stdout),dir=path.join(state,started.repoId,started.runId),leases=path.join(dir,'.mutation-lock-leases'); + for(const [args,label] of [[['resume',started.runId],'resume'],[['inspect',started.runId],'inspect'],[['read',started.runId,'app.js'],'read app.js'],[['read',started.runId,'other.js'],'read other.js'],[['history',started.runId],'history'],[['inspect',started.runId],'inspect again']] as [string[],string][])step(args,label); + expect(fs.readdirSync(leases)).toEqual([]); + // A record left by gstack <= 1.88.0 in another repository's run (lossy NTFS file ID) stays + // blocked for that run, but start for any repository still succeeds. + const otherRun=JSON.parse(step(['start','--repo',other,'--offline'],'start other',other).stdout),otherLeases=path.join(state,otherRun.repoId,otherRun.runId,'.mutation-lock-leases'); + step(['resume',otherRun.runId],'resume other',other); + const legacy='d'.repeat(32),legacyCandidate=path.join(otherLeases,`${legacy}.json`),legacyDecision=path.join(otherLeases,`${legacy}.decision`); + createPrecisionLossCandidate(legacyCandidate,JSON.stringify({pid:2147483647,token:legacy,createdAt:1})+'\n'); + const legacyStat=fs.lstatSync(legacyCandidate,{bigint:true}); + fs.writeFileSync(legacyDecision,JSON.stringify({schemaVersion:1,token:legacy,kind:'ticket',ticket:'0000000000000001',candidateDev:String(legacyStat.dev),candidateIno:String(Number(legacyStat.ino)),ownerPid:2147483647,ownerCreatedAt:1,publisherPid:2147483647,createdAt:1})+'\n'); + const next=JSON.parse(step(['start','--repo',repository,'--offline'],'start with a legacy record in another repository').stdout); + expect(next.runId).not.toBe(started.runId); + const blocked=command(['resume',otherRun.runId],other);expect(blocked.status).not.toBe(0);expect(blocked.stderr).toContain('UNSAFE_PATH'); + expect(fs.existsSync(legacyCandidate)).toBe(true);expect(fs.existsSync(legacyDecision)).toBe(true); + // A recycled PID: the recorded owner is gone and an unrelated process that started after the + // lease was written now holds that PID. A plain dead PID is reclaimed the same way. + const nextLeases=path.join(state,next.repoId,next.runId,'.mutation-lock-leases'); + step(['resume',next.runId],'resume next'); + const createdAt=Date.now();await Bun.sleep(2_500); + const holder=spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{stdio:'ignore'}); + try{ + for(const [token,pid] of [['e'.repeat(32),holder.pid!],['f'.repeat(32),2147483647]] as [string,number][]){ + const candidate=path.join(nextLeases,`${token}.json`),decision=path.join(nextLeases,`${token}.decision`); + fs.writeFileSync(candidate,JSON.stringify({pid,token,createdAt})+'\n'); + const stat=fs.lstatSync(candidate,{bigint:true}); + fs.writeFileSync(decision,JSON.stringify({schemaVersion:1,token,kind:'ticket',ticket:'0000000000000001',candidateDev:String(stat.dev),candidateIno:String(stat.ino),ownerPid:pid,ownerCreatedAt:createdAt,publisherPid:pid,createdAt})+'\n'); + step(['inspect',next.runId],`inspect after ${pid===2147483647?'dead':'recycled'} owner`); + expect(fs.existsSync(candidate)).toBe(false);expect(fs.existsSync(decision)).toBe(false); + } + expect(fs.readdirSync(nextLeases)).toEqual([]); + }finally{holder.kill();} + }, 180_000); + test('the actual helper rejects source mutation during snapshot capture without certifying a report', async () => { const repository=path.join(temporary,'racing repository'),profile=path.join(temporary,'race profile'),padding=path.join(repository,'padding'),target=path.join(repository,'zzzz-race-target.js'); fs.mkdirSync(repository);fs.mkdirSync(profile);fs.mkdirSync(padding); diff --git a/test/redact-engine.test.ts b/test/redact-engine.test.ts index db9e1c02b..32ab8d9bb 100644 --- a/test/redact-engine.test.ts +++ b/test/redact-engine.test.ts @@ -234,6 +234,30 @@ describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => { expect(ids(`authToken: ${v}`)).toContain("env.kv"); // (iv) credential camel expect(ids(`clientSecret: ${v}`)).toContain("env.kv"); // (iv) credential camel }); + // #2912 — a line that READS a secret from the environment holds no secret; + // it must not fire (and so must not be masked or withhold a /cso source file). + test("env.kv skips exact environment reads (#2912)", () => { + for (const line of [ + 'api_key=os.environ["AGENTOPS_API_KEY"]', + ' api_key=os.environ["AGENTOPS_API_KEY"]', + "LIVEKIT_API_KEY = os.getenv('LIVEKIT_API_KEY')", + 'token = os.environ.get("GH_TOKEN")', + "API_KEY=process.env.OPENAI_API_KEY", + "SECRET_KEY=getenv(\"APP_SECRET_KEY\")", + ]) { + expect(ids(line)).not.toContain("env.kv"); + expect(redactFindingSpans(`x=1\n${line}\n`, {})).toBe(`x=1\n${line}\n`); + } + }); + test("env.kv still fires on real high-entropy keys beside or instead of an env read (#2912 negative controls)", () => { + const v = "Zq8vR2mN5tYb7Lc3Wd9K"; + expect(ids(`SECRET_KEY = os.getenv('DJANGO_SECRET', '${v}')`)).toContain("env.kv"); + expect(ids(`API_KEY=process.env.OPENAI_API_KEY||"${v}"`)).toContain("env.kv"); + expect(ids(`API_KEY=os.environ["X"]+"${v}"`)).toContain("env.kv"); + expect(ids(`API_KEY=os.environ_${v}`)).toContain("env.kv"); + expect(ids(`API_KEY=getenv${v}`)).toContain("env.kv"); + expect(ids(`API_KEY="${v}"`)).toContain("env.kv"); + }); test("env.kv stays MEDIUM (calibration: generic net, not a blocker)", () => { const f = scan("api_key=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ", { repoVisibility: "private" }) .findings.find((x) => x.id === "env.kv"); diff --git a/test/ship-hook-windows-paths.test.ts b/test/ship-hook-windows-paths.test.ts new file mode 100644 index 000000000..f81648bf5 --- /dev/null +++ b/test/ship-hook-windows-paths.test.ts @@ -0,0 +1,94 @@ +/** + * #2805: /ship's opted-in pre-push guard must install in a linked worktree on + * Windows, where `git rev-parse --git-path hooks` returns an absolute path in a + * different spelling (C:/… or C:\…) than Git Bash's `pwd` (/c/…). The guard + * decides from git's own answers (core.hooksPath unset, hooks directory not a + * link), never by string-matching two path spellings. + * + * Windows-safe by construction: helpers run through explicit `bash