Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/cloud-hypervisor-foundation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
113 changes: 113 additions & 0 deletions src/commands/main-action-audit-handoff.test.ts
Original file line number Diff line number Diff line change
@@ -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]');
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
expect(fs.readFileSync(file, 'utf8')).toBe('[REDACTED]');
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
expect(fs.statSync(privateRoot).mode & 0o777).toBe(0o700);
expect(fs.statSync(journal).mode & 0o777).toBe(0o600);
expect(fs.readFileSync(journal, 'utf8')).toBe('PRIVATE_SENTINEL');
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
});

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();
});
});
6 changes: 3 additions & 3 deletions src/commands/main-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
});

Expand Down
22 changes: 21 additions & 1 deletion src/commands/main-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Comment on lines +131 to +132
// 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);
Comment on lines +136 to +137
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);
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/enclave/cloud-hypervisor-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
59 changes: 58 additions & 1 deletion src/enclave/public-debug.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch> = 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'; }],
Expand Down
Loading
Loading