From 3d51d184282c40cf188d203ddd753c20743ba1a4 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 2 Oct 2026 10:17:53 -0700 Subject: [PATCH] D: pin every directory above a nested gitlink during execute and fix (#99) The read-only tmpfs at a nested gitlink (#81) moved with its parent when the agent renamed that parent, so the old path could be refilled during the run. Each directory above a nested gitlink is now mounted again from the task's work volume at its own path (volume-subpath). A mountpoint cannot be renamed, so the gitlink's mount stays put; what is inside stays writable, nosuid and nodev, on the same volume and limits. checkTaskTree refuses more than 256 pinned directories or more than 128 KiB of mount paths. The validator derives the pins from the gitlinks and allows exactly those: the task's own work volume, writable, at that subpath. The probe checks each pin and that renaming it fails. plan-format.md no longer lists the exception. Co-Authored-By: Claude Opus 5.5 --- agents/container/changes.ts | 32 ++++++++++++++- agents/container/profile.ts | 13 +++++- agents/container/run.ts | 25 +++++++++++- agents/policy.ts | 11 +++++- docs/implementation/agent-isolation.md | 16 +++++--- docs/plan-format.md | 2 +- test/agent-container.test.ts | 55 +++++++++++++++++++++++--- 7 files changed, 136 insertions(+), 18 deletions(-) diff --git a/agents/container/changes.ts b/agents/container/changes.ts index 17d7a245..df88f27e 100644 --- a/agents/container/changes.ts +++ b/agents/container/changes.ts @@ -371,7 +371,10 @@ export interface TreeCheckOptions extends StorageScriptOptions { */ export interface TaskTreeCheck { readonly base: string; - /** Every gitlink of `base`, each an empty directory now; each gets an empty read-only mount. */ + /** + * Every gitlink of `base`, each an empty directory now; each gets an empty read-only mount, and every directory above + * one (as `gitlinkParents(gitlinks)` lists them) is pinned with a mount of its own. + */ readonly gitlinks: readonly string[]; } /** @@ -389,8 +392,26 @@ export class TaskTreeRefused extends Error { this.differences = Object.freeze([...differences]); } } -/** The most gitlinks one profile mounts; a head with more refuses the launch. */ +/** The most gitlinks one profile mounts, and the most parent directories it pins; a head with more refuses the launch. */ export const MAXIMUM_GITLINK_MOUNTS = 256; +/** + * The most bytes of path the gitlink mounts add to the `docker create` arguments (each pin names its directory twice, + * each gitlink once), well inside what one exec can carry; a head that needs more refuses the launch. + */ +export const MAXIMUM_GITLINK_MOUNT_BYTES = 128 * 1024; +/** + * Every directory above a gitlink below the top level, parents before children (#99). Each is mounted again at its own + * path from the same work volume, so it is a mountpoint: renaming or removing it fails (EBUSY), and the gitlink's own + * mount cannot be moved away with it. What is inside stays writable. + */ +export function gitlinkParents(gitlinks: readonly string[]): readonly string[] { + const parents = new Set(); + for (const path of gitlinks) { + const parts = path.split('/'); + for (let index = 1; index < parts.length; index++) parents.add(parts.slice(0, index).join('/')); + } + return Object.freeze([...parents].sort((a, b) => a.split('/').length - b.split('/').length || (a < b ? -1 : a > b ? 1 : 0))); +} // The storage each check was made over, and whether a profile has used it. const treeChecks = new WeakMap(); // Docker reads `--mount` as comma-separated fields that a double quote can open: a name with either cannot be mounted @@ -488,6 +509,13 @@ export async function checkTaskTree(storage: TaskFilesystems, options: TreeCheck } if (gitlinks.length > MAXIMUM_GITLINK_MOUNTS) differences.push(`the head has ${gitlinks.length} gitlinks, more than the ${MAXIMUM_GITLINK_MOUNTS} that can be mounted`); + const parents = gitlinkParents(gitlinks as string[]).length; + if (parents > MAXIMUM_GITLINK_MOUNTS) + differences.push(`the head's gitlinks are below ${parents} directories, more than the ${MAXIMUM_GITLINK_MOUNTS} that can be pinned`); + const mountBytes = (gitlinks as string[]).reduce((total, path) => total + Buffer.byteLength(path), 0) + + gitlinkParents(gitlinks as string[]).reduce((total, path) => total + 2 * Buffer.byteLength(path), 0); + if (mountBytes > MAXIMUM_GITLINK_MOUNT_BYTES) + differences.push(`the gitlink mounts would name ${mountBytes} bytes of paths, more than the ${MAXIMUM_GITLINK_MOUNT_BYTES} a launch passes`); for (const path of gitlinks as string[]) if (unmountable(path)) differences.push(`gitlink ${q(path)} has a name Docker cannot mount`); if (differences.length) throw new TaskTreeRefused([...new Set(differences)]); const check = Object.freeze({ base: options.base, gitlinks: Object.freeze([...gitlinks as string[]]) }); diff --git a/agents/container/profile.ts b/agents/container/profile.ts index cefeb13b..177b7ef1 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { assertCapturedInvocation, type InvocationInput, type Phase, type UnreleasedResource } from '../contract.ts'; import { assertBuiltAgentImage } from './image.ts'; import { assertTaskFilesystems, taskFilesystemOwner, type TaskFilesystems } from './storage.ts'; -import { useTaskTreeCheck, type TaskTreeCheck } from './changes.ts'; +import { gitlinkParents, useTaskTreeCheck, type TaskTreeCheck } from './changes.ts'; import { ownerLabelArgs, type ResourceOwner } from '../labels.ts'; import { assertVendorNetwork, removeVendorNetwork, vendorNetworkResources, type VendorNetwork } from '../network/network.ts'; @@ -27,6 +27,8 @@ export interface ContainerProfile { readonly deferredOutput: boolean; /** Execute and fix: every gitlink path, each covered by an empty read-only tmpfs. Empty in read-only phases. */ readonly gitlinks: readonly string[]; + /** Execute and fix: every directory above a gitlink below the top level, each remounted from the work volume (#99). */ + readonly gitlinkParents: readonly string[]; } export interface ProfileOptions { readonly invocation: InvocationInput; @@ -292,6 +294,7 @@ export async function createContainerProfile(options: ProfileOptions): Promise ['--mount', mount({ type: 'volume', source: filesystems.workVolume, target: `/work/${path}`, + 'volume-subpath': path })]), // Each gitlink is an empty directory (the check proved it): an empty read-only tmpfs over it keeps it empty. ...gitlinks.flatMap(path => ['--mount', mount({ type: 'tmpfs', target: `/work/${path}`, readonly: true, 'tmpfs-mode': GITLINK_MOUNT_MODE, 'tmpfs-size': String(GITLINK_MOUNT_BYTES) })])]; @@ -329,7 +338,7 @@ export async function createContainerProfile(options: ProfileOptions): Promise | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean; - TmpfsOptions?: { SizeBytes?: number; Mode?: number; Options?: unknown } }> | null; Dns: string[]; + TmpfsOptions?: { SizeBytes?: number; Mode?: number; Options?: unknown }; + VolumeOptions?: { Subpath?: string; NoCopy?: boolean; DriverConfig?: unknown; Labels?: unknown } }> | null; Dns: string[]; DnsOptions: string[]; DnsSearch: string[]; ExtraHosts: string[] | null; PortBindings: Record | null; PublishAllPorts: boolean }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; @@ -256,7 +258,12 @@ export async function validateContainer(container: string, profile: ContainerPro } const mounts = new Map(inspect.Mounts.map(item => [item.Destination, item])); const gitlinkMounts = profile.gitlinks.map(path => `/work/${path}`); - const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input', ...gitlinkMounts, + // Derived again from the gitlinks, so the pins checked are exactly the ones those gitlinks need. + const pins = gitlinkParents(profile.gitlinks); + if (JSON.stringify(pins) !== JSON.stringify(profile.gitlinkParents)) + throw new Error('A directory above a gitlink is not pinned by its own work-volume mount.'); + const parentMounts = pins.map(path => `/work/${path}`); + const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input', ...gitlinkMounts, ...parentMounts, ...(profile.vendor === 'codex' ? ['/run/codeboost-auth/codex/auth.json'] : [])]); if (inspect.Mounts.some(item => !allowedMounts.has(item.Destination))) throw new Error('Container includes an unexpected external mount.'); @@ -272,11 +279,25 @@ export async function validateContainer(container: string, profile: ContainerPro && !(Array.isArray(requested[0]!.TmpfsOptions?.Options) && (requested[0]!.TmpfsOptions!.Options as unknown[]).length === 0)) throw new Error('A gitlink path is not covered by its empty read-only mount.'); } + // Every directory above a nested gitlink is the task's own work volume again, writable, at exactly its own subpath. + for (const path of pins) { + const target = `/work/${path}`; + const actual = inspect.Mounts.filter(item => item.Destination === target); + const requested = (host.Mounts ?? []).filter(item => item.Target === target); + const options = requested[0]?.VolumeOptions; + if (actual.length !== 1 || actual[0]!.Type !== 'volume' || actual[0]!.Name !== profile.filesystems.workVolume + || !actual[0]!.RW || requested.length !== 1 || requested[0]!.Type !== 'volume' + || requested[0]!.Source !== profile.filesystems.workVolume || requested[0]!.ReadOnly || options?.Subpath !== path + || options.NoCopy) + throw new Error('A directory above a gitlink is not pinned by its own work-volume mount.'); + } const work = mounts.get('/work'), metadata = mounts.get('/work/.git'), input = mounts.get('/run/codeboost-input'); if (work?.Type !== 'volume' || work.RW !== ['execute', 'fix'].includes(profile.phase) || metadata?.Type !== 'volume' || metadata.RW || input?.Type !== 'bind' || input.RW) throw new Error('Container mounts do not match the phase isolation profile.'); const requestedMounts = new Map((host.Mounts ?? []).map(item => [item.Target, item])); + // /work is the whole work volume: a subpath there would put another directory's content at the root. + if (requestedMounts.get('/work')?.VolumeOptions?.Subpath) throw new Error('Container mounts do not match the phase isolation profile.'); const requestedInput = requestedMounts.get('/run/codeboost-input'); if (requestedInput?.Type !== 'bind' || canonicalDockerBindSource(requestedInput.Source) !== profile.inputDirectory || canonicalDockerBindSource(input.Source) !== profile.inputDirectory diff --git a/agents/policy.ts b/agents/policy.ts index 82f1615a..2eaa644c 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -226,7 +226,16 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio + 'n=$((n+1)); test "$(findmnt --noheadings --output TARGET --target "/work/$p")" = "/work/$p"; ' + 'findmnt --noheadings --output OPTIONS --target "/work/$p" | tr , "\\n" | grep -Fxq ro; ' + 'test -z "$(ls -A "/work/$p")"; deny touch "/work/$p/x"; deny mkdir "/work/$p/.git"; ' - + 'deny rmdir "/work/$p"; deny mv "/work/$p" "/work/$p.moved"; done; test "$n" -gt 0; ' + + 'deny rmdir "/work/$p"; deny mv "/work/$p" "/work/$p.moved"; ' + // Each directory above it is pinned (#99): its own writable nosuid,nodev mountpoint, which cannot be moved aside, + // so the gitlink's mount cannot be carried off and its path refilled. What is inside stays writable. + + 'd=$(dirname "$p"); while [ "$d" != . ]; do ' + + 'test "$(findmnt --noheadings --output TARGET --target "/work/$d")" = "/work/$d"; ' + + 'o=$(findmnt --noheadings --output OPTIONS --target "/work/$d" | tr , "\\n"); ' + + 'for x in rw nosuid nodev; do printf "%s\\n" "$o" | grep -Fxq "$x"; done; ' + + 'deny mv "/work/$d" "/work/$d.moved"; test -d "/work/$p"; ' + + 'touch "/work/$d/.pinned-write"; test -f "/work/$d/.pinned-write"; rm "/work/$d/.pinned-write"; ' + + 'd=$(dirname "$d"); done; done; test "$n" -gt 0; ' + 'printf ok > /work/beside.txt; printf gitlink-protected', }; return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); diff --git a/docs/implementation/agent-isolation.md b/docs/implementation/agent-isolation.md index cfbf5e98..045c4a02 100644 --- a/docs/implementation/agent-isolation.md +++ b/docs/implementation/agent-isolation.md @@ -36,7 +36,7 @@ Each row is a T9 requirement for the Docker suite. The suite fails if any row fa | Hard links and alias writes from `.git/config` and objects fail, and metadata stays unchanged | `agent-container`: metadata alias probe in planning, review and execute, with a digest of `.git` before and after | | Mountpoint replacement fails | `agent-container`: metadata and metadata alias probes (`mv` and `rm -rf` of `.git`) | | Both vendor startup probes read the schema and return bounded valid output through their documented channel | `agent-supervisor` live probes (credentials required): Codex in review through its output file; Claude in questions through its stdout envelope; Claude in planning returns a schema-constrained answer as bare JSON. **Known failure:** the Codex probes (here and in `agent-container`) fail, because Codex cannot read files in any phase (see [Codex in read-only phases](#codex-in-read-only-phases)). | -| Nothing can be written beneath a gitlink, and execute and fix launch only on a clean tree (#81) | `agent-container`: the gitlink probe (writes, `mkdir`, `rmdir` and `mv` at every gitlink fail; the rest of `/work` stays writable); a pre-populated nested checkout, a restart with an untracked symlink parent and a restart with an occupied add destination are refused; a gitlink name Docker cannot mount, more than 256 gitlinks and an unreadable tree are refused; each check serves one profile over its own storage and head; the validator rejects a gitlink mount that is missing, writable, or of another size or mode | +| Nothing can be written beneath a gitlink, and execute and fix launch only on a clean tree (#81) | `agent-container`: the gitlink probe (writes, `mkdir`, `rmdir` and `mv` at every gitlink fail; the rest of `/work` stays writable); a pre-populated nested checkout, a restart with an untracked symlink parent and a restart with an occupied add destination are refused; a gitlink name Docker cannot mount, more than 256 gitlinks and an unreadable tree are refused; each check serves one profile over its own storage and head; the validator rejects a gitlink mount that is missing, writable, or of another size or mode; every directory above a nested gitlink is pinned, writable and `nosuid,nodev`, so renaming one fails (#99); the validator rejects a pin that is missing, read-only, at another subpath, from another volume, `volume-nocopy`, or not a volume | | Hostile input stays inside the boundary | `agent-container`: repositories with links that leave the checkout are refused, links inside the checkout still work, oversized repositories fail closed; `agent-policy`: option-like prompts; `agent-proxy`: hostile CONNECT traffic; `agent-supervisor`: hostile output | ## Why the gate can fail @@ -126,7 +126,16 @@ Use only these entry points to run an agent: Pass the result as `treeCheck` in the start call's request. An execute or fix profile requires it; a read-only phase refuses one. The profile adds an empty read-only tmpfs (`--mount type=tmpfs,target=/work/,readonly, tmpfs-mode=0555,tmpfs-size=4096`) at every gitlink the check listed, and the validator allows exactly those mounts, - each read-only with that size and mode. A check serves one profile, over the storage and head it was made for: a + each read-only with that size and mode. Every directory above a gitlink below the top level is pinned (#99): the + profile mounts the work volume again at that path (`--mount type=volume,source=,target=/work/, + volume-subpath=`, which needs Docker 26 or later). A mountpoint cannot be renamed, so the gitlink's mount + cannot be moved away with its parent and the path refilled; what is inside stays writable and `nosuid,nodev`, on + the same volume and limits. A pin is a mount boundary: `rename(2)` and hard links across it fail with `EXDEV`, so + `mv` copies instead, and a tool that writes a temporary file elsewhere and renames it into a pinned directory + fails. Docker resolves the subpath when the container starts, following any link there, so nothing may write the + work volume between the check and the start (nothing does). More than 256 pinned directories, or more than + 128 KiB of paths in these mounts, is refused. The validator allows exactly these mounts: the task's own + work volume, writable, at that subpath. A check serves one profile, over the storage and head it was made for: a copy, a second use, other storage or another head is refused, so check again before every invocation, retries included. - **After the handle settles**, call `inspectTaskChanges(storage, { base, linkSnapshot, imageId })`. It returns the @@ -382,6 +391,3 @@ against real Docker, including two reviews with different Ask owners on one daem is a read-only volume, so nothing can be written beneath a gitlink there; that includes `check` attempts, which run as review. Running an item's `cmd` in a writable task filesystem is not built yet; when it is, each run must call `checkTaskTree` and get the same mounts before it starts. -- The mount keeps a gitlink directory empty, but an agent can still rename a parent directory of a nested gitlink - (the mount moves with it) and create a new directory at the old path. The inspection after the run reports that - directory's content as nested gitlink content, so the task goes to needs human. #99 tracks enforcing this during the run. diff --git a/docs/plan-format.md b/docs/plan-format.md index a9a3180d..5dc18f66 100644 --- a/docs/plan-format.md +++ b/docs/plan-format.md @@ -128,7 +128,7 @@ The schema checks the shape. codeboost then checks the meaning. A **failure** bl | A completed agent invocation changes a dependency or a script codeboost will run. | Before its own installation or script invocation, codeboost stops in "needs approval". This post-invocation gate cannot prevent an agent from executing a changed script during its invocation; container and network restrictions must already contain that execution. | | `questions` is not empty. | The plan shows the questions at the top; answer them or approve anyway | -**Submodules in version 1.** Gitlinks are review-only metadata leaves: the linking engine can display and attribute externally produced pointer changes, but v1 plans cannot author, add, delete, or rename a gitlink. Reject declarations targeting an existing gitlink and reject agent-produced gitlinks. Authoring them requires a future typed target-commit field and runner-controlled operation. Do not initialize or update nested submodule worktrees. Before each agent or check invocation, reject an initialized/populated gitlink directory and bind an empty read-only mount at every existing gitlink path; refuse the invocation if that protection cannot be enforced. In v1 the mount fully protects a top-level gitlink; a gitlink below the top level is protected against writes into it, but an agent can rename one of its parent directories (the mount moves with it) and create a new directory at the old path, so for those the inspection below is the enforcement and its finding sends the task to needs human (follow-up #99: enforce it during the run). Before tests and before committing, inspect gitlink locations without following links and reject nested content, including content under newly introduced gitlinks. A top-level Git diff is not enough. Reviewing submodule pointers reads the Git entry only; the agent never receives an editable nested checkout. Tests must attempt writes beneath a gitlink and provide a pre-populated nested checkout, and both must be refused. +**Submodules in version 1.** Gitlinks are review-only metadata leaves: the linking engine can display and attribute externally produced pointer changes, but v1 plans cannot author, add, delete, or rename a gitlink. Reject declarations targeting an existing gitlink and reject agent-produced gitlinks. Authoring them requires a future typed target-commit field and runner-controlled operation. Do not initialize or update nested submodule worktrees. Before each agent or check invocation, reject an initialized/populated gitlink directory and bind an empty read-only mount at every existing gitlink path; refuse the invocation if that protection cannot be enforced. Every directory above a gitlink is pinned for the invocation too (mounted again at its own path, so it cannot be renamed), so the gitlink's mount cannot be moved aside and its path refilled. Before tests and before committing, inspect gitlink locations without following links and reject nested content, including content under newly introduced gitlinks. A top-level Git diff is not enough. Reviewing submodule pointers reads the Git entry only; the agent never receives an editable nested checkout. Tests must attempt writes beneath a gitlink and provide a pre-populated nested checkout, and both must be refused. **Accepted state versus runtime containment.** The symlink target rules and snapshots validate the pre-run and accepted post-run repository states; they do not prove that an arbitrary program never created, used, and restored a transient link. A proposed target discovered after execution is never retroactive authorization for access. During execution, the container mount/permission and network boundaries govern access: no unrelated host paths are mounted, metadata/schema/auth mounts are read-only, and only bounded worktree/scratch are writable. A link cannot confer permissions beyond that namespace. The selected vendor credential is deliberately readable by its CLI inside that namespace, including by a compromised CLI; merely spelling that same mounted path through a symlink is not an additional privilege. We do not promise that arbitrary execution cannot read its own vendor credential or access writable scratch. Tests must attempt transient links to unmounted host paths and writes to protected mounts, restore the links, and still confirm the runtime boundary denied those accesses. Do not use a final snapshot as evidence that transient access was blocked. diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 6db63092..3a9a5e4f 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -1702,20 +1702,22 @@ describe('real Docker agent isolation', () => { }; it('mounts every gitlink empty and read-only, so an agent\'s write beneath one is refused', async () => { - const data = withGitlinks(['sm', 'deps/inner']); + const data = withGitlinks(['sm', 'deps/inner', 'x/y/z']); const check = await checkTaskTree(data.filesystems, { base: data.clone.head, imageId, operations: [{ kind: 'edit', path: 'src/a.ts' }, { kind: 'add', path: 'src/b.ts' }] }); - expect(check).toEqual({ base: data.clone.head, gitlinks: ['deps/inner', 'sm'] }); + expect(check).toEqual({ base: data.clone.head, gitlinks: ['deps/inner', 'sm', 'x/y/z'] }); const valid = await profile(data, 'execute', 'gitlink-write', { treeCheck: check }); - expect(valid.gitlinks).toEqual(['deps/inner', 'sm']); + expect(valid.gitlinks).toEqual(['deps/inner', 'sm', 'x/y/z']); + // Every directory above a nested gitlink is pinned, so the probe's renames of deps, x and x/y all fail (#99). + expect(valid.gitlinkParents).toEqual(['deps', 'x', 'x/y']); expect(await runContainer(valid)).toBe('gitlink-protected'); // What the agent could write is still inspected, and nothing reached a gitlink directory. const manifest = await inspectTaskChanges(data.filesystems, { base: data.clone.head, linkSnapshot: await snapshotDeclaredLinks(data.filesystems, [], { imageId }), imageId }); expect(manifest.nestedGitlinkContent).toEqual([]); expect(manifest.changes.map(change => change.path)).toEqual(['beside.txt']); - // Inspection only: content at a nested gitlink path, as an agent can leave by renaming a parent during the run - // (#99), is reported after it. This runs without the mounts, so it does not test the runtime gap itself. + // Inspection only, without the mounts: content at a nested gitlink path is still reported after a run, should any + // way around the pinned parents remain. asAgent(data.filesystems, 'mv deps deps2 && mkdir -p deps/inner && printf nested > deps/inner/x'); const moved = await inspectTaskChanges(data.filesystems, { base: data.clone.head, linkSnapshot: await snapshotDeclaredLinks(data.filesystems, [], { imageId }), imageId }); @@ -1811,6 +1813,49 @@ describe('real Docker agent isolation', () => { await expect(profile(moved, 'execute', 'noop', { treeCheck: elsewhere })).rejects.toThrow('another head'); }, 180_000); + it('refuses gitlinks below more directories than a profile pins', async () => { + const deep = withGitlinks(Array.from({ length: 130 }, (_, index) => `d${index}/e/g`), undefined, undefined, + { workBytes: 16 * 1024 * 1024, workInodes: 2048, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512 }); + expect(await refusal(checkTaskTree(deep.filesystems, { base: deep.clone.head, operations: [], imageId }))) + .toEqual(['the head\'s gitlinks are below 260 directories, more than the 256 that can be pinned']); + }, 180_000); + + it('refuses gitlink mounts that would name more paths than a launch passes', async () => { + const stem = ['a', 'b', 'c', 'd'].map(letter => letter.repeat(200)).join('/'); + const paths = Array.from({ length: 200 }, (_, index) => `${stem}/g${index}`); + const parents = ['a', 'b', 'c', 'd'].map((_, index) => stem.split('/').slice(0, index + 1).join('/')); + const bytes = paths.reduce((total, path) => total + Buffer.byteLength(path), 0) + + parents.reduce((total, path) => total + 2 * Buffer.byteLength(path), 0); + expect(bytes).toBeGreaterThan(128 * 1024); + const long = withGitlinks(paths); + expect(await refusal(checkTaskTree(long.filesystems, { base: long.clone.head, operations: [], imageId }))) + .toEqual([`the gitlink mounts would name ${bytes} bytes of paths, more than the ${128 * 1024} a launch passes`]); + }, 180_000); + + it('rejects a container whose pin above a nested gitlink is missing, read-only or elsewhere', async () => { + const data = withGitlinks(['deps/inner']); + const pinned = (value: string) => value.startsWith('type=volume,') && value.endsWith(',target=/work/deps,volume-subpath=deps'); + const notPinned = 'A directory above a gitlink is not pinned by its own work-volume mount.'; + for (const [replace, refusal] of [ + [(value: string) => `${value},readonly`, notPinned], + [(value: string) => value.replace('volume-subpath=deps', 'volume-subpath=src'), notPinned], + [(value: string) => value.replace(',target=/work/deps,', ',target=/work/src,'), 'unexpected external mount'], + [(value: string) => value.replace(`source=${data.filesystems.workVolume},`, `source=${data.filesystems.metadataVolume},`), notPinned], + [(value: string) => `${value},volume-nocopy`, notPinned], + [() => 'type=tmpfs,target=/work/deps', notPinned], + [() => undefined, notPinned]] as const) { + const valid = await profile(data, 'execute', 'must-not-run'); + expect(valid.args.filter(pinned)).toHaveLength(1); + const args = valid.args.flatMap((value, index) => valid.args[index + 1] !== undefined && pinned(valid.args[index + 1]!) + && replace(valid.args[index + 1]!) === undefined ? [] : pinned(value) + ? [replace(value)].filter((item): item is string => item !== undefined) : [value]); + docker(...args); + containers.add(valid.name); + await expect(validateContainer(valid.name, valid)).rejects.toThrow(refusal); + docker('rm', '--force', valid.name); containers.delete(valid.name); + } + }, 180_000); + it('rejects a container whose gitlink mount is missing or writable after Docker resolves it', async () => { const data = withGitlinks(['sm']); const notCovered = 'A gitlink path is not covered by its empty read-only mount.';