Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
33 changes: 28 additions & 5 deletions docs/cloud-hypervisor-foundation.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,20 @@ AWF performs these steps for each run:
run directory.
6. Create a bounded cgroup v2 leaf and launch Cloud Hypervisor as the invoking
non-root identity.
7. Start one sandboxed `virtiofsd` process for each validated export.
8. Create and boot the VM, connect to the guest supervisor over VSOCK, verify
7. After the API responds, verify the launched VMM's trusted host `/proc` and
cgroup state. AWF fails closed before `vm.create` if the PID identity,
executable, credentials, capabilities, `no_new_privs`, seccomp worker,
network namespace, cgroup membership, or resource limits differ from the
launch policy.
8. Start one sandboxed `virtiofsd` process for each validated export.
9. Create and boot the VM, connect to the guest supervisor over VSOCK, verify
loopback plus the configured guest interface, address, and route, and probe
each trusted infrastructure service with bounded retries. An exhausted
retryable readiness failure recreates the VM at most twice before the agent
command is dispatched.
9. Execute the agent command and propagate its exit code. Timeouts return
10. Execute the agent command and propagate its exit code. Timeouts return
`124`.
10. Sync and unmount guest filesystems, stop the VM and VMM, reap `virtiofsd`,
11. Sync and unmount guest filesystems, stop the VM and VMM, reap `virtiofsd`,
and remove network, cgroup, and run-directory resources.

Cleanup is idempotent and aggregates errors so one cleanup failure does not
Expand Down Expand Up @@ -120,6 +125,23 @@ shell. The process:
- receives a minimal Landlock filesystem allowlist; and
- belongs to a cgroup v2 leaf with explicit memory, CPU, and PID limits.

API socket readiness alone is not treated as proof of confinement. Before
creating any VM or starting `virtiofsd`, AWF reads the VMM's host `/proc`
records and cgroup files as root. It verifies the PID twice using the kernel
start-time field and executable symlink to reject process-exit and PID-reuse
races. Credentials and capability sets are checked against the launcher's
current policy for every observed thread, and both the `vmm` worker and
`http-server` API thread must be in seccomp filter mode. The verifier also
compares network namespace inode links, requires exclusive membership in the
per-run cgroup, and checks the exact memory, CPU, and PID limits computed by the
cgroup policy.

Successful verification produces bounded structured evidence in
`confinement.json` alongside the other run diagnostics. The evidence records
the stable process identity, expected credentials and capabilities, relevant
seccomp thread IDs, namespace inode, and cgroup membership and limits; it does
not copy unbounded `/proc` content.

The private run directory is under
`/run/awf-cloud-hypervisor/<binary>/<runId>/`. Its per-run leaf is accessible
only to the selected non-root identity and root.
Expand Down Expand Up @@ -468,7 +490,8 @@ sudo nft list ruleset

Inspect preserved workspace data under
`<workDir>/microvm-images/<runId>/` and VMM diagnostics under the run's
preserved log directory.
preserved log directory. `confinement.json` contains the production
post-launch verification evidence captured before `vm.create`.

:::caution
Preserved namespaces and processes continue consuming host resources. Remove
Expand Down
171 changes: 171 additions & 0 deletions src/cloud-hypervisor/confinement-verifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {
verifyCloudHypervisorConfinement,
type CloudHypervisorConfinementVerifierDependencies,
} from './confinement-verifier';
import type { CloudHypervisorLaunchConfinementPolicy } from './launcher';

const PID = 4242;
const CGROUP = '/sys/fs/cgroup/awf-cloud-hypervisor/run-1';
const CAPABILITY_MASK = '0000000000002000';

function launchPolicy(): CloudHypervisorLaunchConfinementPolicy {
return {
supplementaryGroups: [978],
capabilities: {
inheritable: CAPABILITY_MASK,
permitted: CAPABILITY_MASK,
effective: CAPABILITY_MASK,
bounding: CAPABILITY_MASK,
ambient: CAPABILITY_MASK,
},
noNewPrivs: 1,
};
}

function status(name: string, seccomp: number, taskId = PID): string {
return [
`Name:\t${name}`,
`Pid:\t${taskId}`,
`Tgid:\t${PID}`,
'Uid:\t1000\t1000\t1000\t1000',
'Gid:\t1001\t1001\t1001\t1001',
'Groups:\t978',
`CapInh:\t${CAPABILITY_MASK}`,
`CapPrm:\t${CAPABILITY_MASK}`,
`CapEff:\t${CAPABILITY_MASK}`,
`CapBnd:\t${CAPABILITY_MASK}`,
`CapAmb:\t${CAPABILITY_MASK}`,
'NoNewPrivs:\t1',
`Seccomp:\t${seccomp}`,
'',
].join('\n');
}

function procStat(startTime: string): string {
return `${PID} (cloud hypervisor) ${['S', ...Array(18).fill('0'), startTime, '0'].join(' ')}`;
}

function dependencies(overrides: {
statReads?: string[];
executable?: string;
workerStatus?: string;
cgroupProcs?: string;
} = {}): CloudHypervisorConfinementVerifierDependencies {
const statReads = [...(overrides.statReads ?? [procStat('98765'), procStat('98765')])];
const files: Record<string, string> = {
[`/proc/${PID}/task/${PID}/status`]: status('cloud-hypervis', 0),
[`/proc/${PID}/task/${PID + 1}/status`]:
overrides.workerStatus ?? status('vmm', 2, PID + 1),
[`/proc/${PID}/task/${PID + 2}/status`]: status('http-server', 2, PID + 2),
[`/proc/${PID}/cgroup`]: '0::/awf-cloud-hypervisor/run-1\n',
[`${CGROUP}/cgroup.procs`]: overrides.cgroupProcs ?? `${PID}\n`,
[`${CGROUP}/memory.max`]: '805306368\n',
[`${CGROUP}/cpu.max`]: '300000 100000\n',
[`${CGROUP}/pids.max`]: '256\n',
};
return {
readFile: jest.fn(async (filePath) => {
if (filePath === `/proc/${PID}/stat`) {
const value = statReads.shift();
if (!value) throw new Error('unexpected stat read');
return value;
}
const taskStatMatch = filePath.match(new RegExp(`^/proc/${PID}/task/(\\d+)/stat$`));
if (taskStatMatch) return procStat(String(99000 + Number(taskStatMatch[1])));
const value = files[filePath];
if (value === undefined) throw new Error(`unexpected read: ${filePath}`);
return value;
}),
readlink: jest.fn(async (filePath) => {
if (filePath === `/proc/${PID}/exe`) {
return overrides.executable ?? '/opt/cloud-hypervisor';
}
if (filePath === `/proc/${PID}/ns/net`) {
return 'net:[4026533000]';
}
throw new Error(`unexpected readlink: ${filePath}`);
}),
readdir: jest.fn().mockResolvedValue([String(PID + 2), String(PID + 1), String(PID)]),
realpath: jest.fn().mockResolvedValue('/opt/cloud-hypervisor'),
stat: jest.fn().mockResolvedValue({ ino: 4026533000n }),
};
}

function options() {
return {
pid: PID,
expectedExecutable: '/opt/cloud-hypervisor',
identity: { uid: 1000, gid: 1001 },
launchPolicy: launchPolicy(),
networkNamespace: 'awfvm-test',
cgroupPath: CGROUP,
cgroupLimits: {
memoryMax: '805306368',
cpuMax: '300000 100000',
pidsMax: '256',
},
};
}

describe('verifyCloudHypervisorConfinement', () => {
it('verifies stable process, thread, namespace, and cgroup state with policy-derived capabilities', async () => {
const result = await verifyCloudHypervisorConfinement(options(), dependencies());

expect(result).toEqual(expect.objectContaining({
schemaVersion: 1,
process: {
pid: PID,
startTimeTicks: '98765',
executable: '/opt/cloud-hypervisor',
},
identity: {
uid: 1000,
gid: 1001,
supplementaryGroups: [978],
},
capabilities: expect.objectContaining({ effective: CAPABILITY_MASK }),
noNewPrivs: 1,
seccomp: {
mode: 2,
relevantThreadIds: [PID + 1, PID + 2],
observedThreadCount: 3,
},
networkNamespace: {
name: 'awfvm-test',
inode: 'net:[4026533000]',
},
cgroup: expect.objectContaining({
path: CGROUP,
membership: '/awf-cloud-hypervisor/run-1',
}),
}));
});

it('fails closed when PID identity changes while evidence is collected', async () => {
await expect(verifyCloudHypervisorConfinement(
options(),
dependencies({ statReads: [procStat('98765'), procStat('98766')] }),
)).rejects.toThrow(/process identity or thread-set race/);
});

it('rejects a different executable even when the PID exists', async () => {
await expect(verifyCloudHypervisorConfinement(
options(),
dependencies({ executable: '/usr/bin/setpriv' }),
)).rejects.toThrow(/found executable/);
});

it('requires the Cloud Hypervisor vmm worker to have seccomp filter mode 2', async () => {
await expect(verifyCloudHypervisorConfinement(
options(),
dependencies({ workerStatus: status('vmm', 0, PID + 1) }),
)).rejects.toThrow(/does not have seccomp filter mode 2/);
});

it('requires exclusive membership in the configured bounded cgroup', async () => {
await expect(verifyCloudHypervisorConfinement(
options(),
dependencies({ cgroupProcs: `${PID}\n5000\n` }),
)).rejects.toThrow(/cgroup\.procs to contain only PID/);
});
});
Loading
Loading