Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,51 @@ network, env, proof, and cleanup behavior under those constraints.

## Verification

### Native execution bounds

Protected workload and conformance runs explicitly use `--pull never`. Approved
images must already be cached; execution never falls back to pulling an image.
Availability has a ten-second ceiling, conformance has thirty seconds, and direct
command execution defaults to one minute. Explicit request/runtime limits and
earlier caller deadlines take precedence. Owned cancellation cleanup gets a
separate ten-second opportunity shared by ownership inspection and removal.
Cleanup requires the independent per-attempt owner label and a full verified
container ID before removing that ID in the same runtime scope. Name collisions,
missing/malformed inspection and unknown creation remain unresolved errors; no
foreign name is removed and generic inspection failure never proves absence.

`ExecDockerCommandRunner.MaxOutputBytes` bounds aggregate stdout/stderr. A positive
limit is used exactly; the implementation default is 1 MiB when no positive limit
is supplied. Writers continue draining through EOF and retain each stream's final
rolling tail before deterministic aggregate rebalancing. `OutputLimitError`
implements `OutputCaptureMetadataProvider`, carrying original per-stream counts
and final truncation flags without putting output into its error text. Adapters
preserve that typed error across timeout wrapping. Consumers must copy this
metadata into their run-log provenance before preserving bounded streams.
`CaptureIncompleteError` has `Complete:false`; its partial streams are withheld.
Observed bytes from forced pipe closure must never be labeled complete original
stream sizes. Control-process output is not exposed as workload provenance.

`ReadOnlyWorkspace` is an opt-in policy on `DockerSandboxRuntime`,
`SandboxRuntimeCommandAdapter` and `SandboxRunRequest`; defaults remain false.
It keeps the same workspace bind mounted read-only, requires a successful strict
initial byte-limit walk, rejects writable/shadowing mounts, special files,
symlinks, writable rootfs/tmpfs/capability overrides and artifact output requests.
The shared `ValidateReadOnlyPolicy` preflight also rejects image-declared writable
volumes and checks Docker's advertised memory/swap/CPU/PID controls. The opt-in
command policy caps memory plus swap at the declared memory limit. Unsupported runtime
evidence fails closed; read-only control verification currently supports Docker
with a Linux daemon. A native smoke test must still demonstrate actual configured
limits and write denial on the selected runtime. No daemon or cgroup settings are
changed by this policy.

An exclusively owned empty workspace then has zero workload growth; read-only
mounting cannot stop concurrent host writes. `/tmp` is separately bounded to
64 MiB. Read-only commands and all conformance containers use `--log-driver none`;
bounded attached output is distinct from daemon log storage. Legacy writable
workspace/artifact behavior remains available. Its `WorkspaceBytes` describes
measurement/reservation, not a portable hard quota on a writable bind mount.

```sh
GOWORK=off go test ./... -count=1
GOWORK=off go vet ./...
Expand Down
99 changes: 82 additions & 17 deletions container/adapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type SandboxRunRequest struct {
DataMounts []SandboxMount
Timeout time.Duration
Limits core.ResourceLimits
ReadOnlyWorkspace bool
}

type SandboxRunResult struct {
Expand All @@ -70,6 +71,7 @@ type SandboxRunResult struct {
Stderr []byte
ArtifactHash string
ResourceUsage core.ResourceUsage
OutputCapture OutputCaptureMetadata
}

type SandboxRuntime interface {
Expand Down Expand Up @@ -104,7 +106,8 @@ type SandboxedCommandRuntime interface {
}

type SandboxRuntimeCommandAdapter struct {
Runtime SandboxRuntime
ReadOnlyWorkspace bool
Runtime SandboxRuntime
}

type SandboxedContainerBuildRuntimeInvocation struct {
Expand Down Expand Up @@ -363,24 +366,38 @@ func (a SandboxRuntimeCommandAdapter) RunSandboxedCommand(ctx context.Context, i
if len(workload.Args) == 0 {
return core.RuntimeExecutionResult{}, errors.New("command args are required")
}
if err := a.Runtime.Available(ctx); err != nil {
return core.RuntimeExecutionResult{}, fmt.Errorf("sandbox runtime unavailable: %w", err)
}
timeout := defaultDuration(invocation.Timeout, time.Minute)
if invocation.Request.Limits.RuntimeSeconds > 0 && time.Duration(invocation.Request.Limits.RuntimeSeconds)*time.Second < timeout {
timeout = time.Duration(invocation.Request.Limits.RuntimeSeconds) * time.Second
}
timeoutLimitHit := firstNonEmpty(invocation.TimeoutLimitHit, "timeout")
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
readOnly := a.ReadOnlyWorkspace
switch native := a.Runtime.(type) {
case DockerSandboxRuntime:
readOnly = readOnly || native.ReadOnlyWorkspace
case *DockerSandboxRuntime:
readOnly = readOnly || (native != nil && native.ReadOnlyWorkspace)
}
if readOnly && len(workload.ArtifactAllowlist) > 0 {
return core.RuntimeExecutionResult{}, errors.New("read-only workspace cannot produce artifacts")
}
if err := a.Runtime.Available(runCtx); err != nil {
return core.RuntimeExecutionResult{}, fmt.Errorf("sandbox runtime unavailable: %w", controlProcessError(err, "runtime availability output capture failed"))
}

started := time.Now().UTC()
result, err := a.Runtime.Run(runCtx, SandboxRunRequest{
Image: invocation.Image,
Command: slices.Clone(workload.Args),
Workspace: invocation.Workspace,
WorkingDir: workload.WorkingDirectory,
Env: cloneStringMap(invocation.Request.Env),
Network: firstNonEmpty(invocation.Network, SandboxNetworkNone),
Timeout: timeout,
Limits: invocation.Request.Limits,
Image: invocation.Image,
Command: slices.Clone(workload.Args),
Workspace: invocation.Workspace,
WorkingDir: workload.WorkingDirectory,
Env: cloneStringMap(invocation.Request.Env),
Network: firstNonEmpty(invocation.Network, SandboxNetworkNone),
Timeout: timeout,
Limits: invocation.Request.Limits,
ReadOnlyWorkspace: readOnly,
})
finished := time.Now().UTC()
runResult := core.RuntimeExecutionResult{
Expand All @@ -394,7 +411,7 @@ func (a SandboxRuntimeCommandAdapter) RunSandboxedCommand(ctx context.Context, i
}
if runCtx.Err() != nil {
runResult.ResourceUsage.LimitHit = timeoutLimitHit
return runResult, fmt.Errorf("sandboxed command timed out after %s: %w", timeout, runCtx.Err())
return runResult, errors.Join(fmt.Errorf("sandboxed command timed out after %s: %w", timeout, runCtx.Err()), err)
}
if err != nil {
return runResult, err
Expand All @@ -412,7 +429,14 @@ func (a SandboxRuntimeCommandAdapter) RunSandboxedCommand(ctx context.Context, i
runResult.Artifacts = slices.Clone(workload.ArtifactAllowlist)
}
if runResult.ResourceUsage.WorkspaceBytes == 0 && invocation.Workspace != "" {
runResult.ResourceUsage.WorkspaceBytes = DirectorySize(invocation.Workspace)
size, measureErr := directorySizeContext(runCtx, invocation.Workspace)
if measureErr != nil {
if runCtx.Err() != nil {
runResult.ResourceUsage.LimitHit = timeoutLimitHit
}
return runResult, fmt.Errorf("sandbox workspace accounting incomplete: %w", measureErr)
}
runResult.ResourceUsage.WorkspaceBytes = size
}
return runResult, nil
}
Expand Down Expand Up @@ -482,7 +506,7 @@ func (a SandboxRuntimeContainerBuildAdapter) BuildSandboxedContainer(ctx context
return core.RuntimeExecutionResult{}, err
}
if err := a.Runtime.Available(ctx); err != nil {
return core.RuntimeExecutionResult{}, fmt.Errorf("sandbox runtime unavailable: %w", err)
return core.RuntimeExecutionResult{}, fmt.Errorf("sandbox runtime unavailable: %w", controlProcessError(err, "runtime availability output capture failed"))
}
timeout := defaultDuration(invocation.Timeout, time.Hour)
timeoutLimitHit := firstNonEmpty(invocation.TimeoutLimitHit, "timeout")
Expand Down Expand Up @@ -537,7 +561,7 @@ func (a SandboxRuntimeContainerBuildAdapter) BuildSandboxedContainer(ctx context
}
if runCtx.Err() != nil {
runResult.ResourceUsage.LimitHit = timeoutLimitHit
return runResult, fmt.Errorf("sandboxed container build timed out after %s: %w", timeout, runCtx.Err())
return runResult, errors.Join(fmt.Errorf("sandboxed container build timed out after %s: %w", timeout, runCtx.Err()), err)
}
if err != nil {
return runResult, err
Expand All @@ -556,7 +580,14 @@ func (a SandboxRuntimeContainerBuildAdapter) BuildSandboxedContainer(ctx context
return runResult, errors.New("sandboxed container build did not produce a sha256 artifact digest")
}
if runResult.ResourceUsage.WorkspaceBytes == 0 && invocation.Workspace != "" {
runResult.ResourceUsage.WorkspaceBytes = DirectorySize(invocation.Workspace)
size, measureErr := directorySizeContext(runCtx, invocation.Workspace)
if measureErr != nil {
if runCtx.Err() != nil {
runResult.ResourceUsage.LimitHit = timeoutLimitHit
}
return runResult, fmt.Errorf("sandbox workspace accounting incomplete: %w", measureErr)
}
runResult.ResourceUsage.WorkspaceBytes = size
}
return runResult, nil
}
Expand Down Expand Up @@ -693,6 +724,40 @@ func ReadSandboxedContainerBuildDigest(path string) (string, error) {
return digest, nil
}

// directorySizeContext discards partial accounting on cancellation or I/O
// failure. Cancellation is cooperative between filesystem operations; it cannot
// preempt a kernel-blocked filesystem call. Legacy DirectorySize stays unchanged.
func directorySizeContext(ctx context.Context, root string) (int64, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
var total int64
err := filepath.WalkDir(root, func(_ string, entry os.DirEntry, walkErr error) error {
if err := ctx.Err(); err != nil {
return err
}
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
total += info.Size()
return nil
})
if err != nil {
return 0, err
}
if err := ctx.Err(); err != nil {
return 0, err
}
return total, nil
}

func DirectorySize(root string) int64 {
var total int64
_ = filepath.WalkDir(root, func(_ string, entry os.DirEntry, err error) error {
Expand Down
Loading
Loading