Skip to content

feat(container): add gVisor sandbox support for Docker containers#74

Closed
andybons wants to merge 16 commits into
mainfrom
feat/gvisor-integration-clean
Closed

feat(container): add gVisor sandbox support for Docker containers#74
andybons wants to merge 16 commits into
mainfrom
feat/gvisor-integration-clean

Conversation

@andybons
Copy link
Copy Markdown
Collaborator

Summary

  • Adds gVisor (runsc) as the default OCI runtime for Docker containers, providing kernel-level isolation
  • Detects gVisor availability via Docker API and fails with actionable install instructions if missing
  • Adds --no-sandbox CLI flag to opt out when gVisor isn't available or causes compatibility issues
  • Adds sandbox: none config option in agent.yaml for persistent opt-out
  • Documents the sandboxing model and configuration options

Changes

  • internal/container/docker.go: gVisor detection, --runtime=runsc in container creation
  • internal/container/detect.go: RuntimeOptions struct with sandbox support
  • internal/run/manager.go: Wire NoSandbox option through run lifecycle
  • internal/config/config.go: Add sandbox field to agent config
  • cmd/moat/cli/exec.go: Add --no-sandbox flag
  • cmd/moat/cli/run.go: Honor sandbox: none from config
  • docs/content/concepts/01-sandboxing.md: New concepts page
  • docs/content/reference/01-cli.md: Document --no-sandbox flag

Design decisions

  • Secure by default: gVisor required unless explicitly opted out
  • Apple containers unaffected: They already use macOS virtualization for isolation
  • Clear error messages: Linux install instructions are a single copy-paste command

Test plan

  • Run moat run on Linux with gVisor installed → uses runsc runtime
  • Run moat run on Linux without gVisor → clear error with install instructions
  • Run moat run --no-sandbox → uses runc with warning
  • Set sandbox: none in agent.yaml → uses runc without needing flag
  • Run on macOS with Apple containers → unaffected by this change

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings January 27, 2026 12:51
@andybons andybons force-pushed the feat/gvisor-integration-clean branch from f2d0878 to a4cd566 Compare January 27, 2026 12:53
@claude
Copy link
Copy Markdown

claude Bot commented Jan 27, 2026

📦 Previous Review (superseded by newer review)

Pull Request Review: gVisor Sandbox Support

This PR adds gVisor sandbox support for Docker containers, making it the default runtime with an opt-out mechanism. The implementation is well-structured and follows good security practices. Below is my detailed feedback.


Code Quality & Best Practices

Strengths:

  • Clean architecture: The sandbox option flows cleanly through the stack: CLI → Manager → Runtime detection → Docker runtime creation
  • Separation of concerns: Detection logic in detect.go, Docker-specific implementation in docker.go
  • Backward compatibility: NewManager() preserved with default options, minimizing breaking changes
  • Error handling: Proper error wrapping and context propagation throughout
  • User-friendly error messages: Clear installation instructions with copy-pasteable commands (internal/container/docker.go:29-44)

Areas for improvement:

  1. Config validation missing: The Sandbox field accepts any string value without validation. Invalid values like "disabled" or "off" will silently behave as if gVisor is enabled.

    Recommendation: Add validation in internal/config/config.go or where the config is loaded:

    if cfg.Sandbox != "" && cfg.Sandbox != "none" {
        return fmt.Errorf("invalid sandbox value %q: must be empty or \"none\"", cfg.Sandbox)
    }
  2. Redundant gVisor detection: Both GVisorAvailable() in detect.go:1066 and NewDockerRuntime() in docker.go:70 check for gVisor availability using identical logic. This creates duplication and potential maintenance issues.

    Recommendation: Have NewDockerRuntime() call GVisorAvailable() instead of duplicating the check. The function is exported and already tested.

  3. Context creation in NewDockerRuntime: Creating a context with timeout (docker.go:67) during object construction is non-standard. The function signature doesn't accept a context, making it impossible for callers to control cancellation.

    Recommendation: Either accept ctx context.Context as a parameter, or make the gVisor check lazy (on first container creation). For now, the 5s timeout is reasonable but consider documenting this blocking behavior.


Potential Bugs & Issues

  1. Race condition in flag precedence: In cmd/moat/cli/run.go:132-134, the code only applies sandbox: none if !runFlags.NoSandbox. However, if a user runs moat run --no-sandbox with a config that has sandbox: none, both sources agree but the logic suggests the CLI flag should override the config (which it does, but only coincidentally).

    Issue: The current logic works but is fragile. If someone later tries to add a --sandbox flag to force enable it, the precedence will be broken.

    Recommendation: Make precedence explicit:

    // CLI flag takes precedence over config
    if !cmd.Flags().Changed("no-sandbox") && cfg.Sandbox == "none" {
        runFlags.NoSandbox = true
    }
  2. Incomplete test coverage: The new TestGVisorAvailable test (internal/container/detect_test.go:1098-1105) only verifies the function doesn't panic. It doesn't test:

    • Behavior when Docker is unavailable
    • Behavior when Docker is available but gVisor isn't installed
    • Behavior when gVisor is properly installed

    Recommendation: Add table-driven tests with mocked Docker client scenarios, or at minimum document that this is an integration test dependent on local Docker setup.

  3. Silent behavior change for existing configs: Existing users with Docker will suddenly require gVisor after this update. While the error message is helpful, this could break CI/CD pipelines or scripts.

    Mitigation already present: The --no-sandbox flag and clear error messages mitigate this. Consider adding a migration note in the PR description or CHANGELOG.


Security Concerns

Positive findings:

  • Secure by default: Requiring gVisor is the right security posture
  • Explicit opt-out: The --no-sandbox flag and sandbox: none config make it clear when running without additional isolation
  • Warning on disabled sandbox: The log warning (docker.go:63) alerts users to reduced isolation
  • No privileged containers: The change doesn't introduce any privilege escalation risks

Recommendation:

  • Consider logging the actual runtime being used at container creation time (not just at runtime initialization). This would help with auditing: log.Debug("creating container with runtime", "ociRuntime", r.ociRuntime, "containerID", resp.ID)

Performance Considerations

  1. Docker Info API call overhead: Each NewDockerRuntime() call makes a cli.Info(ctx) request (docker.go:70) even when sandbox=false. This API call happens during manager initialization, adding ~5-50ms overhead.

    Impact: Low - this is a one-time cost per moat run invocation
    Optimization: Skip the Info call when sandbox=false:

    if !sandbox {
        log.Warn("running without gVisor sandbox - reduced isolation")
        ociRuntime = "runc"
    } else {
        // Only check Info when we actually need gVisor
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        // ... existing gVisor detection ...
    }
  2. 5-second timeout: The timeout (docker.go:67) is reasonable for local Docker but might be too aggressive for remote Docker daemons or slow networks.

    Impact: Low - most users use local Docker
    Future consideration: Make timeout configurable via env var if users report issues


Test Coverage

Current state:

  • ✅ Basic test exists for GVisorAvailable()
  • ✅ Existing Docker tests updated to use NewDockerRuntime(false)
  • ❌ No tests for RuntimeOptions behavior
  • ❌ No tests for config parsing of sandbox field
  • ❌ No tests for CLI flag integration

Recommendations:

  1. Add unit test for RuntimeOptions in detect_test.go
  2. Add test for sandbox field parsing in config_test.go
  3. Consider e2e test: moat run --no-sandbox -- echo test (if Docker available in CI)

Documentation

Strengths:

  • ✅ CLI reference updated with --no-sandbox flag and usage guidance
  • ✅ agent.yaml reference documents the sandbox field with clear examples
  • ✅ Error messages include installation instructions

Minor issues:

  1. Inconsistent terminology: Docs alternate between "gVisor sandbox", "runsc", and "OCI runtime".

    • Recommendation: Pick one primary term ("gVisor sandbox") and use it consistently, with "runsc" in parentheses on first mention
  2. Missing compatibility note: The docs don't mention which gVisor version is required or if there are known compatibility issues.

    • Recommendation: Add a "Compatibility" section noting any minimum versions or known issues
  3. Plan documents in main branch: The PR includes docs/plans/2026-01-25-gvisor-*.md files. These are useful but typically aren't merged to main.

    • Recommendation: Move to a separate docs/adr/ (Architecture Decision Records) directory, or remove from main branch and keep in PR history only

Summary

Overall assessment: This is a well-implemented security feature with clear benefits. The code quality is high, and the design decisions are sound.

Critical issues: None blocking

Important improvements:

  1. Add config validation for Sandbox field values
  2. Deduplicate gVisor detection logic
  3. Improve test coverage for the new configuration paths

Minor improvements:

  1. Optimize to skip Docker Info call when sandbox is disabled
  2. Make CLI flag precedence more explicit
  3. Enhance documentation consistency

Recommendation: Approve with suggested improvements. The critical path (detection, runtime selection, container creation) is solid. The improvements suggested above would enhance maintainability and robustness but aren't blocking for merge.


Verification Checklist

Before merging, verify:

  • go test ./... passes on Linux without gVisor (should fail with clear error)
  • go test ./... passes on Linux with gVisor installed
  • go test ./... passes on macOS with Apple containers (unaffected by this change)
  • moat run --no-sandbox works and shows warning
  • moat run with sandbox: none in agent.yaml works
  • Invalid sandbox value (e.g., sandbox: disabled) behavior is acceptable
  • Error message for missing gVisor is helpful and copy-pasteable

Great work on this security enhancement! 🔒

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds secure-by-default gVisor (runsc) sandboxing for Docker-based runs, with CLI/config escape hatches to disable sandboxing when needed.

Changes:

  • Introduces sandbox-aware runtime creation (RuntimeOptions) and wires --no-sandbox through the run lifecycle.
  • Updates Docker container creation to set HostConfig.Runtime to runsc (or non-sandbox runtime when opted out) and adds gVisor availability checks with install guidance.
  • Extends config/docs to support persistent opt-out via sandbox: none and documents --no-sandbox.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
internal/run/manager.go Adds manager options and passes sandbox preference into container runtime selection.
internal/container/docker.go Implements gVisor detection, runtime selection, and sets Docker HostConfig.Runtime.
internal/container/detect.go Adds RuntimeOptions plumbing and exposes GVisorAvailable.
internal/container/detect_test.go Adds a basic test for GVisorAvailable.
internal/container/docker_list_test.go Updates integration tests to use non-sandbox Docker runtime.
internal/config/config.go Adds top-level sandbox configuration field for agent.yaml.
cmd/moat/cli/exec.go Adds --no-sandbox flag and wires it into manager creation.
cmd/moat/cli/run.go Honors sandbox: none from agent.yaml by setting --no-sandbox behavior.
docs/content/reference/01-cli.md Documents --no-sandbox flag and adds examples.
docs/content/reference/02-agent-yaml.md Documents sandbox: none configuration option.
docs/plans/2026-01-25-gvisor-integration-design.md Design doc describing the intended gVisor integration.
docs/plans/2026-01-25-gvisor-implementation.md Implementation plan for the gVisor integration work.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/container/detect_test.go Outdated
Comment on lines +6 to +11
)

func TestGVisorAvailable(t *testing.T) {
// This test verifies the function exists and returns a boolean.
// Actual gVisor detection depends on Docker daemon configuration.
ctx := context.Background()
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test uses context.Background() when calling GVisorAvailable(), which may block indefinitely if the Docker daemon/socket is unreachable. Use a context with a short timeout (or a cancellable context) in the test to prevent CI hangs.

Suggested change
)
func TestGVisorAvailable(t *testing.T) {
// This test verifies the function exists and returns a boolean.
// Actual gVisor detection depends on Docker daemon configuration.
ctx := context.Background()
"time"
)
func TestGVisorAvailable(t *testing.T) {
// This test verifies the function exists and returns a boolean.
// Actual gVisor detection depends on Docker daemon configuration.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +27
// DefaultRuntimeOptions returns the default runtime options.
func DefaultRuntimeOptions() RuntimeOptions {
return RuntimeOptions{Sandbox: true}
}
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DefaultRuntimeOptions() hard-enables Sandbox for all callers of container.NewRuntime(), but several non-run commands call NewRuntime without any way to opt out (e.g., cmd/moat/cli/status.go:64, cmd/moat/cli/clean.go:40, cmd/moat/cli/system_images.go:34). On a Docker host without runsc configured, these commands will now fail even though --no-sandbox only exists on moat run. Consider adding a global opt-out (env var / global config) that NewRuntime() honors, or updating those commands to call NewRuntimeWithOptions when sandbox should be disabled.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +45
To bypass (reduced isolation):
moat run --no-sandbox`)
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ErrGVisorNotAvailable message’s bypass instructions are specific to moat run --no-sandbox, but this error can surface from other commands that initialize the Docker runtime (e.g., moat status, moat clean). Consider making the bypass guidance command-agnostic and also mentioning the persistent opt-out (sandbox: none in agent.yaml, or a global opt-out if added) so users aren’t blocked when running non-run commands.

Suggested change
To bypass (reduced isolation):
moat run --no-sandbox`)
To bypass sandboxing (reduced isolation):
- For a single command, disable sandboxing for that invocation (for example by using a no-sandbox flag if supported).
- To persistently disable sandboxing, set 'sandbox: none' in your agent.yaml configuration.`)

Copilot uses AI. Check for mistakes.
Comment thread internal/container/docker.go Outdated
Comment on lines +62 to +66
ociRuntime := "runsc"
if !sandbox {
log.Warn("running without gVisor sandbox - reduced isolation")
ociRuntime = "runc"
} else {
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When sandbox is disabled, this hard-codes the runtime to "runc". That overrides the Docker daemon’s default runtime (which may be something else, e.g., nvidia), and could change behavior unexpectedly. If the goal is only to avoid runsc, consider leaving HostConfig.Runtime unset (empty string) when sandbox=false, or explicitly using Docker’s reported default runtime instead of forcing "runc".

Copilot uses AI. Check for mistakes.
Comment thread internal/container/docker.go Outdated
Comment on lines +71 to +84
info, err := cli.Info(ctx)
if err != nil {
cli.Close()
return nil, fmt.Errorf("checking Docker info: %w", err)
}

found := false
for name := range info.Runtimes {
if name == "runsc" {
found = true
break
}
}
if !found {
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gVisor detection logic here duplicates internal/container/detect.go:GVisorAvailable(). To reduce drift and make this easier to test/maintain, consider reusing the shared helper (or refactor it to accept a Docker client) instead of re-implementing the Info/Runtimes scan in multiple places.

Suggested change
info, err := cli.Info(ctx)
if err != nil {
cli.Close()
return nil, fmt.Errorf("checking Docker info: %w", err)
}
found := false
for name := range info.Runtimes {
if name == "runsc" {
found = true
break
}
}
if !found {
available, err := GVisorAvailable(ctx)
if err != nil {
cli.Close()
return nil, fmt.Errorf("checking gVisor availability: %w", err)
}
if !available {

Copilot uses AI. Check for mistakes.
Commands like 'moat status', 'moat clean', 'moat system images', and
'moat system containers' only query/manage existing resources - they
don't create new containers that need sandboxing.

These commands now explicitly use Sandbox: false to avoid requiring
gVisor when it's not needed.
- Deduplicate gVisor detection: NewDockerRuntime now uses GVisorAvailable()
- Use Docker's default runtime when sandbox disabled (empty string vs 'runc')
- Add config validation for sandbox field (only '' or 'none' allowed)
- Add timeout to TestGVisorAvailable to prevent CI hangs
- Add tests for sandbox config validation
@claude
Copy link
Copy Markdown

claude Bot commented Jan 27, 2026

📦 Previous Review (superseded by newer review)

Pull Request Review: gVisor Sandbox Integration

This PR adds gVisor sandboxing as the default runtime for Docker containers. The implementation is well-structured and follows project conventions.

Code Quality - Strong

Architecture: Clean separation with RuntimeOptions struct, proper functional options pattern, backward compatibility maintained.

Error Handling: ErrGVisorNotAvailable provides actionable instructions, proper context timeouts, resource cleanup on errors.

Key Issues

1. Duplicate gVisor Detection Logic - Check duplicated in NewDockerRuntime and GVisorAvailable function. Consider refactoring with internal helper.

2. Test Coverage Gap - Only smoke test exists. Missing tests for mock clients, error handling, flag behavior, config parsing.

3. Resource Cleanup - cli.Close() errors silently ignored in error paths. Consider logging failures.

4. Context Propagation - GVisorAvailable accepts context but Docker client creation doesn't use it.

Security Review

Secure by Default: gVisor required unless opted out, clear warnings, no automatic fallback. No new attack surface.

Concerns: None identified.

Performance

Detection adds ~5s timeout when gVisor unavailable but mitigated by fast daemon response. gVisor adds ~5-15% syscall overhead which is acceptable for security benefits.

Documentation

CLI and agent.yaml references are clear with good examples. Design docs are comprehensive.

Summary

Strengths: Clean architecture, secure by default, excellent error messages, comprehensive docs, backward compatible.

Areas for Improvement: Deduplicate detection logic, add unit/integration tests, consider caching.

Recommendation: Approve with minor improvements. Core implementation is solid. Suggestions are non-blocking and could be follow-up PRs.

Overall: 8.5/10 - Well-executed feature with minor refinement opportunities.

@claude
Copy link
Copy Markdown

claude Bot commented Jan 27, 2026

📦 Previous Review (superseded by newer review)

Pull Request Review: gVisor Sandbox Support

Summary

This PR adds gVisor (runsc) as the default OCI runtime for Docker containers, providing kernel-level isolation through a userspace kernel. The implementation is well-structured and follows the codebase conventions. However, there are several important issues that should be addressed before merging.


Critical Issues

1. Missing gVisor Documentation in Sandboxing Concepts 🔴

The new docs/content/concepts/01-sandboxing.md file does not mention gVisor at all, despite gVisor being the primary feature of this PR. This is a critical documentation gap that will confuse users.

Location: docs/content/concepts/01-sandboxing.md

Issue: The sandboxing concepts page discusses Docker and Apple containers but never mentions:

  • That Docker containers now use gVisor by default
  • What gVisor is or why it provides better isolation
  • How gVisor differs from standard Docker containers
  • The --no-sandbox flag or when to use it

Recommendation: Add a dedicated section explaining:

  • gVisor's role in the Docker runtime (userspace kernel, syscall interception)
  • Why it's enabled by default (defense-in-depth)
  • Performance and compatibility tradeoffs
  • When to use --no-sandbox

Example addition:

## gVisor Sandbox (Docker only)

When using Docker, Moat runs containers with gVisor (runsc) by default. gVisor provides an additional layer of isolation by intercepting syscalls and implementing them in a userspace kernel (Sentry).

### Why gVisor?

Standard Docker containers share the host kernel. gVisor adds defense-in-depth by inserting a userspace kernel between the container and the host, reducing the attack surface for container escape vulnerabilities.

### Compatibility

Some workloads use syscalls that gVisor doesn't support. If you encounter syscall-related errors, use `--no-sandbox` to disable gVisor:

```bash
moat run --no-sandbox ./my-project

You can also set sandbox: none in agent.yaml for persistent opt-out.


---

#### 2. **Documentation Claims Mismatch with Reality** 🔴

Per CLAUDE.md: "Documentation must match actual behavior. When writing or updating docs, verify claims against the code."

**Location:** `docs/content/concepts/01-sandboxing.md:133-135`

The limitations section states:

> It does not provide:
> - Protection against container escape exploits

**Issue:** This statement is now misleading. gVisor **does** provide significant protection against many container escape exploits by reducing the kernel attack surface. This is the entire point of the PR.

**Recommendation:** Update to reflect gVisor's defensive properties:
```markdown
It does not provide:
- Absolute protection against all container escape exploits (gVisor reduces but doesn't eliminate this risk)
- Isolation equivalent to a separate physical machine
- Defense against malicious code specifically designed to escape gVisor's sandbox

Major Issues

3. No E2E Tests for gVisor Functionality 🟡

Issue: There are no end-to-end tests verifying that:

  • Containers actually use runsc when gVisor is enabled
  • The --no-sandbox flag correctly falls back to runc
  • The error message appears when gVisor is unavailable
  • The sandbox: none config setting works

Current test coverage:

  • TestGVisorAvailable - Only checks the function doesn't panic
  • Config validation tests exist but don't test runtime behavior
  • Docker list tests now disable sandbox but don't verify runtime selection

Recommendation: Add E2E tests in internal/e2e/:

func TestGVisorEnabled(t *testing.T) {
    // Create container with sandbox enabled
    // Inspect container to verify Runtime: "runsc"
}

func TestNoSandboxFlag(t *testing.T) {
    // Create container with --no-sandbox
    // Verify Runtime field is empty (default runc)
}

4. Inconsistent Error Handling in NewDockerRuntime 🟡

Location: internal/container/docker.go:62-76

var ociRuntime string // empty string = Docker's default runtime
if \!sandbox {
    log.Warn("running without gVisor sandbox - reduced isolation")
    // Leave ociRuntime empty to use Docker's default (usually runc)
} else {
    // Verify gVisor is available using shared detection function
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if \!GVisorAvailable(ctx) {
        cli.Close()
        return nil, fmt.Errorf("%w", ErrGVisorNotAvailable)
    }
    ociRuntime = "runsc"
}

Issues:

  1. When sandbox=false, ociRuntime is empty string, relying on "Docker's default (usually runc)" - this is not guaranteed
  2. Comment says "usually runc" but Docker's default could be configured differently
  3. The empty string approach is inconsistent with the explicit "runsc" used for sandbox mode

Recommendation: Be explicit about the runtime in both cases:

var ociRuntime string
if \!sandbox {
    log.Warn("running without gVisor sandbox - reduced isolation")
    ociRuntime = "runc" // Explicitly request standard runtime
} else {
    // ... gVisor detection ...
    ociRuntime = "runsc"
}

Alternatively, if the intent is to use Docker's default (which might be runsc on some systems), document this explicitly and explain why.


5. GVisorAvailable Creates New Client on Every Call 🟡

Location: internal/container/detect.go:195-211

func GVisorAvailable(ctx context.Context) bool {
    cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
    if err \!= nil {
        return false
    }
    defer cli.Close()
    // ...
}

Issue: This function creates a new Docker client every time it's called, even though NewDockerRuntime has already created one. This is inefficient and adds unnecessary overhead.

Current usage in docker.go:71:

if \!GVisorAvailable(ctx) {
    cli.Close()  // We close the cli we just created
    return nil, fmt.Errorf("%w", ErrGVisorNotAvailable)
}

Recommendation: Refactor to accept a client parameter:

// GVisorAvailable checks if runsc is configured as a Docker runtime.
func GVisorAvailable(ctx context.Context, cli *client.Client) bool {
    info, err := cli.Info(ctx)
    if err \!= nil {
        return false
    }
    for name := range info.Runtimes {
        if name == "runsc" {
            return true
        }
    }
    return false
}

Then update the call in docker.go:

if \!GVisorAvailable(ctx, cli) {
    cli.Close()
    return nil, fmt.Errorf("%w", ErrGVisorNotAvailable)
}

Note: The design doc actually shows this better approach at line 55 of 2026-01-25-gvisor-integration-design.md.


Minor Issues

6. Installation Instructions Could Be Clearer 🟢

Location: internal/container/docker.go:32-39

The installation command is complex and spans multiple lines. While the instruction says "copy and run," users might not realize it's a single command.

Current:

To install on Linux (Debian/Ubuntu), copy and run:

  curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor.gpg && \
    echo "deb [signed-by=/usr/share/keyrings/gvisor.gpg] https://storage.googleapis.com/gvisor/releases release main" | \
    sudo tee /etc/apt/sources.list.d/gvisor.list && \
    sudo apt update && sudo apt install -y runsc && \
    sudo runsc install && \
    sudo systemctl reload docker

Recommendation: Consider adding a note that it's a single command, or provide a script alternative:

To install on Linux (Debian/Ubuntu):

  # Single command (copy entire block):
  curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor.gpg && \
    echo "deb [signed-by=/usr/share/keyrings/gvisor.gpg] https://storage.googleapis.com/gvisor/releases release main" | \
    sudo tee /etc/apt/sources.list.d/gvisor.list && \
    sudo apt update && sudo apt install -y runsc && \
    sudo runsc install && \
    sudo systemctl reload docker

7. Inconsistent Comment Style 🟢

Location: internal/container/docker.go:50

ociRuntime string // "runsc" or "runc"

Issue: The comment mentions "runc" but the implementation uses empty string for the non-sandbox case, not "runc". This is misleading.

Recommendation: Update comment to match implementation:

ociRuntime string // "runsc" for gVisor, empty for Docker default

Or fix the implementation to match the comment (see issue #4).


8. Test Files Disable Sandbox Without Explanation 🟢

Location: internal/container/docker_list_test.go:11, 28

rt, err := NewDockerRuntime(false) // Use runc for tests (no gVisor requirement)

Issue: While the comment explains the choice, it would be better to use a test helper or constant to make this explicit across all tests.

Recommendation:

const testSandbox = false // Disable gVisor for tests to avoid setup requirements

func TestDockerRuntime_ListImages(t *testing.T) {
    rt, err := NewDockerRuntime(testSandbox)
    // ...
}

Code Quality Observations

Excellent Error Messages

The installation instructions in ErrGVisorNotAvailable are exactly what CLAUDE.md requests: actionable, specific, and tell users what to do. This is defensive security done right.

Good Separation of Concerns

The RuntimeOptions struct and NewRuntimeWithOptions pattern is clean and extensible.

Proper Backwards Compatibility

All existing call sites that use NewRuntime() continue to work, and the default behavior is secure-by-default.

Configuration Validation

The sandbox field validation in config.go is thorough and provides helpful error messages.


Security Considerations

Secure by Default

Making gVisor required by default is the right choice for a security-focused tool.

Clear Warnings

The warning when running with --no-sandbox appropriately alerts users to reduced isolation.

⚠️ Documentation Gap

Users need to understand what gVisor provides and doesn't provide. The current documentation doesn't explain the security model clearly enough (see issue #1).


Performance Considerations

⚠️ No Performance Testing

gVisor has known performance overhead for certain syscalls (especially I/O-heavy workloads). The PR doesn't:

  • Benchmark gVisor vs runc performance
  • Document expected performance characteristics
  • Provide guidance on when performance tradeoffs matter

Recommendation: Add a performance section to the documentation explaining when users might want to disable gVisor for performance reasons (e.g., heavy disk I/O, certain networking patterns).


Testing Recommendations

  1. Add E2E tests verifying runtime selection (see issue feat(audit): add tamper-proof audit logging with cryptographic verification #3)
  2. Test error paths - what happens when gVisor installation is incomplete?
  3. Test config precedence - does --no-sandbox override sandbox: none?
  4. Test with actual gVisor - the current test just verifies the function doesn't panic

Documentation Updates Needed

  1. Update docs/content/concepts/01-sandboxing.md to explain gVisor (see issue feat(routing): add hostname-based service routing for agents #1)
  2. Add performance characteristics to help users make informed decisions
  3. Add troubleshooting section for common gVisor issues (syscall compatibility, etc.)
  4. Update security model documentation to reflect gVisor's defensive properties (see issue Add Claude Code GitHub Workflow #2)

Conventional Commits

✅ Commits follow the conventional commits format properly with appropriate scopes.


Overall Assessment

This is a well-implemented feature that adds important security hardening to Moat. The code structure is clean, error messages are helpful, and the secure-by-default approach is correct.

However, the documentation gaps are critical. A feature this significant needs comprehensive documentation that explains what gVisor is, why it matters, and when to use the escape hatch. The current sandboxing documentation doesn't mention gVisor at all, which will confuse users.

Recommendation: Request Changes

Must fix before merge:

  1. Add gVisor section to sandboxing documentation (issue feat(routing): add hostname-based service routing for agents #1)
  2. Fix documentation claims about container escape protection (issue Add Claude Code GitHub Workflow #2)
  3. Fix GVisorAvailable to accept client parameter (issue perf(audit): optimize Merkle tree rebuilding to O(log n) per append #5)

Should fix before merge:
4. Add E2E tests for runtime selection (issue #3)
5. Clarify ociRuntime behavior for non-sandbox mode (issue #4)

Nice to have:
6. Improve installation instructions clarity (issue #6)
7. Add performance documentation
8. Add troubleshooting section


Great work overall! The implementation is solid - it just needs the documentation to match the quality of the code.

🤖 Review provided by Claude Sonnet 4.5

E2E tests verify moat functionality, not gVisor specifically.
Using NoSandbox: true allows tests to run in CI without gVisor.
@claude
Copy link
Copy Markdown

claude Bot commented Jan 27, 2026

📦 Previous Review (superseded by newer review)

Pull Request Review: gVisor Sandbox Support

This PR implements gVisor (runsc) as the default OCI runtime for Docker containers, providing kernel-level isolation through userspace syscall handling. The implementation is well-structured and follows secure-by-default principles.

Code Quality ✅

Strengths:

  • Clean separation of concerns with dedicated detection function (GVisorAvailable)
  • Proper error handling with actionable error messages
  • Consistent use of context timeouts (5s) across Docker API calls
  • Good backward compatibility via NewManager() wrapper maintaining existing API
  • Appropriate use of options pattern for extensibility

Suggestions:

  1. Resource leak potential in GVisorAvailable (detect.go:195-213):
    The function creates a new Docker client on every call. Consider accepting a client parameter or caching the result:

    // Current: Creates new client each time
    func GVisorAvailable(ctx context.Context) bool {
        cli, err := client.NewClientWithOpts(...)
        defer cli.Close()
        // ...
    }
    
    // Suggestion: Accept client to avoid repeated connections
    func GVisorAvailable(ctx context.Context, cli *client.Client) bool {
        // ...
    }

    However, since this is only called once during runtime initialization, the impact is minimal.

  2. Empty string vs explicit runtime name (docker.go:62):
    Setting ociRuntime to empty string for "no sandbox" mode relies on Docker's default behavior:

    var ociRuntime string // empty string = Docker's default runtime
    if !sandbox {
        log.Warn("running without gVisor sandbox - reduced isolation")
        // Leave ociRuntime empty to use Docker's default (usually runc)
    }

    Consider explicitly setting ociRuntime = "runc" for clarity, or document why empty is preferred (e.g., respecting user's Docker daemon configuration).

  3. Test coverage for NoSandbox behavior:
    While E2E tests were updated to use NoSandbox: true, there's no explicit test verifying the sandbox flag affects runtime selection. Consider adding a unit test that mocks Docker client and verifies Runtime field in HostConfig.

Potential Bugs 🔍

No critical bugs identified. The implementation correctly:

  • Closes Docker client on gVisor detection failure (docker.go:72)
  • Handles context timeouts appropriately
  • Validates config values ("none" or empty only)

Minor observation:

  • In detect.go:85-92, the runtime name construction could be simplified:
    runtimeName := "Docker"
    if sandbox {
        runtimeName = "Docker+gVisor"
    }
    log.Debug("using " + runtimeName + " runtime")
    Could use: log.Debug("using Docker runtime", "sandbox", sandbox) for structured logging.

Performance Considerations ⚡

Good:

  • gVisor detection is only performed at initialization (not per-container)
  • Reasonable 5-second timeout for Docker API calls
  • No sandbox check for read-only operations (status, clean, list)

Neutral:

  • gVisor overhead is well-documented in the gVisor project (~5-15% for most workloads)
  • No performance tests included, but this is acceptable for a security feature

Security Concerns 🔒

Strong security model:

  • ✅ Secure by default (gVisor required unless explicitly opted out)
  • ✅ Clear warnings when running without sandbox
  • ✅ Actionable error messages with installation instructions
  • ✅ Apple containers unaffected (already isolated via macOS virtualization)

Potential concerns:

  1. Config validation could be stricter (config.go:1055-1057):
    Currently allows empty string or "none". Consider explicitly documenting that empty != disabled:

    // Validate sandbox setting
    if cfg.Sandbox != "" && cfg.Sandbox != "none" {
        return nil, fmt.Errorf("invalid sandbox value %q: must be empty (default) or 'none'", cfg.Sandbox)
    }

    The validation is correct, but the error message could clarify: "empty (gVisor enabled)" vs "none (disabled)".

  2. No runtime verification after container creation:
    Consider logging which runtime was actually used after successful container creation for audit purposes. This would help detect Docker daemon misconfigurations where runsc is reported as available but fails at runtime.

Test Coverage 📊

Good:

  • ✅ Config validation tests for sandbox field (config_test.go:1071-1110)
  • ✅ Detection function smoke test (detect_test.go)
  • ✅ All E2E tests updated to explicitly set NoSandbox: true

Gaps:

  • Missing integration test that verifies Runtime: "runsc" is actually passed to Docker API
  • No test for the cfg.Sandbox == "none" code path in run.go:131-133
  • Consider adding a test that mocks Docker Info() to return different runtime configurations

Documentation 📚

Excellent documentation:

  • ✅ Clear CLI flag documentation with usage examples
  • ✅ agent.yaml reference properly documents sandbox field
  • ✅ Comprehensive design docs and implementation plans
  • ✅ Actionable error messages with copy-paste installation commands

Minor suggestions:

  • The install instructions could mention that sudo systemctl reload docker might need restart on some systems
  • Consider adding a troubleshooting section for common gVisor issues (syscall compatibility)

Architecture & Design 🏗️

Well-architected:

  • ✅ Options pattern allows future extensibility
  • ✅ Clean separation between detection, configuration, and runtime
  • ✅ Consistent handling across all CLI commands
  • ✅ No breaking changes to existing API

Future considerations:

  • The TraceConfig hooks mentioned in design docs are well-positioned for future syscall observability
  • Consider exposing gVisor platform mode (ptrace vs KVM) as a future config option

Recommendations

Before merge:

  1. ✅ No blocking issues - this is ready to merge

Future improvements:

  1. Add integration test for runtime field in container creation
  2. Consider explicit "runc" over empty string for clarity
  3. Add audit logging of actual runtime used per container
  4. Document gVisor syscall compatibility limitations in troubleshooting guide

Summary

This is a high-quality implementation of an important security feature. The code is clean, well-tested, properly documented, and follows security best practices with secure-by-default behavior. The error messages are exemplary - users will know exactly what to do when gVisor is missing.

Recommendation: Approve and merge

The minor suggestions above are non-blocking and can be addressed in future PRs if desired.


Review generated by Claude Code - Moat security reviewer

Users reported no visible feedback when running 'moat run'. Now shows
'Initializing...' immediately while the runtime, proxy, and container
are being set up.
@claude
Copy link
Copy Markdown

claude Bot commented Jan 27, 2026

Pull Request Review: gVisor Sandbox Support

This PR adds gVisor (runsc) as the default sandbox for Docker containers, providing kernel-level isolation for enhanced security. I've reviewed the implementation and have several observations and recommendations.


Strengths

  1. Security-first design: Secure by default with explicit opt-out is the right approach
  2. Clear error messages: The ErrGVisorNotAvailable error provides actionable installation instructions (internal/container/docker.go:30-45)
  3. Comprehensive testing: E2E tests properly updated to use NoSandbox: true to avoid CI/test failures
  4. Good separation of concerns: RuntimeOptions abstraction cleanly separates sandbox configuration
  5. Documentation: CLI and agent.yaml reference docs are well-written
  6. Backward compatibility: All existing commands that don't need sandbox (clean, status, system) properly bypass it

🔍 Code Quality Issues

1. Empty string vs. explicit runtime specification (internal/container/docker.go:62-66)

var ociRuntime string // empty string = Docker's default runtime
if !sandbox {
    log.Warn("running without gVisor sandbox - reduced isolation")
    // Leave ociRuntime empty to use Docker's default (usually runc)
}

Issue: The code leaves ociRuntime as an empty string when sandbox is disabled, relying on Docker's default runtime. This creates ambiguity:

  • Comment says "usually runc" but doesn't guarantee it
  • Different Docker installations might have different defaults
  • Could be confusing in logs/debugging

Recommendation: Explicitly set to "runc" for clarity:

var ociRuntime string
if !sandbox {
    log.Warn("running without gVisor sandbox - reduced isolation")
    ociRuntime = "runc" // Explicitly use runc when sandbox disabled
} else {
    // ...
}

2. Duplicate Docker client creation (internal/container/detect.go:195-213 & docker.go:57-60)

Both GVisorAvailable() and NewDockerRuntime() create their own Docker clients. GVisorAvailable is called during NewDockerRuntime (docker.go:71), resulting in:

  • Two Docker client creations per runtime initialization
  • Duplicate API calls to docker.Info()

Recommendation: Refactor to reuse the client:

// In docker.go
func NewDockerRuntime(sandbox bool) (*DockerRuntime, error) {
    cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
    if err != nil {
        return nil, fmt.Errorf("creating docker client: %w", err)
    }

    var ociRuntime string
    if !sandbox {
        log.Warn("running without gVisor sandbox - reduced isolation")
        ociRuntime = "runc"
    } else {
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()

        if !gvisorAvailableWithClient(ctx, cli) { // New helper
            cli.Close()
            return nil, fmt.Errorf("%w", ErrGVisorNotAvailable)
        }
        ociRuntime = "runsc"
    }

    return &DockerRuntime{cli: cli, ociRuntime: ociRuntime}, nil
}

// New internal helper
func gvisorAvailableWithClient(ctx context.Context, cli *client.Client) bool {
    info, err := cli.Info(ctx)
    if err != nil {
        return false
    }
    for name := range info.Runtimes {
        if name == "runsc" {
            return true
        }
    }
    return false
}

// Keep public function for external callers
func GVisorAvailable(ctx context.Context) bool {
    cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
    if err != nil {
        return false
    }
    defer cli.Close()
    return gvisorAvailableWithClient(ctx, cli)
}

3. Test coverage gap for gVisor detection

The test in internal/container/detect_test.go:9-17 only verifies the function doesn't panic. It doesn't test:

  • Detection when gVisor is available
  • Detection when gVisor is not available
  • Error handling scenarios

Recommendation: Add mocked tests using httpmock or Docker test containers to verify detection logic.


🐛 Potential Bugs

1. Race condition in initialization message (cmd/moat/cli/exec.go:49-68)

fmt.Print("Initializing...")
os.Stdout.Sync()

manager, err := run.NewManagerWithOptions(...)
if err != nil {
    fmt.Println() // Clear the "Initializing..." line
    return nil, fmt.Errorf("creating run manager: %w", err)
}

fmt.Println() // Clear the "Initializing..." line

Issue: If gVisor detection fails, the multi-line error message will appear on the same line as "Initializing...", creating confusing output like:

Initializing...creating run manager: gVisor (runsc) is required but not available

To install on Linux...

Recommendation: Print newline before the error or use a spinner/status indicator that properly clears.

2. Config validation allows empty string but runtime defaults to true (internal/config/config.go:264-267)

// Validate sandbox setting
if cfg.Sandbox != "" && cfg.Sandbox != "none" {
    return nil, fmt.Errorf("invalid sandbox value %q: must be empty (default) or 'none'", cfg.Sandbox)
}

This validation is correct, but there's a subtle UX issue:

  • Empty string in YAML means "use default" (gVisor enabled)
  • But RuntimeOptions{Sandbox: true} is the actual default
  • No explicit "gvisor" or "enabled" value is allowed

Consideration: Current design is fine for opt-out only, but consider future extensibility if you want to support explicit "gvisor" or other sandbox types. Document this clearly.


Performance Considerations

  1. gVisor overhead: gVisor adds CPU/memory overhead vs. runc (typically 10-30% for CPU-intensive workloads). Consider documenting this in the concepts page or CLI help.

  2. 5-second timeout for detection (detect.go:81, docker.go:68): Reasonable for most cases, but could be slow in CI environments with many parallel runs. Consider making this configurable via env var.


🔒 Security Observations

  1. Good: Default-secure approach prevents accidental exposure
  2. Good: Warning logs when sandbox disabled (docker.go:64)
  3. Good: Documentation warns about reduced isolation
  4. Consideration: The --no-sandbox flag bypasses security without requiring any confirmation. For production environments, consider adding:
    • An env var check (e.g., MOAT_ALLOW_NO_SANDBOX=1)
    • A confirmation prompt in interactive mode
    • Audit log entry when sandbox is disabled

📝 Documentation Review

docs/content/reference/01-cli.md

  • ✅ Clear explanation of --no-sandbox
  • ✅ When to use it
  • ⚠️ Missing: Performance impact mention
  • ⚠️ Missing: Security implications details

docs/content/reference/02-agent-yaml.md

  • ✅ Clear table showing values
  • ✅ Good warning about reduced isolation
  • ⚠️ Inconsistency: Says "gVisor sandbox enabled (default)" but implementation allows empty string only (not explicit "gvisor" value)

🧪 Test Coverage Assessment

Strong coverage:

  • ✅ All E2E tests updated to use NoSandbox: true
  • ✅ Config validation tests for sandbox field (config_test.go:1087-1124)

Gaps:

  • ❌ No tests for gVisor detection logic beyond "doesn't panic"
  • ❌ No tests for runtime selection with/without gVisor
  • ❌ No integration test verifying containers actually use runsc vs. runc
  • ❌ No test for the initialization message race condition

Recommendation: Add at least one integration test that:

  1. Creates a container with sandbox enabled (if gVisor available)
  2. Inspects container to verify HostConfig.Runtime == "runsc"
  3. Creates a container with --no-sandbox and verifies runtime is empty/runc

📋 Minor Issues

  1. Comment style inconsistency: Some comments use // comment (detect.go:62) while others have blank lines before (docker.go:62). Pick one style.

  2. Variable naming: ociRuntime could be more specific, like containerRuntime or dockerRuntime, since OCI is the spec and both runsc and runc are OCI-compliant.

  3. Error wrapping: In docker.go:73, fmt.Errorf("%w", ErrGVisorNotAvailable) is redundant - just return ErrGVisorNotAvailable directly.

  4. Logging: Detection logic in detect.go:89-92 logs "Docker+gVisor" but doesn't log when detection fails. Consider debug log for troubleshooting.


🎯 Recommendations Priority

High Priority (fix before merge):

  1. Fix initialization message race condition (exec.go:49-68)
  2. Deduplicate Docker client creation in gVisor detection

Medium Priority (fix soon after):
3. Add integration test for runtime verification
4. Make detection timeout configurable
5. Improve test coverage for gVisor detection

Low Priority (future enhancement):
6. Document performance impact
7. Consider confirmation for --no-sandbox in interactive mode
8. Add explicit "gvisor" config value for clarity


Conclusion

This is a solid implementation of gVisor sandboxing with good security defaults and clear documentation. The main concerns are:

  1. Minor code quality issues (duplicate clients, empty string vs. explicit runtime)
  2. Test coverage gaps
  3. UI polish for error messages

The PR is ready to merge with the understanding that the high-priority issues should be addressed in a follow-up PR if not fixed immediately.

Overall Grade: B+ (would be A with the high-priority fixes)


Reviewed by: Claude Sonnet 4.5
Review Date: 2026-01-27

@dpup
Copy link
Copy Markdown
Collaborator

dpup commented Jan 28, 2026

I'm going to merge these commits into another pending PR, which I think will be easier.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants