From bac935414e15392d30f60c25904af1fdb2320b7e Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 23 Aug 2026 10:57:11 -0700 Subject: [PATCH 1/3] feat: support filesystem allowWrite with Cloud Hypervisor Wires the write-policy planner (#7660) into the host mount-tree enforcement layer (#7661) so `filesystem.allowWrite` is actually enforced by the Cloud Hypervisor microVM runtime. Both foundations landed deliberately inert; this is the runtime integration. `filesystem-write-enforcement.ts` is the only place the two meet. It plans the policy in a dedicated startup stage before the boot loop, so an invalid allowlist aborts before virtiofsd or the guest launches, and translates each planner disposition into the merged mount-tree API: - policy absent: no enforcement argument at all, byte-identical legacy behaviour and original export objects; - unrestricted/fully writable: guest `rw`, no plan; - fully read-only: guest `ro`, staged host root `ro`, zero-overlay plan; - selectively writable: guest `rw`, staged host root `ro`, one overlay per allowed path. A zero-overlay read-only export still gets a plan rather than falling back to the legacy single bind plus remount, so a narrowed export always uses the recursively-verified staged tree. No `internalTags` are passed: Cloud Hypervisor has no analogue of Docker's always-writable log and session-state binds, and exempting `tmp-gh-aw` would defeat the narrowing a policy like `allowWrite: ["/tmp/gh-aw/agent"]` expresses. Because the host tree is the boundary, a selective export is never mounted read-only guest-side. `validateCloudHypervisorExports()` accepts a read-only `workspace` only when a plan for that tag exists, so a read-only workspace nothing enforces is still rejected. Unknown plan tags stay fail-closed and planner validation is not duplicated. Removes only the Cloud Hypervisor rejection from `filesystem-policy.ts`; sbx and Docker-in-Docker still fail closed. Adds live-KVM smoke cases proving an allowed directory and file write persists to the host while sibling, parent, create, truncate, rename, and delete outside the allowlist fail, plus empty-allowlist narrowing and a fail-closed abort. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/test-cloud-hypervisor.yml | 1 + docs/awf-config-spec.md | 11 +- docs/awf-config.schema.json | 2 +- docs/cloud-hypervisor-foundation.md | 66 ++++++-- .../ci/cloud-hypervisor-ci-scripts.test.ts | 21 +++ scripts/ci/cloud-hypervisor-live-smoke.sh | 53 ++++++ src/awf-config-schema.json | 2 +- src/cloud-hypervisor-runtime-backend.test.ts | 78 +++++++++ src/cloud-hypervisor-runtime-backend.ts | 17 +- src/cloud-hypervisor/exports.test.ts | 19 +++ src/cloud-hypervisor/exports.ts | 39 ++++- .../filesystem-write-enforcement.test.ts | 153 ++++++++++++++++++ .../filesystem-write-enforcement.ts | 105 ++++++++++++ src/cloud-hypervisor/manager-types.ts | 8 +- src/cloud-hypervisor/manager.test.ts | 58 +++++++ src/cloud-hypervisor/manager.ts | 12 +- .../vm-config-builder.test.ts | 38 ++++- src/cloud-hypervisor/vm-config-builder.ts | 9 +- src/filesystem-policy.test.ts | 20 ++- src/filesystem-policy.ts | 8 +- 20 files changed, 687 insertions(+), 33 deletions(-) create mode 100644 src/cloud-hypervisor/filesystem-write-enforcement.test.ts create mode 100644 src/cloud-hypervisor/filesystem-write-enforcement.ts diff --git a/.github/workflows/test-cloud-hypervisor.yml b/.github/workflows/test-cloud-hypervisor.yml index 56c690d00..51cf9c0fc 100644 --- a/.github/workflows/test-cloud-hypervisor.yml +++ b/.github/workflows/test-cloud-hypervisor.yml @@ -19,6 +19,7 @@ on: - 'src/cloud-hypervisor-runtime-backend.ts' - 'src/cloud-hypervisor-runtime-backend.test.ts' - 'src/microvm/**' + - 'src/filesystem-policy.ts' - 'src/types/runtime-options.ts' - 'scripts/ci/cloud-hypervisor-*.sh' - 'docs/cloud-hypervisor-foundation.md' diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 0465d7e0d..5fc33d3d7 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -99,9 +99,14 @@ existing writable host mount. An empty list makes all non-internal host bind mounts read-only. AWF-owned agent log and session-state mounts and required virtual devices remain -writable so the sandbox can operate. `filesystem.allowWrite` currently supports -Docker and gVisor compose runtimes; AWF rejects it with unsupported microVM -runtimes. +writable so the sandbox can operate. `filesystem.allowWrite` is supported by the +Docker and gVisor compose runtimes and by the Cloud Hypervisor microVM runtime, +where it is enforced by the host mount tree that backs each virtio-fs export +(see [docs/cloud-hypervisor-foundation.md](./cloud-hypervisor-foundation.md#runtime-integration)). +Cloud Hypervisor has no always-writable internal mounts, so a policy narrows +every export it publishes, including `/tmp/gh-aw` and the guest home directory +at `/workspace/.awf-home`. AWF rejects `filesystem.allowWrite` with the sbx +runtime and with Docker-in-Docker agent execution. ### 4.2 Cloud Hypervisor microVM preview diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 79a332be1..0ee39137c 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -83,7 +83,7 @@ "type": "string", "pattern": "^/(?!\\.\\.(?:/|$))(?!.*(?:/)\\.\\.(?:/|$))[^*?\\[\\]{}]*$" }, - "description": "Guest-visible absolute paths that may be writable. When present, existing writable host bind mounts are made read-only outside these paths. An empty array makes all non-internal host bind mounts read-only. Paths must exist and cannot use glob patterns. This option is currently supported by Docker and gVisor compose runtimes." + "description": "Guest-visible absolute paths that may be writable. When present, existing writable host bind mounts are made read-only outside these paths. An empty array makes all non-internal host bind mounts read-only. Paths must exist and cannot use glob patterns. This option is supported by the Docker and gVisor compose runtimes and by the Cloud Hypervisor microVM runtime; it is rejected with the sbx runtime and with Docker-in-Docker agent execution." } } }, diff --git a/docs/cloud-hypervisor-foundation.md b/docs/cloud-hypervisor-foundation.md index 2780093cb..c63daf152 100644 --- a/docs/cloud-hypervisor-foundation.md +++ b/docs/cloud-hypervisor-foundation.md @@ -164,7 +164,7 @@ Temporary microVM workspace data lives under: With `--keep-containers`, AWF preserves this directory, the network namespace, and runtime diagnostics for investigation. -### Write-policy planning (inert) +### Write-policy planning [`src/cloud-hypervisor/filesystem-write-policy.ts`](../src/cloud-hypervisor/filesystem-write-policy.ts) plans how a `filesystem.allowWrite` allowlist would narrow validated exports. It @@ -190,13 +190,11 @@ escape the export source. The planner only removes write access: it never widens a read-only export and never introduces a host path that an existing read-write export does not -already cover. It is pure policy planning and is not yet wired into runtime -execution — `filesystem.allowWrite` is still rejected for the Cloud Hypervisor -runtime by [`src/filesystem-policy.ts`](../src/filesystem-policy.ts). The host -side of that boundary — how a `hostRootMode: 'ro'` root with writable overlays is -actually staged and enforced — is described in -[Host mount-tree enforcement](#host-mount-tree-enforcement) below. The two layers -are independent: neither is wired into runtime execution yet. +already cover. It is pure policy planning; the host side of that boundary — how +a `hostRootMode: 'ro'` root with writable overlays is actually staged and +enforced — is described in +[Host mount-tree enforcement](#host-mount-tree-enforcement) below, and +[Runtime integration](#runtime-integration) describes how the two are joined. ## Host mount-tree enforcement @@ -206,7 +204,7 @@ guest-side read-only mount is not a security boundary. The only trustworthy boundary is the host VFS. This is the host-side counterpart to -[Write-policy planning](#write-policy-planning-inert): the planner decides which +[Write-policy planning](#write-policy-planning): the planner decides which paths stay writable, and this layer stages a host mount tree that enforces it. `VirtiofsdManager.start()` therefore accepts an optional, strongly typed @@ -313,6 +311,52 @@ require containment inside the export, but this residual setup-time TOCTOU windo cannot be closed without fd-based mount APIs that the current tooling does not expose. +## Runtime integration + +[`src/cloud-hypervisor/filesystem-write-enforcement.ts`](../src/cloud-hypervisor/filesystem-write-enforcement.ts) +is the only place where the planner and the host mount tree meet. The Cloud +Hypervisor runtime backend resolves and validates its exports, then plans the +policy in a dedicated `filesystem-write-policy` startup stage *before* the boot +loop, so an invalid allowlist aborts the run before virtiofsd or the guest is +ever launched, and before any retry can re-attempt it. The resulting +`VirtiofsdMountEnforcement` is threaded through `createManager()` into +`CloudHypervisorManager`, which forwards it to `VirtiofsdManager.start()`. + +No `internalTags` are passed to the planner. Cloud Hypervisor has no analogue of +the Docker runtime's always-writable agent-log and session-state binds: every +export it publishes is host-visible workspace or runner state, and marking one +internal — `tmp-gh-aw` in particular — would defeat the narrowing that a policy +such as `allowWrite: ["/tmp/gh-aw/agent"]` exists to express. + +The translation is total; there is no fallback path: + +| Planner disposition | Guest mount mode | Host staged root | Mount plan passed to virtiofsd | +| --- | --- | --- | --- | +| policy absent (`undefined`) | unchanged | unchanged | none — `start()` receives no enforcement argument at all, so behaviour is byte-identical to a run without a policy | +| unrestricted / fully writable (`hostRootMode: 'rw'`) | `rw` | unchanged | none | +| fully read-only (`hostRootMode: 'ro'`, no overlays) | `ro` | `ro` | plan with zero overlays | +| selectively writable (`hostRootMode: 'ro'`, overlays) | `rw` | `ro` | plan with one overlay per allowed path | + +A read-only export with zero overlays still gets a plan rather than falling back +to the legacy single `mount --bind` plus `remount,ro`. The staged tree is the +only variant that recursively remounts carried-in submounts read-only and +verifies the result against `/proc/self/mountinfo`, so a policy-narrowed export +is always served by the stronger path. + +Because the host tree is the boundary, a selectively writable export is never +mounted read-only guest-side. The guest mode is derived from the plan, so +`validateCloudHypervisorExports()` accepts a read-only `workspace` export only +when a mount plan for the `workspace` tag actually exists; a read-only workspace +that nothing enforces is still rejected. Unknown plan tags remain fail-closed +via `assertPlansMatchExports()`, and the planner's own validation is not +duplicated here. + +One consequence is worth stating plainly: the guest `HOME` is +`/workspace/.awf-home`, inside the workspace export. A policy that narrows +`/workspace` — including an empty `allowWrite: []` — makes the agent's home +directory read-only. That is the policy working as specified, not an oversight; +add the home path to `allowWrite` if the workload needs it. + ## Limitations The preview rejects configurations that weaken or conflict with its boundary, @@ -349,6 +393,10 @@ The live job runs only when explicitly enabled by workflow dispatch or the - direct-egress, arbitrary-TCP, DNS, and metadata denial; - API proxy reachability and secret non-disclosure; - workspace persistence; +- `filesystem.allowWrite` enforcement — an allowed directory and file write + persisting to the host, sibling/parent/create/truncate/rename/delete denial + outside the allowlist, an empty allowlist narrowing the whole workspace, and + a fail-closed abort on an allowlist entry that matches no export path; - exit-code, timeout, and cancellation behavior; - device assumptions; - partial-start and normal cleanup; diff --git a/scripts/ci/cloud-hypervisor-ci-scripts.test.ts b/scripts/ci/cloud-hypervisor-ci-scripts.test.ts index 10c9e5a5f..7ea45095d 100644 --- a/scripts/ci/cloud-hypervisor-ci-scripts.test.ts +++ b/scripts/ci/cloud-hypervisor-ci-scripts.test.ts @@ -97,6 +97,9 @@ describe('cloud-hypervisor-live-smoke.sh', () => { 'api-proxy-reflect', 'workspace-live-share', 'runtime-cache-readonly', + 'allow-write', + 'allow-write-none', + 'allow-write-invalid', 'exit-code', 'timeout-124', 'partial-start-cleanup', @@ -162,6 +165,24 @@ describe('cloud-hypervisor-live-smoke.sh', () => { expect(source).toMatch(/COMMON=\(\n(?:.*\n)*?\s*--network-isolation\n/); }); + it('proves filesystem.allowWrite enforcement end to end, including fail-closed', () => { + const source = fs.readFileSync(smokePath, 'utf-8'); + // Selective policy: host-visible persistence of an allowed write, plus + // sibling/parent/create/truncate/rename/delete denial outside the list. + expect(source).toContain('"allowWrite": ["/workspace/allowed", "/workspace/allowed-file.txt"]'); + expect(source).toContain('test "$(cat "$allow_workspace/allowed/created.txt")" = guest-allowed'); + expect(source).toContain('test "$(cat "$allow_workspace/blocked/file.txt")" = host'); + expect(source).toContain('test ! -e "$allow_workspace/created-at-root.txt"'); + expect(source).toContain('test ! -e "$allow_workspace/renamed.txt"'); + // A selective export stays read-write guest-side; only the zero-overlay + // narrowing publishes the guest mount itself read-only. + expect(source).toContain('grep -q " /workspace/allowed virtiofs " /proc/mounts'); + expect(source).toContain('grep -q " /workspace virtiofs ro," /proc/mounts'); + // Unmatched allowlist entries abort the run instead of widening it. + expect(source).toContain('run_case allow-write-invalid 1'); + expect(source).toContain("grep -q 'filesystem.allowWrite'"); + }); + (shellcheckAvailable() ? it : it.skip)('has no shellcheck errors', () => { expect(() => execFileSync('shellcheck', ['--severity=error', smokePath]), diff --git a/scripts/ci/cloud-hypervisor-live-smoke.sh b/scripts/ci/cloud-hypervisor-live-smoke.sh index 45f87d1fe..0ac79f50e 100755 --- a/scripts/ci/cloud-hypervisor-live-smoke.sh +++ b/scripts/ci/cloud-hypervisor-live-smoke.sh @@ -280,6 +280,59 @@ run_case runtime-cache-readonly 0 \ 'grep -q cache-readable "$RUNNER_TEMP/gh-aw/awf-virtiofs-ro-probe" && ! printf changed > "$RUNNER_TEMP/gh-aw/awf-virtiofs-ro-probe"' test "$(cat "$RUNNER_TEMP/gh-aw/awf-virtiofs-ro-probe")" = cache-readable +# filesystem.allowWrite enforcement (docs/cloud-hypervisor-foundation.md +# "Runtime integration"). The host mount tree -- not the guest mount mode -- +# is the boundary, so a selective policy publishes /workspace to the guest +# read-write while virtiofsd only ever sees a read-only staged root with +# narrow read-write submounts bind-mounted back in. +allow_workspace="$RUN_ROOT/allow-write/workspace" +mkdir -p "$allow_workspace/allowed/nested" "$allow_workspace/blocked" +printf 'host\n' >"$allow_workspace/allowed/removable.txt" +printf 'host\n' >"$allow_workspace/allowed-file.txt" +printf 'host\n' >"$allow_workspace/blocked/file.txt" +printf 'host\n' >"$allow_workspace/rename-me.txt" +cat >"$RUN_ROOT/allow-write.json" <<'JSON' +{ + "filesystem": { + "allowWrite": ["/workspace/allowed", "/workspace/allowed-file.txt"] + } +} +JSON +run_case allow-write 0 \ + 'printf guest-allowed > /workspace/allowed/created.txt && mkdir -p /workspace/allowed/nested/deeper && printf deep > /workspace/allowed/nested/deeper/file.txt && rm /workspace/allowed/removable.txt && printf guest-file > /workspace/allowed-file.txt && grep -q " /workspace/allowed virtiofs " /proc/mounts && grep -q host-input /workspace/input.txt && ! printf blocked > /workspace/blocked/file.txt && ! printf blocked > /workspace/created-at-root.txt && ! mkdir /workspace/blocked-dir && ! : > /workspace/input.txt && ! mv /workspace/rename-me.txt /workspace/renamed.txt && ! rm -f /workspace/blocked/file.txt' \ + --config "$RUN_ROOT/allow-write.json" +test "$(cat "$allow_workspace/allowed/created.txt")" = guest-allowed +test "$(cat "$allow_workspace/allowed/nested/deeper/file.txt")" = deep +test "$(cat "$allow_workspace/allowed-file.txt")" = guest-file +test ! -e "$allow_workspace/allowed/removable.txt" +test "$(cat "$allow_workspace/blocked/file.txt")" = host +test "$(cat "$allow_workspace/input.txt")" = host-input +test -f "$allow_workspace/rename-me.txt" +test ! -e "$allow_workspace/renamed.txt" +test ! -e "$allow_workspace/created-at-root.txt" +test ! -e "$allow_workspace/blocked-dir" + +# An empty allowlist narrows every writable export with zero overlays: the +# guest mount itself is published read-only on top of the read-only staged +# host root. +cat >"$RUN_ROOT/allow-write-none.json" <<'JSON' +{ "filesystem": { "allowWrite": [] } } +JSON +run_case allow-write-none 0 \ + 'grep -q host-input /workspace/input.txt && grep -q " /workspace virtiofs ro," /proc/mounts && ! printf blocked > /workspace/input.txt && ! printf blocked > /workspace/created.txt' \ + --config "$RUN_ROOT/allow-write-none.json" +test "$(cat "$RUN_ROOT/allow-write-none/workspace/input.txt")" = host-input +test ! -e "$RUN_ROOT/allow-write-none/workspace/created.txt" + +# Fail-closed: an allowlist entry that matches no export path must abort the +# run before any guest boots, and must leave no staged mount residue. +cat >"$RUN_ROOT/allow-write-invalid.json" <<'JSON' +{ "filesystem": { "allowWrite": ["/workspace/does-not-exist"] } } +JSON +run_case allow-write-invalid 1 'true' \ + --config "$RUN_ROOT/allow-write-invalid.json" +grep -q 'filesystem.allowWrite' "$RUN_ROOT/allow-write-invalid/stderr.log" + run_case exit-code 37 'exit 37' run_case timeout-124 124 'sleep 90' --agent-timeout 1 diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 79a332be1..0ee39137c 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -83,7 +83,7 @@ "type": "string", "pattern": "^/(?!\\.\\.(?:/|$))(?!.*(?:/)\\.\\.(?:/|$))[^*?\\[\\]{}]*$" }, - "description": "Guest-visible absolute paths that may be writable. When present, existing writable host bind mounts are made read-only outside these paths. An empty array makes all non-internal host bind mounts read-only. Paths must exist and cannot use glob patterns. This option is currently supported by Docker and gVisor compose runtimes." + "description": "Guest-visible absolute paths that may be writable. When present, existing writable host bind mounts are made read-only outside these paths. An empty array makes all non-internal host bind mounts read-only. Paths must exist and cannot use glob patterns. This option is supported by the Docker and gVisor compose runtimes and by the Cloud Hypervisor microVM runtime; it is rejected with the sbx runtime and with Docker-in-Docker agent execution." } } }, diff --git a/src/cloud-hypervisor-runtime-backend.test.ts b/src/cloud-hypervisor-runtime-backend.test.ts index 7ee6b6529..14b36f70e 100644 --- a/src/cloud-hypervisor-runtime-backend.test.ts +++ b/src/cloud-hypervisor-runtime-backend.test.ts @@ -1,3 +1,6 @@ +import { promises as fsPromises } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { PassThrough } from 'stream'; import type { WrapperConfig } from './types'; import * as hostEligibility from './cloud-hypervisor/host-eligibility'; @@ -226,6 +229,81 @@ describe('Cloud Hypervisor runtime backend', () => { } }); + it('plans filesystem.allowWrite before any boot attempt and passes it to the manager', async () => { + const directory = await fsPromises.realpath( + await fsPromises.mkdtemp(path.join(os.tmpdir(), 'ch-backend-write-')), + ); + try { + const workspaceSource = path.join(directory, 'workspace'); + const ghAwSource = path.join(directory, 'gh-aw'); + await fsPromises.mkdir(path.join(ghAwSource, 'agent'), { recursive: true }); + await fsPromises.mkdir(workspaceSource, { recursive: true }); + const resolved = [ + { tag: 'workspace', source: workspaceSource, target: '/workspace', mode: 'rw' as const }, + { tag: 'tmp-gh-aw', source: ghAwSource, target: '/tmp/gh-aw', mode: 'rw' as const }, + ]; + const { deps } = harness({ resolveExports: jest.fn().mockResolvedValue(resolved) }); + const backend = new CloudHypervisorRuntimeBackend( + config({ filesystemAllowWrite: ['/tmp/gh-aw/agent'] }), + deps, + ); + + await backend.start('/tmp/awf', ['github.com']); + + expect(deps.createManager).toHaveBeenCalledWith( + expect.anything(), + '/tmp/awf', + expect.anything(), + [ + { ...resolved[0], mode: 'ro' }, + { ...resolved[1], mode: 'rw' }, + ], + { uid: 1000, gid: 1000 }, + { + plans: [ + { tag: 'workspace', writableOverlays: [] }, + { + tag: 'tmp-gh-aw', + writableOverlays: [{ + source: path.join(ghAwSource, 'agent'), + destination: path.join(ghAwSource, 'agent'), + kind: 'directory', + }], + }, + ], + }, + ); + } finally { + await fsPromises.rm(directory, { recursive: true, force: true }); + } + }); + + it('passes no enforcement argument when filesystem.allowWrite is undefined', async () => { + const { deps } = harness(); + const backend = new CloudHypervisorRuntimeBackend(config(), deps); + + await backend.start('/tmp/awf', ['github.com']); + + const call = (deps.createManager as jest.Mock).mock.calls[0]; + expect(call[3]).toEqual([ + { tag: 'workspace', source: '/workspace-host', target: '/workspace', mode: 'rw' }, + ]); + expect(call[5]).toBeUndefined(); + expect(call).toHaveLength(6); + }); + + it('fails closed on an unmatched allowlist path before creating a manager', async () => { + const { deps } = harness(); + const backend = new CloudHypervisorRuntimeBackend( + config({ filesystemAllowWrite: ['/workspace/does-not-exist'] }), + deps, + ); + + await expect(backend.start('/tmp/awf', ['github.com'])) + .rejects.toThrow('filesystem.allowWrite path is not an existing path within a writable'); + expect(deps.createManager).not.toHaveBeenCalled(); + }); + it('starts infrastructure, revalidates it, boots and probes before execution', async () => { const { order, manager, deps, stdin } = harness(); const backend = new CloudHypervisorRuntimeBackend(config(), deps); diff --git a/src/cloud-hypervisor-runtime-backend.ts b/src/cloud-hypervisor-runtime-backend.ts index 277e82d37..10be17d78 100644 --- a/src/cloud-hypervisor-runtime-backend.ts +++ b/src/cloud-hypervisor-runtime-backend.ts @@ -30,6 +30,8 @@ import { resolveCloudHypervisorExports, type CloudHypervisorDirectoryExport, } from './cloud-hypervisor/exports'; +import { planCloudHypervisorFilesystemWriteEnforcement } from './cloud-hypervisor/filesystem-write-enforcement'; +import type { VirtiofsdMountEnforcement } from './cloud-hypervisor/virtiofsd'; export { assertCloudHypervisorPreSecurityCompatibility, assertCloudHypervisorRuntimeCompatibility, @@ -103,6 +105,7 @@ export interface CloudHypervisorRuntimeBackendDependencies { infrastructure: MicrovmInfrastructureSnapshot, exports: readonly CloudHypervisorDirectoryExport[], identity: { uid: number; gid: number }, + mountEnforcement?: VirtiofsdMountEnforcement, ): CloudHypervisorManagerAdapter; resolveExports(): Promise; identity(): { uid: number; gid: number }; @@ -121,7 +124,7 @@ function defaultDependencies( preflight: runCloudHypervisorPreflight, resolveInfrastructure: (enableApiProxy, ipPath, topologyPeerNames) => resolveMicrovmInfrastructure(enableApiProxy, undefined, ipPath, topologyPeerNames), - createManager: (config, workDir, infrastructure, exports, identity) => + createManager: (config, workDir, infrastructure, exports, identity, mountEnforcement) => new CloudHypervisorManager( config, workDir, @@ -139,6 +142,7 @@ function defaultDependencies( }, { exports, + ...(mountEnforcement ? { mountEnforcement } : {}), supervisorBinaryPath: config.supervisorPath!, supervisorSha256: config.sha256!.supervisor!, identity, @@ -274,7 +278,15 @@ export class CloudHypervisorRuntimeBackend implements ExternalAgentRuntimeBacken ); this.infrastructure = infrastructure; this.identity = this.dependencies.identity(); - const exports = await this.dependencies.resolveExports(); + stage = 'filesystem-write-policy'; + // Planned before the boot loop so an invalid or unmatched + // `filesystem.allowWrite` entry fails closed before virtiofsd, the VMM, + // or the guest is launched, and so every boot attempt reuses one + // decision instead of re-resolving host paths per attempt. + const { exports, mountEnforcement } = planCloudHypervisorFilesystemWriteEnforcement( + await this.dependencies.resolveExports(), + this.config.filesystemAllowWrite, + ); stage = 'topology-revalidation'; await infrastructure.revalidate(); for ( @@ -296,6 +308,7 @@ export class CloudHypervisorRuntimeBackend implements ExternalAgentRuntimeBacken infrastructure, exports, this.identity, + mountEnforcement, ); try { stage = 'vmm-configuration'; diff --git a/src/cloud-hypervisor/exports.test.ts b/src/cloud-hypervisor/exports.test.ts index d1475695e..9a28eab24 100644 --- a/src/cloud-hypervisor/exports.test.ts +++ b/src/cloud-hypervisor/exports.test.ts @@ -82,4 +82,23 @@ describe('Cloud Hypervisor directory exports', () => { { tag: 'workspace', source: 'relative', target: '/workspace', mode: 'rw' }, ])).toThrow(/absolute clean/); }); + + it('only permits a read-only workspace when explicitly allowed', () => { + const readOnlyWorkspace = [ + { tag: 'workspace', source: '/host/work', target: '/workspace', mode: 'ro' as const }, + ]; + + expect(() => validateCloudHypervisorExports(readOnlyWorkspace)) + .toThrow('Cloud Hypervisor requires read-write tag "workspace" at /workspace'); + expect(() => validateCloudHypervisorExports(readOnlyWorkspace, {})) + .toThrow('Cloud Hypervisor requires read-write tag "workspace" at /workspace'); + expect(validateCloudHypervisorExports(readOnlyWorkspace, { allowReadOnlyWorkspace: true })) + .toEqual(readOnlyWorkspace); + // The option relaxes only the mode, never the presence or target of the + // workspace export. + expect(() => validateCloudHypervisorExports( + [{ tag: 'cache', source: '/host/cache', target: '/cache', mode: 'ro' }], + { allowReadOnlyWorkspace: true }, + )).toThrow('Cloud Hypervisor requires tag "workspace" at /workspace'); + }); }); diff --git a/src/cloud-hypervisor/exports.ts b/src/cloud-hypervisor/exports.ts index 010004a45..7079ce325 100644 --- a/src/cloud-hypervisor/exports.ts +++ b/src/cloud-hypervisor/exports.ts @@ -20,6 +20,32 @@ export interface CloudHypervisorExportEnvironment { const SAFE_TAG = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,35}$/; const MAX_EXPORTS = 4; +/** + * Local alias used inside this module. `validateCloudHypervisorExports` takes a + * parameter named `exports`, which shadows the CommonJS module object, so a + * reference to the exported binding would compile to `exports.` on the + * parameter and silently resolve to `undefined`. + */ +const WORKSPACE_EXPORT_TAG = 'workspace'; + +/** Tag of the mandatory workspace export. */ +export const CLOUD_HYPERVISOR_WORKSPACE_EXPORT_TAG = WORKSPACE_EXPORT_TAG; + +export interface CloudHypervisorExportValidationOptions { + /** + * Permits the workspace export to be *published* read-only. + * + * The declared export layout resolved by + * {@link resolveCloudHypervisorExports} always requires a read-write + * workspace, and that resolution is validated without this option. Only a + * `filesystem.allowWrite` policy may narrow the published workspace mode, and + * only when the workspace is additionally staged read-only on the host, which + * is the actual boundary. Callers must therefore derive this from the + * presence of a workspace host mount plan, never enable it unconditionally. + */ + readonly allowReadOnlyWorkspace?: boolean; +} + export async function resolveCloudHypervisorExports( environment: CloudHypervisorExportEnvironment = process.env, cwd = process.cwd(), @@ -92,6 +118,7 @@ export async function resolveCloudHypervisorExports( export function validateCloudHypervisorExports( exports: readonly CloudHypervisorDirectoryExport[], + options: CloudHypervisorExportValidationOptions = {}, ): CloudHypervisorDirectoryExport[] { if (exports.length === 0 || exports.length > MAX_EXPORTS) { throw new Error(`Cloud Hypervisor requires 1-${MAX_EXPORTS} directory exports`); @@ -122,13 +149,19 @@ export function validateCloudHypervisorExports( } } targets.add(entry.target); - if (entry.tag === 'workspace') { - workspace = entry.target === '/workspace' && entry.mode === 'rw'; + if (entry.tag === WORKSPACE_EXPORT_TAG) { + workspace = + entry.target === '/workspace' && + (entry.mode === 'rw' || options.allowReadOnlyWorkspace === true); } return { ...entry }; }); if (!workspace) { - throw new Error('Cloud Hypervisor requires read-write tag "workspace" at /workspace'); + throw new Error( + options.allowReadOnlyWorkspace === true + ? 'Cloud Hypervisor requires tag "workspace" at /workspace' + : 'Cloud Hypervisor requires read-write tag "workspace" at /workspace', + ); } return validated; } diff --git a/src/cloud-hypervisor/filesystem-write-enforcement.test.ts b/src/cloud-hypervisor/filesystem-write-enforcement.test.ts new file mode 100644 index 000000000..414770aa6 --- /dev/null +++ b/src/cloud-hypervisor/filesystem-write-enforcement.test.ts @@ -0,0 +1,153 @@ +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { CloudHypervisorDirectoryExport } from './exports'; +import { + hasReadOnlyWorkspaceMountPlan, + planCloudHypervisorFilesystemWriteEnforcement, +} from './filesystem-write-enforcement'; + +describe('Cloud Hypervisor filesystem write enforcement translation', () => { + let directory: string; + let workspaceSource: string; + let toolsSource: string; + let tmpGhAwSource: string; + let exports: CloudHypervisorDirectoryExport[]; + + beforeEach(async () => { + directory = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ch-write-enforce-'))); + workspaceSource = path.join(directory, 'workspace'); + toolsSource = path.join(directory, 'tools'); + tmpGhAwSource = path.join(directory, 'gh-aw'); + await fs.mkdir(path.join(workspaceSource, 'nested'), { recursive: true }); + await fs.mkdir(path.join(tmpGhAwSource, 'agent'), { recursive: true }); + await fs.mkdir(path.join(tmpGhAwSource, 'cache'), { recursive: true }); + await fs.mkdir(toolsSource, { recursive: true }); + await fs.writeFile(path.join(workspaceSource, 'nested', 'file.txt'), 'data'); + exports = [ + { tag: 'workspace', source: workspaceSource, target: '/workspace', mode: 'rw' }, + { tag: 'runner-tool-cache', source: toolsSource, target: '/tools', mode: 'ro' }, + { tag: 'tmp-gh-aw', source: tmpGhAwSource, target: '/tmp/gh-aw', mode: 'rw' }, + ]; + }); + + afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }); + }); + + it('produces no enforcement and untouched exports when the policy is undefined', () => { + const result = planCloudHypervisorFilesystemWriteEnforcement(exports, undefined); + + expect(result.mountEnforcement).toBeUndefined(); + expect('mountEnforcement' in result).toBe(false); + expect(result.exports).toEqual(exports); + // Identity matters: the published list must be the resolved exports + // verbatim so virtiofsd's legacy staging path stays byte-identical. + result.exports.forEach((entry, index) => expect(entry).toBe(exports[index])); + expect(hasReadOnlyWorkspaceMountPlan(result.mountEnforcement)).toBe(false); + }); + + it('narrows every writable export for an empty allowlist without exempting /tmp/gh-aw', () => { + const result = planCloudHypervisorFilesystemWriteEnforcement(exports, []); + + expect(result.exports).toEqual([ + { ...exports[0], mode: 'ro' }, + { ...exports[1], mode: 'ro' }, + { ...exports[2], mode: 'ro' }, + ]); + expect(result.mountEnforcement).toEqual({ + plans: [ + { tag: 'workspace', writableOverlays: [] }, + { tag: 'runner-tool-cache', writableOverlays: [] }, + { tag: 'tmp-gh-aw', writableOverlays: [] }, + ], + }); + expect(hasReadOnlyWorkspaceMountPlan(result.mountEnforcement)).toBe(true); + }); + + it('maps the motivating /tmp/gh-aw/agent policy to one selective overlay', () => { + const result = planCloudHypervisorFilesystemWriteEnforcement(exports, ['/tmp/gh-aw/agent']); + + expect(result.exports).toEqual([ + { ...exports[0], mode: 'ro' }, + { ...exports[1], mode: 'ro' }, + // Selective exports must stay read-write guest-side or the announced + // writable submount would inherit MNT_READONLY from its parent. + { ...exports[2], mode: 'rw' }, + ]); + expect(result.mountEnforcement?.plans).toEqual([ + { tag: 'workspace', writableOverlays: [] }, + { tag: 'runner-tool-cache', writableOverlays: [] }, + { + tag: 'tmp-gh-aw', + writableOverlays: [{ + source: path.join(tmpGhAwSource, 'agent'), + destination: path.join(tmpGhAwSource, 'agent'), + kind: 'directory', + }], + }, + ]); + }); + + it('keeps a wholly allowed export read-write with no plan', () => { + const result = planCloudHypervisorFilesystemWriteEnforcement( + exports, + ['/workspace', '/tmp/gh-aw'], + ); + + expect(result.exports).toEqual([ + { ...exports[0], mode: 'rw' }, + { ...exports[1], mode: 'ro' }, + { ...exports[2], mode: 'rw' }, + ]); + expect(result.mountEnforcement).toEqual({ + plans: [{ tag: 'runner-tool-cache', writableOverlays: [] }], + }); + expect(hasReadOnlyWorkspaceMountPlan(result.mountEnforcement)).toBe(false); + }); + + it('maps a selective file overlay with its canonical host path and kind', () => { + const result = planCloudHypervisorFilesystemWriteEnforcement( + exports, + ['/workspace/nested/file.txt', '/tmp/gh-aw'], + ); + + expect(result.exports[0]).toEqual({ ...exports[0], mode: 'rw' }); + expect(result.mountEnforcement?.plans).toEqual([ + { + tag: 'workspace', + writableOverlays: [{ + source: path.join(workspaceSource, 'nested', 'file.txt'), + destination: path.join(workspaceSource, 'nested', 'file.txt'), + kind: 'file', + }], + }, + { tag: 'runner-tool-cache', writableOverlays: [] }, + ]); + // A selective workspace has a plan, so it is legitimately published `rw` + // while its host root is staged read-only. + expect(hasReadOnlyWorkspaceMountPlan(result.mountEnforcement)).toBe(true); + }); + + it('emits plan tags that exist in the published exports, in export order', () => { + const result = planCloudHypervisorFilesystemWriteEnforcement(exports, ['/tmp/gh-aw/cache']); + const publishedTags = result.exports.map((entry) => entry.tag); + + expect(result.mountEnforcement?.plans.map((plan) => plan.tag)) + .toEqual(['workspace', 'runner-tool-cache', 'tmp-gh-aw']); + result.mountEnforcement?.plans.forEach((plan) => { + expect(publishedTags).toContain(plan.tag); + }); + expect(new Set(result.mountEnforcement?.plans.map((plan) => plan.tag)).size) + .toBe(result.mountEnforcement?.plans.length); + }); + + it('fails closed on a missing or escaping allowlist path instead of widening', () => { + expect(() => planCloudHypervisorFilesystemWriteEnforcement(exports, ['/workspace/absent'])) + .toThrow('filesystem.allowWrite path is not an existing path within a writable'); + expect(() => planCloudHypervisorFilesystemWriteEnforcement(exports, ['/tools/tool.txt'])) + .toThrow('filesystem.allowWrite path is not an existing path within a writable'); + expect(() => planCloudHypervisorFilesystemWriteEnforcement(exports, ['relative/path'])) + .toThrow("filesystem.allowWrite path must be absolute without '..'"); + }); +}); diff --git a/src/cloud-hypervisor/filesystem-write-enforcement.ts b/src/cloud-hypervisor/filesystem-write-enforcement.ts new file mode 100644 index 000000000..809d79003 --- /dev/null +++ b/src/cloud-hypervisor/filesystem-write-enforcement.ts @@ -0,0 +1,105 @@ +import { + CLOUD_HYPERVISOR_WORKSPACE_EXPORT_TAG, + type CloudHypervisorDirectoryExport, +} from './exports'; +import { + planCloudHypervisorFilesystemWrites, + type CloudHypervisorFilesystemWritePlan, +} from './filesystem-write-policy'; +import type { + VirtiofsdExportMountPlan, + VirtiofsdMountEnforcement, +} from './mount-tree'; + +/** + * Translation layer between the pure `filesystem.allowWrite` planner + * (`./filesystem-write-policy.ts`) and the host mount-tree enforcement API + * consumed by `VirtiofsdManager.start()` (`./mount-tree.ts`). + * + * The planner decides *what* stays writable; the mount tree decides *how* that + * is enforced on the host. This module only re-expresses one in the other's + * vocabulary — it repeats no validation and applies no policy of its own. + */ +export interface CloudHypervisorFilesystemWriteEnforcement { + /** + * Exports as published to virtiofsd, the guest boot arguments, and the guest + * environment. Identical to the resolved exports when no policy is in force; + * otherwise each mode is replaced by the planner's `guestMountMode`. + */ + readonly exports: readonly CloudHypervisorDirectoryExport[]; + /** + * Host mount-tree enforcement, or `undefined` when `filesystem.allowWrite` + * was absent. `undefined` is what preserves byte-identical legacy staging, + * including virtiofsd's argument vector. + */ + readonly mountEnforcement?: VirtiofsdMountEnforcement; +} + +/** + * Plans `filesystem.allowWrite` for the resolved Cloud Hypervisor exports and + * translates the result into published exports plus host mount-tree + * enforcement. + * + * No `internalTags` are declared. Cloud Hypervisor has no counterpart to the + * always-writable Docker agent-log and session-state binds: agent output leaves + * the guest over vsock, and diagnostics are written by the host into the per-run + * directory, which is not an export at all. In particular `tmp-gh-aw` is *not* + * internal — the motivating policy allows only `/tmp/gh-aw/agent`, and exempting + * the whole export would silently widen it back to fully writable. + */ +export function planCloudHypervisorFilesystemWriteEnforcement( + exports: readonly CloudHypervisorDirectoryExport[], + allowWrite: string[] | undefined, +): CloudHypervisorFilesystemWriteEnforcement { + return toCloudHypervisorFilesystemWriteEnforcement( + planCloudHypervisorFilesystemWrites(exports, allowWrite), + ); +} + +/** @internal Split out so translation is testable against a synthetic plan. */ +// ts-prune-ignore-next +export function toCloudHypervisorFilesystemWriteEnforcement( + plan: CloudHypervisorFilesystemWritePlan, +): CloudHypervisorFilesystemWriteEnforcement { + if (!plan.restricted) { + // No policy: the exports and the virtiofsd invocation must be exactly what + // they were before this feature existed, so no enforcement is produced. + return { exports: plan.exports.map((entry) => entry.export) }; + } + + const plans: VirtiofsdExportMountPlan[] = []; + const publishedExports = plan.exports.map((entry) => { + // `hostRootMode: 'ro'` covers both a fully read-only export (zero overlays) + // and a selective one. Both are staged through the recursively verified + // mount tree rather than the legacy single read-only bind, which cannot + // prove that carried-in submounts are read-only. + if (entry.hostRootMode === 'ro') { + plans.push({ + tag: entry.export.tag, + writableOverlays: entry.overlays.map((overlay) => ({ + // The planner already resolved a realpath-canonical host path that is + // contained in the export source; it is both the bind source and the + // destination whose staged counterpart becomes writable. + source: overlay.hostPath, + destination: overlay.hostPath, + kind: overlay.kind, + })), + }); + } + return { ...entry.export, mode: entry.guestMountMode }; + }); + + return { exports: publishedExports, mountEnforcement: { plans } }; +} + +/** + * True when the workspace export is staged read-only on the host, which is the + * only circumstance under which it may be published to the guest read-only. + */ +export function hasReadOnlyWorkspaceMountPlan( + enforcement: VirtiofsdMountEnforcement | undefined, +): boolean { + return enforcement?.plans.some( + (plan) => plan.tag === CLOUD_HYPERVISOR_WORKSPACE_EXPORT_TAG, + ) === true; +} diff --git a/src/cloud-hypervisor/manager-types.ts b/src/cloud-hypervisor/manager-types.ts index addf4edb5..5ebefe032 100644 --- a/src/cloud-hypervisor/manager-types.ts +++ b/src/cloud-hypervisor/manager-types.ts @@ -14,7 +14,7 @@ import type { CloudHypervisorApiClient } from './api-client'; import type { CloudHypervisorDirectoryExport } from './exports'; import type { CloudHypervisorCgroup, CloudHypervisorResourceLimits } from './launcher'; import type { CloudHypervisorHostToolPaths, runCloudHypervisorPreflight } from './preflight'; -import type { VirtiofsdManager } from './virtiofsd'; +import type { VirtiofsdManager, VirtiofsdMountEnforcement } from './virtiofsd'; const API_SOCKET_NAME = 'api.socket'; const VSOCK_SOCKET_NAME = 'awf-vsock.socket'; @@ -108,6 +108,12 @@ export interface CloudHypervisorManagerNetworkConfig { export interface CloudHypervisorManagerGuestConfig { readonly exports: readonly CloudHypervisorDirectoryExport[]; + /** + * Host mount-tree enforcement derived from `filesystem.allowWrite`. Absent + * when no policy was supplied, which keeps virtiofsd staging byte-identical + * to its pre-policy behaviour. + */ + readonly mountEnforcement?: VirtiofsdMountEnforcement; readonly supervisorBinaryPath: string; readonly supervisorSha256: string; readonly vsockPort?: number; diff --git a/src/cloud-hypervisor/manager.test.ts b/src/cloud-hypervisor/manager.test.ts index e0b3ee396..7ce0e8cea 100644 --- a/src/cloud-hypervisor/manager.test.ts +++ b/src/cloud-hypervisor/manager.test.ts @@ -498,6 +498,64 @@ describe('CloudHypervisorManager', () => { expect(order).toEqual(['virtiofsd']); }); + it('starts virtiofsd without an enforcement argument when no write policy applies', async () => { + const virtiofsd = virtiofsdManagerMock(); + const deps = dependencies({ + launch: jest.fn().mockReturnValue(processMock()), + createVirtiofsdManager: jest.fn().mockReturnValue(virtiofsd), + }); + const manager = new CloudHypervisorManager( + config(), '/tmp/awf', deps, 'plain', networkConfig(), guestConfig(), + ); + + await manager.start(); + + expect(virtiofsd.start).toHaveBeenCalledWith(exportsConfig, undefined); + }); + + it('forwards host mount enforcement and publishes a read-only workspace', async () => { + const virtiofsd = virtiofsdManagerMock(); + const deps = dependencies({ + launch: jest.fn().mockReturnValue(processMock()), + createVirtiofsdManager: jest.fn().mockReturnValue(virtiofsd), + }); + const mountEnforcement = { plans: [{ tag: 'workspace', writableOverlays: [] }] }; + const narrowedExports = [{ ...exportsConfig[0], mode: 'ro' as const }]; + const manager = new CloudHypervisorManager( + config(), + '/tmp/awf', + deps, + 'narrowed', + networkConfig(), + { ...guestConfig(), exports: narrowedExports, mountEnforcement }, + ); + + const client = await manager.start(); + + expect(virtiofsd.start).toHaveBeenCalledWith(narrowedExports, mountEnforcement); + expect((client.vmCreate as jest.Mock).mock.calls[0][0].payload.cmdline) + .toContain(`awf.virtiofs=workspace:${Buffer.from('/workspace').toString('base64url')}:ro`); + }); + + it('refuses a read-only workspace that no host mount plan enforces', async () => { + const deps = dependencies({ launch: jest.fn().mockReturnValue(processMock()) }); + const manager = new CloudHypervisorManager( + config(), + '/tmp/awf', + deps, + 'unenforced', + networkConfig(), + { + ...guestConfig(), + exports: [{ ...exportsConfig[0], mode: 'ro' as const }], + mountEnforcement: { plans: [{ tag: 'tmp-gh-aw', writableOverlays: [] }] }, + }, + ); + + await expect(manager.start()) + .rejects.toThrow('Cloud Hypervisor requires read-write tag "workspace" at /workspace'); + }); + it('preserves the cgroup and run directory when virtiofsd cannot be reaped', async () => { const virtiofsd = virtiofsdManagerMock(); (virtiofsd.stop as jest.Mock).mockRejectedValue(new Error('virtiofsd did not exit')); diff --git a/src/cloud-hypervisor/manager.ts b/src/cloud-hypervisor/manager.ts index 9f704c582..b5d7d5420 100644 --- a/src/cloud-hypervisor/manager.ts +++ b/src/cloud-hypervisor/manager.ts @@ -55,6 +55,7 @@ import type { CloudHypervisorHostToolPaths } from './preflight'; import { validateCloudHypervisorExports, } from './exports'; +import { hasReadOnlyWorkspaceMountPlan } from './filesystem-write-enforcement'; import { VirtiofsdManager, type VirtiofsdDevice } from './virtiofsd'; import { buildCloudHypervisorVmConfig, @@ -249,7 +250,11 @@ export class CloudHypervisorManager { await this.network.setup(); let rootfsSource = artifacts.rootfsPath; if (this.guestConfig) { - validateCloudHypervisorExports(this.guestConfig.exports); + validateCloudHypervisorExports(this.guestConfig.exports, { + allowReadOnlyWorkspace: hasReadOnlyWorkspaceMountPlan( + this.guestConfig.mountEnforcement, + ), + }); const rootfsPreparationDirectory = path.join( this.workDir, 'cloud-hypervisor-rootfs', @@ -344,7 +349,10 @@ export class CloudHypervisorManager { this.cgroup, { mount: artifacts.tools.mount, umount: artifacts.tools.umount }, ); - this.fsDevices = await this.virtiofsd.start(this.guestConfig.exports); + this.fsDevices = await this.virtiofsd.start( + this.guestConfig.exports, + this.guestConfig.mountEnforcement, + ); } await this.client.vmCreate(buildCloudHypervisorVmConfig({ config: this.config, diff --git a/src/cloud-hypervisor/vm-config-builder.test.ts b/src/cloud-hypervisor/vm-config-builder.test.ts index d16d019ce..611226f64 100644 --- a/src/cloud-hypervisor/vm-config-builder.test.ts +++ b/src/cloud-hypervisor/vm-config-builder.test.ts @@ -99,8 +99,42 @@ describe('buildCloudHypervisorVmConfig', () => { ); }); - it('sizes cpus/memory from the runtime options and disables NIC offloads', () => { - const vmConfig = buildCloudHypervisorVmConfig({ + it('encodes a policy-narrowed workspace mode only alongside its host mount plan', () => { + const exports = [{ + tag: 'workspace', + source: '/workspace', + target: '/workspace', + mode: 'ro' as const, + }]; + + expect(buildCloudHypervisorVmConfig({ + config: config(), + paths, + networkPlan: networkPlan(), + guestConfig: { + exports, + mountEnforcement: { plans: [{ tag: 'workspace', writableOverlays: [] }] }, + supervisorBinaryPath: '/opt/awf-supervisor', + supervisorSha256: 'a'.repeat(64), + }, + }).payload).toHaveProperty( + 'cmdline', + expect.stringContaining('awf.virtiofs=workspace:L3dvcmtzcGFjZQ:ro'), + ); + + expect(() => buildCloudHypervisorVmConfig({ + config: config(), + paths, + networkPlan: networkPlan(), + guestConfig: { + exports, + supervisorBinaryPath: '/opt/awf-supervisor', + supervisorSha256: 'a'.repeat(64), + }, + })).toThrow('Cloud Hypervisor requires read-write tag "workspace" at /workspace'); + }); + + it('sizes cpus/memory from the runtime options and disables NIC offloads', () => { const vmConfig = buildCloudHypervisorVmConfig({ config: config({ vcpuCount: 4, memoryMib: 1024 }), paths, networkPlan: networkPlan(), diff --git a/src/cloud-hypervisor/vm-config-builder.ts b/src/cloud-hypervisor/vm-config-builder.ts index 72cc2f1f5..c0b3bcc68 100644 --- a/src/cloud-hypervisor/vm-config-builder.ts +++ b/src/cloud-hypervisor/vm-config-builder.ts @@ -3,7 +3,9 @@ import type { CloudHypervisorOptions } from '../types/runtime-options'; import { validateCloudHypervisorExports, type CloudHypervisorDirectoryExport, + type CloudHypervisorExportValidationOptions, } from './exports'; +import { hasReadOnlyWorkspaceMountPlan } from './filesystem-write-enforcement'; import { CLOUD_HYPERVISOR_GUEST_CID, computeCloudHypervisorLandlockRules, @@ -128,7 +130,9 @@ export function buildSupervisorBootArgs( 'biosdevname=0', `init=${CLOUD_HYPERVISOR_GUEST_SUPERVISOR}`, 'awf.workspace-mount=/workspace', - `awf.virtiofs=${encodeVirtiofsBootArg(guestConfig.exports)}`, + `awf.virtiofs=${encodeVirtiofsBootArg(guestConfig.exports, { + allowReadOnlyWorkspace: hasReadOnlyWorkspaceMountPlan(guestConfig.mountEnforcement), + })}`, `awf.vsock-port=${port}`, `awf.guest-ip=${networkPlan.guestIp}`, `awf.guest-prefix=${networkPlan.guestPrefixLength}`, @@ -139,8 +143,9 @@ export function buildSupervisorBootArgs( export function encodeVirtiofsBootArg( exports: readonly CloudHypervisorDirectoryExport[], + options: CloudHypervisorExportValidationOptions = {}, ): string { - const encoded = validateCloudHypervisorExports(exports) + const encoded = validateCloudHypervisorExports(exports, options) .map((entry) => ( `${entry.tag}:${Buffer.from(entry.target).toString('base64url')}:${entry.mode}` )) diff --git a/src/filesystem-policy.test.ts b/src/filesystem-policy.test.ts index 111286865..c21925d7a 100644 --- a/src/filesystem-policy.test.ts +++ b/src/filesystem-policy.test.ts @@ -10,9 +10,19 @@ function config(overrides: Partial = {}): WrapperConfig { } describe('assertFilesystemWritePolicyCompatibility', () => { - it.each(['sbx', 'cloud-hypervisor'])('rejects the unsupported %s runtime', (containerRuntime) => { - expect(() => assertFilesystemWritePolicyCompatibility(config({ containerRuntime }))) - .toThrow(`filesystem.allowWrite is not yet supported by the ${containerRuntime} runtime`); + it('rejects the unsupported sbx runtime', () => { + expect(() => assertFilesystemWritePolicyCompatibility(config({ containerRuntime: 'sbx' }))) + .toThrow('filesystem.allowWrite is not yet supported by the sbx runtime'); + }); + + it('accepts the Cloud Hypervisor runtime, which enforces the policy host-side', () => { + expect(() => assertFilesystemWritePolicyCompatibility(config({ + containerRuntime: 'cloud-hypervisor', + }))).not.toThrow(); + expect(() => assertFilesystemWritePolicyCompatibility(config({ + containerRuntime: 'cloud-hypervisor', + filesystemAllowWrite: [], + }))).not.toThrow(); }); it('accepts compose runtimes and an unset policy', () => { @@ -26,5 +36,9 @@ describe('assertFilesystemWritePolicyCompatibility', () => { it('rejects effective Docker-in-Docker access', () => { expect(() => assertFilesystemWritePolicyCompatibility(config({ enableDind: true }))) .toThrow('filesystem.allowWrite cannot be combined with Docker-in-Docker access'); + expect(() => assertFilesystemWritePolicyCompatibility(config({ + containerRuntime: 'cloud-hypervisor', + enableDind: true, + }))).toThrow('filesystem.allowWrite cannot be combined with Docker-in-Docker access'); }); }); diff --git a/src/filesystem-policy.ts b/src/filesystem-policy.ts index 4895aa378..3e63e65a5 100644 --- a/src/filesystem-policy.ts +++ b/src/filesystem-policy.ts @@ -5,10 +5,10 @@ export function assertFilesystemWritePolicyCompatibility(config: WrapperConfig): throw new Error('filesystem.allowWrite cannot be combined with Docker-in-Docker access'); } - if ( - config.filesystemAllowWrite !== undefined && - (config.containerRuntime === 'sbx' || config.containerRuntime === 'cloud-hypervisor') - ) { + // Cloud Hypervisor enforces the policy through host mount-tree staging (see + // src/cloud-hypervisor/filesystem-write-enforcement.ts). sbx has no + // equivalent enforcement path yet, so it still fails closed. + if (config.filesystemAllowWrite !== undefined && config.containerRuntime === 'sbx') { throw new Error( `filesystem.allowWrite is not yet supported by the ${config.containerRuntime} runtime`, ); From b09d08af45f180fabca3c6f32028e49e07774229 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 23 Aug 2026 11:05:14 -0700 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/awf-config-spec.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 5fc33d3d7..552fba34f 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -103,9 +103,10 @@ writable so the sandbox can operate. `filesystem.allowWrite` is supported by the Docker and gVisor compose runtimes and by the Cloud Hypervisor microVM runtime, where it is enforced by the host mount tree that backs each virtio-fs export (see [docs/cloud-hypervisor-foundation.md](./cloud-hypervisor-foundation.md#runtime-integration)). -Cloud Hypervisor has no always-writable internal mounts, so a policy narrows -every export it publishes, including `/tmp/gh-aw` and the guest home directory -at `/workspace/.awf-home`. AWF rejects `filesystem.allowWrite` with the sbx +Cloud Hypervisor has no always-writable internal mounts, so every export it +publishes is subject to the policy, including `/tmp/gh-aw` and the guest home +directory at `/workspace/.awf-home`; paths not covered by `allowWrite` become +read-only. AWF rejects `filesystem.allowWrite` with the sbx runtime and with Docker-in-Docker agent execution. ### 4.2 Cloud Hypervisor microVM preview From 0fbc6b3183ffa93c5da292efdb54b31f0e6150eb Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 23 Aug 2026 11:18:10 -0700 Subject: [PATCH 3/3] fix: gate compose write policy by runtime and repair allowWrite smoke probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review blockers on the Cloud Hypervisor allowWrite integration. 1. CRITICAL — `filesystem.allowWrite` leaked into compose generation. `writeConfigs()` -> `generateDockerCompose()` -> `buildAgentVolumes()` called Docker's `applyFilesystemWritePolicy()` unconditionally. Compose still builds an agent service object for microVM runtimes (so infra containers can wire depends_on edges) even though it is omitted from the emitted file, so a Cloud Hypervisor policy was evaluated against compose bind mounts the agent never uses. Guest paths such as `/workspace/allowed` -- and the motivating `/tmp/gh-aw/agent` -- are not backed by any host bind mount, so they threw "not an existing path within a writable host mount" during writeConfigs(), long before the Cloud Hypervisor planner ran. Adds `resolveComposeFilesystemAllowWrite()`, which returns the policy only when `runtimeUsesComposeAgent()` is true, and routes both consumers through it: the `applyFilesystemWritePolicy()` call in volume-builder and the `dropUnbackedHostHomeOverlays()` toggle in optional-services. Docker and gVisor behaviour is unchanged; sbx still fails closed in filesystem-policy. Audited every other `filesystemAllowWrite` reader: the remaining ones are the type declaration, config mapping, the runtime compatibility gate, and the Cloud Hypervisor planner itself, all correct. 2. HIGH — the live smoke truncate probe killed the guest shell. The guest runs BusyBox ash, where a redirection failure on a POSIX *special* builtin is fatal. `! : > /workspace/input.txt` therefore exited the shell before the rename and delete probes ran, while the host post-checks still passed vacuously -- a write never attempted also never changes anything. Reproduced in busybox: the old chain exits 1 and never prints the rename/delete markers; `printf ''` continues correctly. Every write-denial probe is now a regular builtin or external command, wrapped in a subshell so a fatal error stays contained, and followed by a sentinel. A new `assert_sentinels` helper requires all of them after the case, so a shell that dies partway can no longer look like a pass. Regression coverage: top-level `generateDockerCompose` tests proving a Cloud Hypervisor config with `/workspace/allowed` neither throws nor rewrites any emitted compose volume, while Docker/gVisor still narrow mounts and still fail closed on an unbacked path; plus script-contract tests banning special-builtin write probes and requiring each sentinel to be both emitted and asserted. Full suite 319 suites / 5068 tests passing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ci/cloud-hypervisor-ci-scripts.test.ts | 48 +++++++++++++++ scripts/ci/cloud-hypervisor-live-smoke.sh | 44 ++++++++++++- src/compose-generator.test.ts | 61 +++++++++++++++++++ .../agent-volumes/filesystem-write-policy.ts | 30 +++++++++ src/services/agent-volumes/volume-builder.ts | 4 +- src/services/optional-services.ts | 5 +- 6 files changed, 187 insertions(+), 5 deletions(-) diff --git a/scripts/ci/cloud-hypervisor-ci-scripts.test.ts b/scripts/ci/cloud-hypervisor-ci-scripts.test.ts index 7ea45095d..6f2d1f5c7 100644 --- a/scripts/ci/cloud-hypervisor-ci-scripts.test.ts +++ b/scripts/ci/cloud-hypervisor-ci-scripts.test.ts @@ -183,6 +183,54 @@ describe('cloud-hypervisor-live-smoke.sh', () => { expect(source).toContain("grep -q 'filesystem.allowWrite'"); }); + it('proves every allowWrite denial probe actually executed under BusyBox ash', () => { + const source = fs.readFileSync(smokePath, 'utf-8'); + // Regression: the guest shell is BusyBox ash, where a redirection failure + // on a POSIX *special* builtin is fatal and exits the shell. An earlier + // `! : > /workspace/input.txt` truncate probe therefore terminated the + // guest command before the rename and delete probes ran, while the host + // post-checks still passed vacuously -- a write never attempted also never + // Comment lines are excluded: the rationale above quotes the old probe. + const executableLines = source + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')); + expect(executableLines.some((line) => /!\s*:\s*>/.test(line))).toBe(false); + expect(source).toContain('! ( printf "" > /workspace/input.txt )'); + + // Every denial probe is subshell-contained (so even a fatal shell error is + // isolated) and followed by a sentinel that the suite then requires. + for (const probe of [ + '! ( printf blocked > /workspace/blocked/file.txt )', + '! ( printf blocked > /workspace/created-at-root.txt )', + '! ( mkdir /workspace/blocked-dir )', + '! ( mv /workspace/rename-me.txt /workspace/renamed.txt )', + '! ( rm /workspace/blocked/file.txt )', + ]) { + expect(source).toContain(probe); + } + + expect(source).toContain('assert_sentinels() {'); + expect(source).toContain('missing sentinel $sentinel (probe never executed)'); + for (const sentinel of [ + 'AWF-ALLOWWRITE-ALLOWED-OK', + 'AWF-ALLOWWRITE-SIBLING-DENIED', + 'AWF-ALLOWWRITE-CREATE-DENIED', + 'AWF-ALLOWWRITE-MKDIR-DENIED', + 'AWF-ALLOWWRITE-TRUNCATE-DENIED', + 'AWF-ALLOWWRITE-RENAME-DENIED', + 'AWF-ALLOWWRITE-DELETE-DENIED', + 'AWF-ALLOWWRITE-NONE-READ-OK', + 'AWF-ALLOWWRITE-NONE-WRITE-DENIED', + 'AWF-ALLOWWRITE-NONE-CREATE-DENIED', + ]) { + // Emitted by the guest command, and separately required afterwards. + expect(source).toContain(`echo ${sentinel}`); + expect(source.split(sentinel).length - 1).toBeGreaterThanOrEqual(2); + } + expect(source).toContain('assert_sentinels allow-write \\'); + expect(source).toContain('assert_sentinels allow-write-none \\'); + }); + (shellcheckAvailable() ? it : it.skip)('has no shellcheck errors', () => { expect(() => execFileSync('shellcheck', ['--severity=error', smokePath]), diff --git a/scripts/ci/cloud-hypervisor-live-smoke.sh b/scripts/ci/cloud-hypervisor-live-smoke.sh index 0ac79f50e..7120c08c7 100755 --- a/scripts/ci/cloud-hypervisor-live-smoke.sh +++ b/scripts/ci/cloud-hypervisor-live-smoke.sh @@ -181,6 +181,26 @@ run_case() { assert_no_residue } +# Requires that a completed case printed every named marker to its guest +# stdout. `run_case` only checks the final exit status, which cannot +# distinguish "every probe ran and behaved" from "the guest shell died partway +# through and the remaining probes never executed" -- the latter leaves host +# post-checks passing vacuously, because a write that was never attempted +# also never changed anything. +assert_sentinels() { + local name=$1 + shift + local log="$RUN_ROOT/$name/stdout.log" + local sentinel + for sentinel in "$@"; do + if ! grep -qF "$sentinel" "$log"; then + echo "case $name: missing sentinel $sentinel (probe never executed)" >&2 + tail -50 "$log" >&2 + return 1 + fi + done +} + assert_no_residue # Best-effort, bounded packet capture across the first live case only (never @@ -298,9 +318,25 @@ cat >"$RUN_ROOT/allow-write.json" <<'JSON' } } JSON +# Each denial is wrapped in a subshell and followed by a sentinel echo. The +# guest shell is BusyBox ash: a redirection failure on a POSIX *special* +# builtin (`:`, `.`, `eval`, ...) is fatal and exits the shell outright, so an +# earlier `! : > /workspace/input.txt` truncate probe silently skipped the +# rename and delete probes below it while the host post-checks still passed -- +# nothing had been attempted, so nothing had changed. `printf ''` is a regular +# builtin (non-fatal), the subshell contains a fatal error even if one occurs, +# and requiring every sentinel below proves each probe actually ran. run_case allow-write 0 \ - 'printf guest-allowed > /workspace/allowed/created.txt && mkdir -p /workspace/allowed/nested/deeper && printf deep > /workspace/allowed/nested/deeper/file.txt && rm /workspace/allowed/removable.txt && printf guest-file > /workspace/allowed-file.txt && grep -q " /workspace/allowed virtiofs " /proc/mounts && grep -q host-input /workspace/input.txt && ! printf blocked > /workspace/blocked/file.txt && ! printf blocked > /workspace/created-at-root.txt && ! mkdir /workspace/blocked-dir && ! : > /workspace/input.txt && ! mv /workspace/rename-me.txt /workspace/renamed.txt && ! rm -f /workspace/blocked/file.txt' \ + 'printf guest-allowed > /workspace/allowed/created.txt && mkdir -p /workspace/allowed/nested/deeper && printf deep > /workspace/allowed/nested/deeper/file.txt && rm /workspace/allowed/removable.txt && printf guest-file > /workspace/allowed-file.txt && grep -q " /workspace/allowed virtiofs " /proc/mounts && grep -q host-input /workspace/input.txt && echo AWF-ALLOWWRITE-ALLOWED-OK && ! ( printf blocked > /workspace/blocked/file.txt ) && echo AWF-ALLOWWRITE-SIBLING-DENIED && ! ( printf blocked > /workspace/created-at-root.txt ) && echo AWF-ALLOWWRITE-CREATE-DENIED && ! ( mkdir /workspace/blocked-dir ) && echo AWF-ALLOWWRITE-MKDIR-DENIED && ! ( printf "" > /workspace/input.txt ) && echo AWF-ALLOWWRITE-TRUNCATE-DENIED && ! ( mv /workspace/rename-me.txt /workspace/renamed.txt ) && echo AWF-ALLOWWRITE-RENAME-DENIED && ! ( rm /workspace/blocked/file.txt ) && echo AWF-ALLOWWRITE-DELETE-DENIED' \ --config "$RUN_ROOT/allow-write.json" +assert_sentinels allow-write \ + AWF-ALLOWWRITE-ALLOWED-OK \ + AWF-ALLOWWRITE-SIBLING-DENIED \ + AWF-ALLOWWRITE-CREATE-DENIED \ + AWF-ALLOWWRITE-MKDIR-DENIED \ + AWF-ALLOWWRITE-TRUNCATE-DENIED \ + AWF-ALLOWWRITE-RENAME-DENIED \ + AWF-ALLOWWRITE-DELETE-DENIED test "$(cat "$allow_workspace/allowed/created.txt")" = guest-allowed test "$(cat "$allow_workspace/allowed/nested/deeper/file.txt")" = deep test "$(cat "$allow_workspace/allowed-file.txt")" = guest-file @@ -319,8 +355,12 @@ cat >"$RUN_ROOT/allow-write-none.json" <<'JSON' { "filesystem": { "allowWrite": [] } } JSON run_case allow-write-none 0 \ - 'grep -q host-input /workspace/input.txt && grep -q " /workspace virtiofs ro," /proc/mounts && ! printf blocked > /workspace/input.txt && ! printf blocked > /workspace/created.txt' \ + 'grep -q host-input /workspace/input.txt && grep -q " /workspace virtiofs ro," /proc/mounts && echo AWF-ALLOWWRITE-NONE-READ-OK && ! ( printf blocked > /workspace/input.txt ) && echo AWF-ALLOWWRITE-NONE-WRITE-DENIED && ! ( printf blocked > /workspace/created.txt ) && echo AWF-ALLOWWRITE-NONE-CREATE-DENIED' \ --config "$RUN_ROOT/allow-write-none.json" +assert_sentinels allow-write-none \ + AWF-ALLOWWRITE-NONE-READ-OK \ + AWF-ALLOWWRITE-NONE-WRITE-DENIED \ + AWF-ALLOWWRITE-NONE-CREATE-DENIED test "$(cat "$RUN_ROOT/allow-write-none/workspace/input.txt")" = host-input test ! -e "$RUN_ROOT/allow-write-none/workspace/created.txt" diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index 3494f543e..1d62ead40 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -768,4 +768,65 @@ describe('generateDockerCompose', () => { expect(volumes.some(v => v.split(':')[0].startsWith('/tmp/awf-12345'))).toBe(false); }); }); + + // Regression: `filesystem.allowWrite` is expressed in guest-visible paths, + // and each runtime realises those paths differently. Compose generation + // still builds an agent service object for microVM runtimes (so infra + // containers can wire depends_on edges) even though it is omitted from the + // emitted file, so an ungated policy was evaluated against compose bind + // mounts the agent never uses. A Cloud Hypervisor guest path such as + // `/workspace/allowed` is not backed by any host bind mount, so it threw + // during writeConfigs() -- long before the Cloud Hypervisor planner ran. + describe('filesystem.allowWrite runtime gating', () => { + it('does not apply the compose write policy to Cloud Hypervisor guest paths', () => { + const microVmConfig = { + ...mockConfig, + containerRuntime: 'cloud-hypervisor', + filesystemAllowWrite: ['/workspace/allowed', '/tmp/gh-aw/agent'], + }; + + expect(() => generateDockerCompose(microVmConfig, mockNetworkConfig)).not.toThrow(); + + // The agent itself is launched by the microVM backend, not compose. + const policed = generateDockerCompose(microVmConfig, mockNetworkConfig); + expect(policed.services.agent).toBeUndefined(); + + // Every volume compose *does* emit must be byte-identical to the same + // run with no policy at all: the policy belongs to the Cloud Hypervisor + // mount tree, so it must not rewrite a single compose mode here. + const allVolumes = (compose: ReturnType): string[] => + Object.entries(compose.services) + .flatMap(([name, service]: [string, any]) => + ((service.volumes ?? []) as string[]).map((volume) => `${name} ${volume}`)) + .sort(); + const unpoliced = generateDockerCompose( + { ...mockConfig, containerRuntime: 'cloud-hypervisor' }, + mockNetworkConfig, + ); + expect(allVolumes(policed)).toEqual(allVolumes(unpoliced)); + }); + + it('still enforces the compose write policy for Docker and gVisor', () => { + for (const containerRuntime of [undefined, 'docker', 'gvisor']) { + const composeConfig = { + ...mockConfig, + ...(containerRuntime ? { containerRuntime } : {}), + filesystemAllowWrite: [], + }; + const volumes = generateDockerCompose(composeConfig, mockNetworkConfig) + .services.agent.volumes as string[]; + + // An empty allowlist narrows every non-internal writable bind mount. + expect(volumes).toContain('/tmp:/tmp:ro'); + expect(volumes).not.toContain('/tmp:/tmp:rw'); + + // And a guest path with no backing writable host mount still fails + // closed for compose runtimes rather than being silently ignored. + expect(() => generateDockerCompose( + { ...composeConfig, filesystemAllowWrite: ['/workspace/allowed'] }, + mockNetworkConfig, + )).toThrow(/filesystem\.allowWrite/); + } + }); + }); }); diff --git a/src/services/agent-volumes/filesystem-write-policy.ts b/src/services/agent-volumes/filesystem-write-policy.ts index ec19cfc4d..a4ac6ddbd 100644 --- a/src/services/agent-volumes/filesystem-write-policy.ts +++ b/src/services/agent-volumes/filesystem-write-policy.ts @@ -1,5 +1,35 @@ import * as fs from 'fs'; import * as path from 'path'; +import { runtimeUsesComposeAgent } from '../../container-runtime'; +import type { WrapperConfig } from '../../types'; + +/** + * Resolves the `filesystem.allowWrite` policy that applies to *compose* bind + * mounts. + * + * `filesystem.allowWrite` is expressed in guest-visible paths, and each runtime + * realises those paths differently. Compose runtimes (Docker, gVisor) express + * them as host bind mounts, so the policy is enforced by rewriting volume + * specs. MicroVM runtimes do not: Cloud Hypervisor enforces the same policy + * against its own virtio-fs exports via a staged host mount tree (see + * `src/cloud-hypervisor/filesystem-write-enforcement.ts`), and sbx rejects the + * policy outright in `src/filesystem-policy.ts`. + * + * Compose generation still builds an agent service object for microVM runtimes + * so infra containers can wire `depends_on` edges, even though that service is + * omitted from the emitted compose file. Without this gate, a microVM policy + * would be evaluated against compose bind mounts that the agent never uses, and + * a guest path such as `/workspace/allowed` — perfectly valid for a Cloud + * Hypervisor export — would throw the Docker "not backed by a writable host + * mount" error during `writeConfigs()`, long before the Cloud Hypervisor + * planner ever ran. + */ +export function resolveComposeFilesystemAllowWrite( + config: Pick, +): string[] | undefined { + if (!runtimeUsesComposeAgent(config.containerRuntime)) return undefined; + return config.filesystemAllowWrite; +} interface BindMount { source: string; diff --git a/src/services/agent-volumes/volume-builder.ts b/src/services/agent-volumes/volume-builder.ts index 964614ed9..7be25e840 100644 --- a/src/services/agent-volumes/volume-builder.ts +++ b/src/services/agent-volumes/volume-builder.ts @@ -10,7 +10,7 @@ import { generateHostsFileMount } from './hosts-file'; import { buildSslMounts } from './ssl-mounts'; import { buildSystemMounts } from './system-mounts'; import { buildCustomVolumeMounts, buildWorkspaceMounts } from './workspace-mounts'; -import { applyFilesystemWritePolicy } from './filesystem-write-policy'; +import { applyFilesystemWritePolicy, resolveComposeFilesystemAllowWrite } from './filesystem-write-policy'; interface AgentVolumesParams { config: WrapperConfig; @@ -81,7 +81,7 @@ export function buildAgentVolumes(params: AgentVolumesParams): string[] { ); const policyVolumes = applyFilesystemWritePolicy( agentVolumes, - config.filesystemAllowWrite, + resolveComposeFilesystemAllowWrite(config), alwaysWritableMounts, localSourceRoots, ); diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts index 451f50fba..badf4fa79 100644 --- a/src/services/optional-services.ts +++ b/src/services/optional-services.ts @@ -11,6 +11,7 @@ import { resolveDockerHostGateway } from './host-gateway'; import { runtimeUsesIptables } from '../container-runtime'; import { applyHostPathPrefixToVolumes } from './host-path-prefix'; import { buildCustomVolumeMounts } from './agent-volumes/workspace-mounts'; +import { resolveComposeFilesystemAllowWrite } from './agent-volumes/filesystem-write-policy'; import { NetworkConfig, ImageBuildConfig } from './squid-service'; interface AssembleOptionalServicesParams { @@ -122,7 +123,9 @@ function filterAgentVolumesForSysroot( return dropUnbackedHostHomeOverlays( filtered, hostHomeMountPrefix, - config.filesystemAllowWrite !== undefined, + // Mirrors the gate in buildAgentVolumes: a microVM runtime's policy is + // enforced against its own exports, never against compose bind mounts. + resolveComposeFilesystemAllowWrite(config) !== undefined, ); }