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
23 changes: 23 additions & 0 deletions .boite/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Written by Boite the first time it stored a boite in this project.
# It won't be rewritten — edit freely.
#
# Tracked on purpose: boite.json (panels, layout, working directory) and
# panels/*.sh (per-panel init scripts). Those are why a boite lives in
# the repo — clone it, open it, get the same terminal.

# Regenerated from Boite's types on every save, and replaced whenever the
# schema version moves. It's here so editors can complete boite.json.
boite.schema.json

# Rolling snapshot of the last config Boite itself wrote, used to recover
# from an outside edit that breaks boite.json. Local to this machine.
.last-good/

# Which pane you were last in, and when you last opened this boite.
# Yours, not the project's — a fresh clone correctly has none.
state.json

.DS_Store

# Already committed one of the above? Boite won't touch your index:
# git rm -r --cached .boite && git add .boite
42 changes: 42 additions & 0 deletions .boite/F02D376A-B248-47BF-8EB3-A2E246B8B910/boite.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"$schema" : "./boite.schema.json",
"schemaVersion" : 14,
"createdAt" : "2026-08-08T08:50:06Z",
"id" : "F02D376A-B248-47BF-8EB3-A2E246B8B910",
"name" : "agentc",
"color" : "system://green",
"icon" : "lucide://container",
"isPinned" : false,
"storageChosenByUser" : false,
"panels" : [
{
"id" : "ED864585-65F4-40BB-A7A5-03C0BBB48741"
},
{
"id" : "AE74A2C1-36E5-4C4C-984E-93A61FCB1628"
}
],
"panelLayout" : {
"children" : [
{
"id" : "1238D765-FF0A-4C94-9FDB-78D056B3BC07",
"node" : {
"id" : "ED864585-65F4-40BB-A7A5-03C0BBB48741",
"type" : "leaf"
},
"ratio" : 0.5
},
{
"id" : "FF18AE60-8846-43F8-AE91-77731A917C18",
"node" : {
"id" : "AE74A2C1-36E5-4C4C-984E-93A61FCB1628",
"type" : "leaf"
},
"ratio" : 0.5
}
],
"direction" : "horizontal",
"id" : "042D2D85-0C01-421D-B2D7-C229685CD341",
"type" : "branch"
}
}
115 changes: 115 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,121 @@ jobs:
- name: Run unit tests
run: swift test --disable-default-traits --traits "${{ matrix.traits }}" --filter 'AgentIsolationTests|AgentIsolationDockerRuntimeTests'

# End-to-end coverage for the hardened runtimes the Docker adapter prefers over `runc`.
# Each job installs one runtime on the runner's Docker daemon and then asserts that
# discovery finds it, selects it, and that containers really stop sharing the host kernel.
#
# gVisor needs no hardware support (its default `systrap` platform is pure userspace), so
# it runs as a required job on both architectures. Kata needs `/dev/kvm`.
runtime-e2e:
name: Runtime E2E (${{ matrix.runtime }}, ${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- runtime: runsc
os: ubuntu-24.04
arch: x64
required: true
- runtime: runsc
os: ubuntu-24.04-arm
arch: arm64
required: true
- runtime: kata
os: ubuntu-24.04
arch: x64
required: false

runs-on: ${{ matrix.os }}
continue-on-error: ${{ !matrix.required }}
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Swift
uses: ./.github/actions/setup-swift

- name: Cache SPM build artifacts
uses: actions/cache@v4
with:
path: .build
key: spm-runtime-e2e-${{ matrix.os }}-${{ matrix.arch }}-${{ hashFiles('Package.swift', 'Package.resolved') }}
restore-keys: |
spm-runtime-e2e-${{ matrix.os }}-${{ matrix.arch }}-
spm-unit-${{ matrix.os }}-${{ matrix.arch }}-

# Kata boots a VM per container, so without KVM it either fails outright or silently
# falls back to QEMU's TCG interpreter, which is far too slow to test against.
- name: Probe KVM
id: kvm
if: matrix.runtime == 'kata'
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm || true
grep -c -E 'vmx|svm' /proc/cpuinfo || echo "no virtualization flags in /proc/cpuinfo"
if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
echo "::notice::/dev/kvm is usable on this runner"
else
echo "available=false" >> "$GITHUB_OUTPUT"
echo "::warning::/dev/kvm is unavailable on this runner — skipping the Kata E2E"
fi

- name: Install gVisor
if: matrix.runtime == 'runsc'
run: |
set -euo pipefail
url="https://storage.googleapis.com/gvisor/releases/release/latest/$(uname -m)"
wget -q "${url}/runsc" "${url}/runsc.sha512" \
"${url}/containerd-shim-runsc-v1" "${url}/containerd-shim-runsc-v1.sha512"
sha512sum -c runsc.sha512 -c containerd-shim-runsc-v1.sha512
sudo mv runsc containerd-shim-runsc-v1 /usr/local/bin/
sudo chmod a+rx /usr/local/bin/runsc /usr/local/bin/containerd-shim-runsc-v1
# Registers a Docker runtime named `runsc` in /etc/docker/daemon.json.
sudo /usr/local/bin/runsc install
sudo systemctl restart docker

- name: Install Kata Containers
if: matrix.runtime == 'kata' && steps.kvm.outputs.available == 'true'
run: |
set -euo pipefail
# Release asset names have shifted across Kata 3.x (kata-static, kata-go-static,
# .tar.xz, .tar.zst), so pick whatever the latest release actually publishes.
asset=$(curl -fsSL https://api.github.com/repos/kata-containers/kata-containers/releases/latest \
| jq -r '.assets[].browser_download_url
| select(test("kata(-go)?-static-.*(amd64|x86_64)\\.tar\\.(xz|zst)$"))' \
| head -n1)
test -n "$asset" || { echo "::error::no Kata static release asset found"; exit 1; }
echo "Installing $asset"
sudo apt-get update && sudo apt-get install -y zstd
curl -fsSL -o kata-static.tar "$asset"
sudo tar -xf kata-static.tar -C /
/opt/kata/bin/kata-runtime --version

# Register the shim as a Docker runtime, merging into any existing daemon.json.
sudo mkdir -p /etc/docker
[ -f /etc/docker/daemon.json ] || echo '{}' | sudo tee /etc/docker/daemon.json >/dev/null
jq '.runtimes.kata = {"runtimeType": "/opt/kata/bin/containerd-shim-kata-v2"}' \
/etc/docker/daemon.json | sudo tee /etc/docker/daemon.json.new >/dev/null
sudo mv /etc/docker/daemon.json.new /etc/docker/daemon.json
sudo systemctl restart docker

- name: Verify the runtime is registered with Docker
if: matrix.runtime != 'kata' || steps.kvm.outputs.available == 'true'
run: |
set -euo pipefail
docker info --format 'default={{.DefaultRuntime}} runtimes={{json .Runtimes}}'
docker info --format '{{json .Runtimes}}' | jq -e 'has("${{ matrix.runtime }}")'
docker run --rm --runtime '${{ matrix.runtime }}' alpine:latest uname -r

- name: Run runtime E2E tests
if: matrix.runtime != 'kata' || steps.kvm.outputs.available == 'true'
run: swift test --disable-default-traits --traits ContainerRuntimeDocker --filter AgentIsolationDockerRuntimeTests
env:
AGENTC_TEST_DOCKER_RUNTIME: ${{ matrix.runtime }}

build-test-image:
strategy:
fail-fast: false
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [GitHub

**macOS / Linux (Docker runtime):** x64 or arm64, Docker Engine API v1.44+ (Docker, Podman with Docker compatibility, etc.).

> [!IMPORTANT]
> Standard Docker containers share the host kernel. Because agents run untrusted code,
> `agentc` automatically prefers Kata Containers or gVisor when available and warns when
> only standard `runc` isolation is available. See [*Safer Docker Isolation*](./docs/docker-runtimes.md).

### Install

```sh
Expand Down Expand Up @@ -78,6 +83,23 @@ To skip the bootstrap and use the image's own entrypoint:
agentc run --respect-image-entrypoint -i my-image:latest
```

### Docker Isolation

Agents run code you did not write, so on the Docker backend `agentc` asks the daemon which
runtimes it has and prefers the strongest isolation available: **Kata Containers** (a VM per
container) over **gVisor** (`runsc`) over **`runc`** (shares the host kernel). If only `runc`
is available, `agentc` uses it and prints a warning with setup instructions.

Pick one yourself — including `runc`, which also silences the warning:

```sh
agentc run --docker-runtime runsc
agentc run --docker-runtime runc # "I know, runc is fine here"
```

See [Safer Docker Isolation](./docs/docker-runtimes.md). The Apple Container backend already
gives every container its own VM, so runtime selection applies only to the Docker backend.

## Architecture

```
Expand Down
23 changes: 22 additions & 1 deletion Sources/AgentIsolation/ContainerRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,30 @@ public struct ContainerRuntimeConfiguration: Sendable {
public var storagePath: String
public var endpoint: String?

public init(storagePath: String, endpoint: String? = nil) {
/// An explicit low-level runtime (OCI runtime binary or containerd shim) to run
/// containers with — Docker's `HostConfig.Runtime`, for example.
///
/// Runtime names are administrator-defined aliases, so this is an opaque value passed
/// through to the runtime unvalidated. When `nil`, a conforming runtime is free to pick
/// one itself; ``AgentIsolationDockerRuntime`` discovers what the daemon offers and
/// prefers the strongest isolation available.
public var ociRuntime: String?

/// Invoked with user-facing security or configuration warnings raised while setting up
/// the runtime. Messages are multi-line and pre-formatted; the host decides where they
/// go. When `nil`, the runtime falls back to its logger.
public var warningHandler: (@Sendable (String) -> Void)?

public init(
storagePath: String,
endpoint: String? = nil,
ociRuntime: String? = nil,
warningHandler: (@Sendable (String) -> Void)? = nil
) {
self.storagePath = storagePath
self.endpoint = endpoint
self.ociRuntime = ociRuntime
self.warningHandler = warningHandler
}
}

Expand Down
19 changes: 18 additions & 1 deletion Sources/AgentIsolation/ProjectSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,26 @@
/// only the values you specify take effect.
public struct ProjectSettings: Codable, Sendable, Equatable {
public var agent: AgentSettings?
public var docker: DockerSettings?

public init(agent: AgentSettings? = nil) {
public init(agent: AgentSettings? = nil, docker: DockerSettings? = nil) {
self.agent = agent
self.docker = docker
}

/// Settings specific to the Docker runtime backend.
public struct DockerSettings: Codable, Sendable, Equatable {
/// The runtime to run containers with, e.g. `kata`, `runsc`, or `runc`.
///
/// Runtime names are administrator-defined aliases and Docker can also invoke
/// fully-qualified containerd shims that are not registered with the daemon, so this
/// value is passed through unvalidated. Setting it also opts out of the security
/// warning shown when only `runc` is available.
public var runtime: String?

public init(runtime: String? = nil) {
self.runtime = runtime
}
}

public struct AgentSettings: Codable, Sendable, Equatable {
Expand Down
16 changes: 16 additions & 0 deletions Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ final class DockerAPIClient: Sendable {
}
}

// MARK: - Daemon Info

/// Fetch `GET /info` to discover the runtimes registered with the daemon.
func info() async throws -> DockerInfo {
var request = makeRequest(url: buildURL(path: "/info"))
request.method = .GET

let response = try await httpClient.execute(request, timeout: .seconds(30))
let body = try await response.body.collect(upTo: 10 * 1024 * 1024)
guard response.status == .ok else {
throw DockerRuntimeError.apiError(
Int(response.status.code), "Failed to read daemon info: \(String(buffer: body))")
}
return try JSONDecoder().decode(DockerInfo.self, from: body)
}

// MARK: - Image Operations

func pullImage(ref: String, platform: String? = nil) async throws {
Expand Down
27 changes: 27 additions & 0 deletions Sources/AgentIsolationDockerRuntime/DockerModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@
import Foundation
#endif

// MARK: - Daemon Info

/// The subset of `GET /info` we use to discover which runtimes the daemon can invoke.
///
/// Docker reports every runtime registered in `daemon.json` (plus the built-in `runc`).
/// It can additionally invoke fully-qualified containerd shims found on its `PATH` without
/// registering them, so absence from `Runtimes` does not mean a runtime is unusable —
/// hence ``DockerRuntimeSelection/Source/configured``.
struct DockerInfo: Codable, Sendable {
let Runtimes: [String: RuntimeEntry]?
let DefaultRuntime: String?

/// A registered runtime. `path` is set for OCI runtime binaries, `runtimeType` for
/// containerd shims — either one can identify a runtime hiding behind an opaque alias.
struct RuntimeEntry: Codable, Sendable {
var path: String?
var runtimeType: String?

init(path: String? = nil, runtimeType: String? = nil) {
self.path = path
self.runtimeType = runtimeType
}
}
}

// MARK: - Image Types

struct DockerImageInspect: Codable, Sendable {
Expand Down Expand Up @@ -47,6 +72,8 @@ struct DockerHostConfig: Codable, Sendable {
var NanoCpus: Int64?
var CpusetCpus: String?
var Init: Bool?
/// The runtime to run this container with. Left `nil` to use the daemon's default.
var Runtime: String?
}

struct DockerCreateContainerResponse: Codable, Sendable {
Expand Down
Loading
Loading