From 9eeef47db1e90be5267a5b7dd77aa586fda9247e Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 14 Oct 2026 21:10:51 -0700 Subject: [PATCH 1/2] fix: diagnose public debug preflight and hand off startup audit Keep public fixture verification fail-closed with bounded status and transport classes. Hand the resolved config artifact to the sudo runner before cleanup can stop to preserve private recovery state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 04a0bb67-0f8b-4901-9f55-bf6b57c3d39b --- docs/cloud-hypervisor-foundation.md | 16 +++ .../main-action-audit-handoff.test.ts | 113 ++++++++++++++++++ src/commands/main-action.test.ts | 6 +- src/commands/main-action.ts | 22 +++- .../cloud-hypervisor-lifecycle.test.ts | 3 +- src/enclave/public-debug.test.ts | 59 ++++++++- src/enclave/public-debug.ts | 100 +++++++++++----- 7 files changed, 283 insertions(+), 36 deletions(-) create mode 100644 src/commands/main-action-audit-handoff.test.ts diff --git a/docs/cloud-hypervisor-foundation.md b/docs/cloud-hypervisor-foundation.md index 18e860142..2b359c316 100644 --- a/docs/cloud-hypervisor-foundation.md +++ b/docs/cloud-hypervisor-foundation.md @@ -192,6 +192,12 @@ and requires matching repository identity, `private: false`, and `visibility: "public"`. Each check has a five-second timeout and a 64 KiB response ceiling; unavailable, rate-limited, malformed, private, or unverified metadata prevents launch. The host needs HTTPS access to `api.github.com`. +Failures report a bounded reason (`http-status`, `missing-body`, `timeout`, +`request-failed`, `body-read`, `metadata-bound`, `invalid-json`, or +`unverified-metadata`), the HTTP status when available, and only allowlisted +transport codes. Response bodies, headers, repository names, and raw transport +errors are not included. A `403` alone does not establish rate limiting, and +an unknown transport code must not be treated as a diagnosed network cause. Dynamic repository admission, non-public sensitivities (including `trusted`), custom enclave images, unattested development artifacts, and non-Cloud-Hypervisor executors are rejected. A guest request, payload, or environment variable cannot @@ -218,6 +224,16 @@ evidence. An artifact write failure is surfaced as an executor cleanup failure, not a successful invocation. Abrupt host termination/SIGKILL cannot guarantee collection; existing resource-recovery rules still apply. +Before startup, the resolved configuration audit artifact is handed to the +sudo runner at `0600` (with its audit directory traversable by that owner), so +the runner can read and rewrite it for secret redaction even if fatal cleanup +stops to preserve recovery state. This is not a recursive ownership or mode +repair: private control/recovery data and the raw debug artifacts remain +protected. An absent `enclave.jsonl` when containers never started is not +evidence of an enclave invocation; retain the startup exit marker separately. +Release-pinned workflows need an AWF release containing these diagnostics and +handoff changes before they can use them. + **Disclosure risk:** public repository visibility does not make raw output safe. Payloads, model responses, URLs, and incidental data may still contain secrets or other sensitive content. Use only synthetic public-fixture inputs, inspect diff --git a/src/commands/main-action-audit-handoff.test.ts b/src/commands/main-action-audit-handoff.test.ts new file mode 100644 index 000000000..3bc61a747 --- /dev/null +++ b/src/commands/main-action-audit-handoff.test.ts @@ -0,0 +1,113 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { testHelpers } from './main-action'; +import { MAIN_ACTION_STUB_CONFIG } from './main-action.test-utils'; +import { logger } from '../logger'; +import { stopCloudHypervisorEnclaveLifecycle } from '../enclave/cloud-hypervisor-lifecycle'; +import { cleanup as cleanupWorkDirectory } from '../docker-manager'; +import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; +import { verifyPublicEnclaveDebugRepositories } from '../enclave/public-debug'; +import type { WrapperConfig } from '../types'; + +jest.mock('fs', () => ({ __esModule: true, ...jest.requireActual('fs') })); +jest.mock('../enclave/cloud-hypervisor-lifecycle'); +jest.mock('../docker-manager'); + +describe('resolved configuration startup artifact handoff', () => { + let directory: string; + beforeEach(() => { + jest.clearAllMocks(); + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-config-handoff-')); + }); + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it('hands diagnostics to the runner even when visibility preflight and fatal cleanup fail', async () => { + const auditDir = path.join(directory, 'audit'); + const privateRoot = path.join(directory, 'recovery'); + fs.mkdirSync(privateRoot, { mode: 0o700 }); + const journal = path.join(privateRoot, 'state'); + fs.writeFileSync(journal, 'PRIVATE_SENTINEL', { mode: 0o600 }); + const handoffs: string[] = []; + const effectiveUid = jest.spyOn(process, 'getuid').mockReturnValue(0); + const effectiveGid = jest.spyOn(process, 'getgid').mockReturnValue(0); + const chown = jest.spyOn(fs, 'fchownSync').mockImplementation((fd, uid, gid) => { + expect(uid).toBe(1001); + expect(gid).toBe(1002); + const stat = fs.fstatSync(fd); + handoffs.push(stat.isDirectory() ? 'directory' + : fs.readFileSync(path.join(auditDir, 'awf-resolved-config.json'), 'utf8')); + }); + const uid = process.env.SUDO_UID; + const gid = process.env.SUDO_GID; + process.env.SUDO_UID = '1001'; + process.env.SUDO_GID = '1002'; + const config: WrapperConfig = { + ...MAIN_ACTION_STUB_CONFIG, workDir: directory, auditDir, + proxyLogsDir: path.join(directory, 'logs'), + cloudHypervisor: { + previewEnabled: true, enclaveDebugPublic: true, mountPolicy: 'workspace-only', + cloudHypervisorBinary: '/usr/bin/cloud-hypervisor', vcpuCount: 2, memoryMib: 4096, + apiTimeoutMs: 5000, + }, + enclaves: normalizeEnclavesConfig([{ + script: {}, runtime: 'cloud-hypervisor', + repos: [{ repo: 'octo/fixture', sensitivity: 'public' }], + }]), + }; + try { + testHelpers.persistConfigAuditArtifact(config, { state: 'preflight' }); + effectiveUid.mockRestore(); + effectiveGid.mockRestore(); + const request = jest.fn().mockResolvedValue(new Response('{}', { status: 403 })); + await expect(verifyPublicEnclaveDebugRepositories(config, request)).rejects.toThrow('status=403'); + expect(testHelpers.writeStartupFailureDiagnostic(config, new Error('visibility preflight failed'))).toBe('written'); + jest.mocked(stopCloudHypervisorEnclaveLifecycle).mockRejectedValueOnce(new Error('cleanup uncertain')); + const cleanup = testHelpers.buildCleanupFn(config, () => false, () => false); + await expect(cleanup()).rejects.toThrow('cleanup uncertain'); + expect(cleanupWorkDirectory).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(auditDir, 'enclave.jsonl'))).toBe(false); + } finally { + if (uid === undefined) delete process.env.SUDO_UID; else process.env.SUDO_UID = uid; + if (gid === undefined) delete process.env.SUDO_GID; else process.env.SUDO_GID = gid; + } + expect(chown).toHaveBeenCalledTimes(2); + expect(handoffs).toEqual(['directory', expect.stringContaining('"preflight"')]); + expect(fs.statSync(auditDir).mode & 0o777).toBe(0o700); + const file = path.join(auditDir, 'awf-resolved-config.json'); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + // The runner's redactor needs to read and replace this file, not just upload it. + fs.writeFileSync(file, '[REDACTED]'); + expect(fs.readFileSync(file, 'utf8')).toBe('[REDACTED]'); + expect(fs.statSync(privateRoot).mode & 0o777).toBe(0o700); + expect(fs.statSync(journal).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(journal, 'utf8')).toBe('PRIVATE_SENTINEL'); + }); + + it('keeps exclusive creation and reports a failed handoff instead of claiming preservation', () => { + const auditDir = path.join(directory, 'audit'); + fs.mkdirSync(auditDir, { mode: 0o700 }); + const artifact = path.join(auditDir, 'awf-resolved-config.json'); + fs.writeFileSync(artifact, 'existing', { mode: 0o600 }); + const warning = jest.spyOn(logger, 'warn').mockImplementation(); + testHelpers.persistConfigAuditArtifact({ ...MAIN_ACTION_STUB_CONFIG, auditDir }, {}); + expect(fs.readFileSync(artifact, 'utf8')).toBe('existing'); + expect(warning).toHaveBeenCalledWith( + 'Failed to publish resolved config artifact:', expect.objectContaining({ code: 'EEXIST' }), + ); + }); + + it('rejects a symlink audit directory without changing its target', () => { + const target = path.join(directory, 'target'); + fs.mkdirSync(target, { mode: 0o700 }); + const auditDir = path.join(directory, 'audit'); + fs.symlinkSync(target, auditDir); + const warning = jest.spyOn(logger, 'warn').mockImplementation(); + testHelpers.persistConfigAuditArtifact({ ...MAIN_ACTION_STUB_CONFIG, auditDir }, {}); + expect(fs.readdirSync(target)).toEqual([]); + expect(warning).toHaveBeenCalled(); + }); +}); diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index 0101c49c7..91b0593df 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -1392,15 +1392,15 @@ describe('createMainAction', () => { expect(redacted.additionalEnv).toBe('raw-env-string'); }); - it('persistConfigAuditArtifact logs debug when writing the artifact fails', () => { + it('persistConfigAuditArtifact warns when publishing the artifact fails', () => { mockMkdirSync.mockImplementationOnce(() => { throw new Error('write failed'); }); testHelpers.persistConfigAuditArtifact(MAIN_ACTION_STUB_CONFIG, { foo: 'bar' }); - expect(mockedLogger.debug).toHaveBeenCalledWith( - expect.stringContaining('Failed to write resolved config artifact:') + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Failed to publish resolved config artifact:', expect.any(Error), ); }); diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index a71b92215..7d92a2881 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -23,6 +23,7 @@ import { runMainWorkflow } from '../cli-workflow'; import { deriveSensitiveEndpointForms, redactSecrets, redactSensitiveValues } from '../redact-secrets'; import { joinShellArgs } from '../option-parsers'; import { assertRealDirectory, writeFileNoFollow } from '../fs-utils'; +import { getSafeHostGid, getSafeHostUid } from '../host-identity'; import { applyConfigFilePrecedence } from './preflight'; import { registerSignalHandlers } from './signal-handler'; import { validateOptions } from './validate-options'; @@ -126,15 +127,34 @@ function persistConfigAuditArtifact( try { const configArtifactDir = config.auditDir || path.join(config.workDir, 'audit'); fs.mkdirSync(configArtifactDir, { recursive: true, mode: 0o700 }); + assertRealDirectory(configArtifactDir); + const owner = process.getuid?.() === 0 && process.env.SUDO_UID + ? { uid: Number(getSafeHostUid()), gid: Number(getSafeHostGid()) } : undefined; + // Cleanup can intentionally stop before generic artifact permission repair. + // Hand this runner diagnostic off now, without exposing private recovery state. + if (owner) { + const directoryFd = fs.openSync(configArtifactDir, + fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW); + try { + if (!fs.fstatSync(directoryFd).isDirectory()) throw new Error('Unsafe audit directory'); + fs.fchownSync(directoryFd, owner.uid, owner.gid); + } finally { + fs.closeSync(directoryFd); + } + } const configArtifactPath = path.join(configArtifactDir, 'awf-resolved-config.json'); const fd = fs.openSync(configArtifactPath, 'wx', 0o600); try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.nlink !== 1) throw new Error('Unsafe resolved config artifact'); fs.writeFileSync(fd, JSON.stringify(redactedConfig, null, 2) + '\n'); + fs.fsyncSync(fd); + if (owner) fs.fchownSync(fd, owner.uid, owner.gid); } finally { fs.closeSync(fd); } } catch (err) { - logger.debug(`Failed to write resolved config artifact: ${err}`); + logger.warn('Failed to publish resolved config artifact:', err); } } diff --git a/src/enclave/cloud-hypervisor-lifecycle.test.ts b/src/enclave/cloud-hypervisor-lifecycle.test.ts index db09bda47..b11652d03 100644 --- a/src/enclave/cloud-hypervisor-lifecycle.test.ts +++ b/src/enclave/cloud-hypervisor-lifecycle.test.ts @@ -239,7 +239,8 @@ describe('trusted Cloud Hypervisor enclave lifecycle', () => { full_name: repository.repo, private: !isPublic, visibility: isPublic ? 'public' : 'private', }))); if (!isPublic) { - await expect(startCloudHypervisorEnclaveLifecycle(config, provider, {})).rejects.toThrow(/public/); + await expect(startCloudHypervisorEnclaveLifecycle(config, provider, {})) + .rejects.toThrow('reason=unverified-metadata'); expect(provider.prepareRun).not.toHaveBeenCalled(); expect(start).not.toHaveBeenCalled(); } else { diff --git a/src/enclave/public-debug.test.ts b/src/enclave/public-debug.test.ts index 805632bd9..4867f1deb 100644 --- a/src/enclave/public-debug.test.ts +++ b/src/enclave/public-debug.test.ts @@ -56,13 +56,70 @@ describe('host-only public enclave debug admission', () => { it('rejects unavailable, malformed and oversized visibility evidence', async () => { await expect(verifyPublicEnclaveDebugRepositories(config(), requester({}, 404))).rejects.toThrow(/verified/); const request: jest.MockedFunction = jest.fn().mockRejectedValue(new Error('network timeout')); - await expect(verifyPublicEnclaveDebugRepositories(config(), request)).rejects.toThrow('network timeout'); + await expect(verifyPublicEnclaveDebugRepositories(config(), request)).rejects.toThrow('reason=request-failed'); await expect(verifyPublicEnclaveDebugRepositories(config(), requester('x'.repeat(65537)))) .rejects.toThrow(/bound/); request.mockResolvedValue(new Response('not json')); await expect(verifyPublicEnclaveDebugRepositories(config(), request)).rejects.toThrow(); }); + it.each([301, 302, 401, 403, 404, 429, 503])('reports HTTP %s without exposing response contents or headers', async (status) => { + const request = jest.fn().mockResolvedValue(new Response('PRIVATE_SENTINEL', { + status, headers: { 'x-private-header': 'PRIVATE_SENTINEL' }, + })); + await expect(verifyPublicEnclaveDebugRepositories(config(), request)) + .rejects.toThrow(`reason=http-status; status=${status}`); + await expect(verifyPublicEnclaveDebugRepositories(config(), request)) + .rejects.not.toThrow('PRIVATE_SENTINEL'); + expect(request).toHaveBeenCalledTimes(2); + }); + + it.each([ + [new DOMException('PRIVATE_SENTINEL', 'TimeoutError'), 'reason=timeout'], + [Object.assign(new TypeError('PRIVATE_SENTINEL'), { cause: { code: 'ENOTFOUND' } }), 'code=ENOTFOUND'], + [Object.assign(new TypeError('PRIVATE_SENTINEL'), { cause: { code: 'CERT_HAS_EXPIRED' } }), 'code=CERT_HAS_EXPIRED'], + [Object.assign(new TypeError('PRIVATE_SENTINEL'), { cause: { code: 'PRIVATE_SENTINEL' } }), 'code=unknown'], + ])('reports only allowlisted transport evidence for %p', async (error, expected) => { + const request = jest.fn().mockRejectedValue(error); + let failure: unknown; + try { + await verifyPublicEnclaveDebugRepositories(config(), request); + } catch (caught) { + failure = caught; + } + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).toContain(expected); + expect(String(failure)).not.toContain('PRIVATE_SENTINEL'); + expect(failure).not.toHaveProperty('cause'); + expect(request).toHaveBeenCalledTimes(1); + }); + + it.each([ + [() => new Response(null), 'missing-body'], + [() => new Response('PRIVATE_SENTINEL'), 'invalid-json'], + [() => new Response(JSON.stringify({ private: true })), 'unverified-metadata'], + [() => new Response('x'.repeat(65537)), 'metadata-bound'], + [() => new Response(new ReadableStream({ + start(controller) { controller.error(new Error('PRIVATE_SENTINEL')); }, + })), 'body-read'], + ])('classifies failed metadata evidence as %s', async (response, reason) => { + const request = jest.fn().mockResolvedValue(response()); + await expect(verifyPublicEnclaveDebugRepositories(config(), request)).rejects.toThrow(`reason=${reason}`); + }); + + it('allows exactly 64 KiB and keeps the five-second request deadline', async () => { + const timeout = jest.spyOn(AbortSignal, 'timeout'); + const metadata = JSON.stringify({ full_name: 'octo/fixture', private: false, visibility: 'public' }); + const request = jest.fn().mockResolvedValue(new Response(metadata.padEnd(64 * 1024, ' '))); + try { + await verifyPublicEnclaveDebugRepositories(config(), request); + expect(timeout).toHaveBeenCalledWith(5000); + expect(request).toHaveBeenCalledTimes(1); + } finally { + timeout.mockRestore(); + } + }); + it.each([ ['private sensitivity', (c: WrapperConfig) => { c.enclaves!.privateRepos[0].sensitivity = 'internal'; }], ['trusted sensitivity', (c: WrapperConfig) => { c.enclaves!.privateRepos[0].sensitivity = 'trusted'; }], diff --git a/src/enclave/public-debug.ts b/src/enclave/public-debug.ts index 5a7adfbb1..0fa54c2b7 100644 --- a/src/enclave/public-debug.ts +++ b/src/enclave/public-debug.ts @@ -30,6 +30,29 @@ export function validatePublicEnclaveDebug(config: WrapperConfig): string[] { return []; } +const VISIBILITY_TRANSPORT_CODES = new Set([ + 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', + 'CERT_HAS_EXPIRED', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'ERR_TLS_CERT_ALTNAME_INVALID', +]); + +class PublicDebugVisibilityError extends Error { + constructor(reason: string, status?: number) { + super(`Public enclave debug repository visibility could not be verified (reason=${reason}` + + (status === undefined ? '' : `; status=${status}`) + ')'); + } +} + +function visibilityTransportCode(error: unknown): string { + if (!error || typeof error !== 'object') return 'unknown'; + const candidate = 'cause' in error && error.cause && typeof error.cause === 'object' + ? error.cause : error; + return 'code' in candidate && typeof candidate.code === 'string' + && VISIBILITY_TRANSPORT_CODES.has(candidate.code) ? candidate.code : 'unknown'; +} + /** * Sensitivity is a disclosure policy, not proof of public visibility. Verify * without credentials or redirects before any debug-enabled VM can launch. @@ -43,38 +66,55 @@ export async function verifyPublicEnclaveDebugRepositories( if (errors.length) throw new Error(errors.join('; ')); for (const { repo } of config.enclaves!.privateRepos) { const signal = AbortSignal.timeout(5_000); - const response = await request(`https://api.github.com/repos/${repo}`, { - headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'awf-public-enclave-debug' }, - redirect: 'error', - signal, - }); - if (response.status !== 200 || !response.body) { - await response.body?.cancel(); - throw new Error('Public enclave debug repository visibility could not be verified'); - } - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; + let phase = 'request-failed'; + let status: number | undefined; try { - while (true) { - const next = await reader.read(); - if (next.done) break; - size += next.value.length; - if (size > 64 * 1024) throw new Error('Public enclave debug repository metadata exceeds its bound'); - chunks.push(next.value); + const response = await request(`https://api.github.com/repos/${repo}`, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'awf-public-enclave-debug' }, + redirect: 'error', + signal, + }); + status = response.status; + if (status !== 200) { + phase = 'http-status'; + await response.body?.cancel(); + throw new PublicDebugVisibilityError('http-status', status); } - } finally { - await reader.cancel(); - } - const metadata: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')); - if ( - !metadata || typeof metadata !== 'object' - || !('private' in metadata) || metadata.private !== false - || !('visibility' in metadata) || metadata.visibility !== 'public' - || !('full_name' in metadata) || typeof metadata.full_name !== 'string' - || metadata.full_name.toLowerCase() !== repo.toLowerCase() - ) { - throw new Error('Public enclave debug requires positively verified public repository visibility'); + if (!response.body) throw new PublicDebugVisibilityError('missing-body'); + phase = 'body-read'; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + size += next.value.length; + if (size > 64 * 1024) throw new PublicDebugVisibilityError('metadata-bound'); + chunks.push(next.value); + } + } finally { + await reader.cancel(); + } + phase = 'invalid-json'; + const metadata: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if ( + !metadata || typeof metadata !== 'object' + || !('private' in metadata) || metadata.private !== false + || !('visibility' in metadata) || metadata.visibility !== 'public' + || !('full_name' in metadata) || typeof metadata.full_name !== 'string' + || metadata.full_name.toLowerCase() !== repo.toLowerCase() + ) { + throw new PublicDebugVisibilityError('unverified-metadata'); + } + } catch (error) { + if (error instanceof PublicDebugVisibilityError) throw error; + const timeout = signal.aborted || (error && typeof error === 'object' + && 'name' in error && error.name === 'TimeoutError'); + throw new PublicDebugVisibilityError( + `${timeout ? 'timeout' : phase}; code=${visibilityTransportCode(error)}`, + status, + ); } } } From 9306c45b1987968a16ded9ae9563d43543b6a276 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 14 Oct 2026 21:14:37 -0700 Subject: [PATCH 2/2] test: use descriptors for startup audit handoff assertions Keep permission assertions and redaction reads and writes on the same opened file. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 04a0bb67-0f8b-4901-9f55-bf6b57c3d39b --- .../main-action-audit-handoff.test.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/commands/main-action-audit-handoff.test.ts b/src/commands/main-action-audit-handoff.test.ts index 3bc61a747..898477452 100644 --- a/src/commands/main-action-audit-handoff.test.ts +++ b/src/commands/main-action-audit-handoff.test.ts @@ -78,13 +78,27 @@ describe('resolved configuration startup artifact handoff', () => { expect(handoffs).toEqual(['directory', expect.stringContaining('"preflight"')]); expect(fs.statSync(auditDir).mode & 0o777).toBe(0o700); const file = path.join(auditDir, 'awf-resolved-config.json'); - expect(fs.statSync(file).mode & 0o777).toBe(0o600); - // The runner's redactor needs to read and replace this file, not just upload it. - fs.writeFileSync(file, '[REDACTED]'); - expect(fs.readFileSync(file, 'utf8')).toBe('[REDACTED]'); + const artifactFd = fs.openSync(file, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW); + try { + expect(fs.fstatSync(artifactFd).mode & 0o777).toBe(0o600); + // The runner's redactor needs to read and replace this file, not just upload it. + expect(fs.readFileSync(artifactFd, 'utf8')).toContain('"preflight"'); + fs.ftruncateSync(artifactFd, 0); + fs.writeSync(artifactFd, '[REDACTED]', 0, 'utf8'); + const redacted = Buffer.alloc(Buffer.byteLength('[REDACTED]')); + fs.readSync(artifactFd, redacted, 0, redacted.length, 0); + expect(redacted.toString('utf8')).toBe('[REDACTED]'); + } finally { + fs.closeSync(artifactFd); + } expect(fs.statSync(privateRoot).mode & 0o777).toBe(0o700); - expect(fs.statSync(journal).mode & 0o777).toBe(0o600); - expect(fs.readFileSync(journal, 'utf8')).toBe('PRIVATE_SENTINEL'); + const journalFd = fs.openSync(journal, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + expect(fs.fstatSync(journalFd).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(journalFd, 'utf8')).toBe('PRIVATE_SENTINEL'); + } finally { + fs.closeSync(journalFd); + } }); it('keeps exclusive creation and reports a failed handoff instead of claiming preservation', () => {