Configuration Reference
File location
| Source | Precedence |
|---|---|
--config <file> |
1 |
JAILOC_CONFIG=<file> |
2 |
~/.config/jailoc/config.toml |
3 (default) |
~ is expanded and relative paths are resolved against the current directory. The default file is auto-created with defaults on first run; a file set with --config or JAILOC_CONFIG must exist.
Per-workspace files, such as the CA bundle copied by ca_bundle, live in workspaces/<name>/ next to the configuration file.
VM state under ~/.cache/jailoc-vm/ is keyed by workspace name only and is shared by all configuration files: workspaces with the same name in two files control the same VM.
All fields are optional unless noted. jailoc ignores settings it does not know and prints a warning naming each of them; jailoc migrate removes them when it rewrites the file.
Top-level fields
Besides the tables [vm], [base], [defaults] and [workspaces.<name>], the configuration has one setting at the root level. Put it before the first table.
| Field | Type | Default | Description |
|---|---|---|---|
detach_keys |
string | "ctrl-b,d" |
Key sequence that detaches the terminal from an agent session. The --detach-keys flag of jailoc overrides it. |
detach_keys is a comma-separated list of keys, typed one after another, in the format of docker attach --detach-keys. A key is ctrl-<letter> (lowercase), one of ctrl-@, ctrl-[, ctrl-\, ctrl-], ctrl-^, ctrl-_, DEL, or a single character other than a comma. While a session is attached, jailoc holds back input that starts like the sequence until the next key decides. Typing the first key twice sends it once to the agent, as in tmux. Keys match also when the agent switches the terminal to an extended keyboard mode (xterm modifyOtherKeys or the kitty keyboard protocol): key release and repeat events, presses of a modifier key alone, and Caps Lock or Num Lock do not break the sequence.
detach_keys = "ctrl-a,d"
The jailoc v1 settings mode and password_mode are rejected at config load time. See jailoc v1 settings.
[vm]
Host runtime of the workspace VMs. All fields are optional.
| Field | Type | Default | Description |
|---|---|---|---|
runner |
string | (embedded) | Path to a jailoc-vm-runner (or go-microvm-runner) binary. When empty, jailoc unpacks the VM runtime (jailoc-vm-runner, libkrun, libkrunfw) embedded in release builds for darwin/arm64, linux/amd64 and linux/arm64 into ~/.cache/jailoc-vm/runtime/ on first use. Builds without an embedded runtime (other platforms, builds from source) require runner and lib_dir. Must be absolute; supports ~ expansion. |
lib_dir |
string | (none) | Directory containing libkrun and libkrunfw for a configured runner. Ignored when runner is empty. When empty with a configured runner, jailoc uses /opt/homebrew/lib on macOS if it exists, otherwise the system library path. Must be absolute; supports ~ expansion. |
builder |
string | "auto" |
Where workspace images are built: "auto" uses the host Docker daemon when it answers and runs Linux on the host's CPU architecture, otherwise the shared builder VM; "docker" requires the host daemon; "vm" always uses the shared builder VM. A workspace with isolated_build = true always uses its own builder VM. |
Example
[vm]
runner = "~/.local/bin/go-microvm-runner"
lib_dir = "/opt/homebrew/lib"
[base]
Global base image settings. Controls the base image used when no workspace or default image is set. See Image Resolution.
| Field | Type | Default | Description |
|---|---|---|---|
dockerfile |
string | (none) | Local path (/..., ~/...) or HTTP(S) URL to a Dockerfile for the base image. When set, takes priority over the embedded Dockerfile. Build failure is fatal. Maximum file size for HTTP sources: 1 MiB. |
Example
[base]
dockerfile = "https://example.com/Dockerfiles/custom"
[defaults]
Defaults applied to all workspaces.
| Field | Type | Default | Description |
|---|---|---|---|
backend |
string | "vm" |
Accepted values: "vm" or empty. "docker" is rejected as a jailoc v1 setting. |
agent |
string | "opencode" |
Coding agent of the workspaces: "opencode", "opencode1", "opencode2", "claude", "copilot", "omo", "agy" or a custom agent. See Coding agents. |
agents |
table | {} |
Settings per coding agent and custom agent definitions, keyed by agent name. See agents. |
image |
string | (none) | Pre-built image used as the base for workspace Dockerfile builds, or used directly when no workspace dockerfile is set. |
env |
string[] | [] |
Environment variables for every session, in KEY=VALUE format. |
env_file |
string[] | [] |
.env files loaded for all workspaces. Each file must exist at config load time. |
mounts |
string[] | [] |
Additional directory shares, host:container[:mode]. See mounts. |
snapshots |
string[] | [] |
Read-only snapshots of host files or directories. See snapshots. |
allowed_hosts |
string[] | [] |
Hostnames whose A and AAAA records are allowed through the network broker even when they resolve to private addresses. |
allowed_networks |
string[] | [] |
IPv4 and IPv6 CIDR ranges allowed through the network broker. |
ports |
(int | string)[] | [] |
Guest ports published on host 127.0.0.1. See ports. |
host_ports |
(int | string)[] | [] |
Host 127.0.0.1 ports or Unix sockets reachable from the guest. See host_ports. |
ssh_auth_sock |
bool | false |
Relay the host SSH agent ($SSH_AUTH_SOCK) into the VM at /run/ssh-agent.sock and copy ~/.ssh/known_hosts into it. |
gpg_agent |
bool | false |
Relay the host gpg-agent extra socket into the VM at /run/user/<uid>/gnupg/S.gpg-agent and import the public key of user.signingKey, or of all host secret keys when it is unset, into the VM keyring. Requires gpg in the image. |
git_config |
bool | true |
Copy the host Git configuration read-only into the VM: ~/.gitconfig, config, ignore and attributes from $XDG_CONFIG_HOME/git (default ~/.config/git), and the files they reference under the home directory. See git_config. |
import_agent_config |
bool | true |
Copy the coding agent's host configuration read-only into the VM, such as ~/.claude/CLAUDE.md or ~/.config/opencode. See import_agent_config. |
cpu |
float | 2.0 |
vCPUs of the workspace VM, rounded up. |
memory |
string | "4g" |
Memory of the workspace VM, at least 512m. |
docker_disk |
string | "20g" |
Size of the Docker data disk of an enable_docker workspace and of its isolated_build builder VM, at least 1g. The disk can grow but not shrink. |
timezone |
string | (host zone) | Time zone of the workspace VM, an IANA name such as "UTC" or "Europe/Prague". Omitted: the host's local zone. See timezone. |
enable_docker |
bool | false |
Run a Docker daemon inside the workspace VM. See enable_docker. |
protect_git |
bool | false |
Make .git/hooks, .git/config, config.worktree, linked worktree commondir pointers, core.hooksPath and included config files read-only inside the VM for repositories under workspace paths and read-write mounts, including symlinked workspace roots. Disable sudo and Docker there. |
isolated_build |
bool | false |
Build the workspace image in a builder VM of its own, with its own build cache and the workspace's network policy, instead of the host Docker daemon or the shared builder VM. |
network |
table | {} |
IPv6 and DNS settings of the network broker. See [defaults.network]. |
ca_bundle |
bool | string | true |
CA bundle added to the VM's system trust store. See ca_bundle. |
secrets |
table | {} |
Secrets for all workspaces. See secrets. |
Example
[defaults]
agent = "claude"
image = "myregistry.example.com/myteam/agent-base:v1.2.3"
env = ["GOPRIVATE=*.example.com"]
env_file = ["~/.config/jailoc/shared.env"]
allowed_hosts = ["internal-registry.example.com"]
allowed_networks = ["10.0.0.0/8", "fd12:3456::/48"]
ssh_auth_sock = true
cpu = 4.0
memory = "8g"
[defaults.agents.claude]
args = ["--model", "sonnet"]
[defaults.network]
Network settings of the workspace VM and its network broker. [workspaces.<name>.network] takes the same fields; a set workspace field wins.
| Field | Type | Default | Description |
|---|---|---|---|
ipv6 |
bool | false |
Give the guest IPv6 (fd00:127::2/64) and let the broker allow IPv6 destinations by the IPv6 policy. When off, the guest has IPv4 only and the broker denies every IPv6 flow. Changing it needs jailoc restart; jailoc reload turns it off at the broker right away, except in a VM started by a jailoc release without this setting: that VM keeps IPv6 until jailoc restart. |
dns |
string | (host resolver) | Routable numeric IPv4 resolver used for permitted DNS queries. |
rules |
table[] | [] |
Allow and deny egress rules ([[<scope>.network.rules]]). Combined across [defaults] and the workspace; deny always wins over allow. |
[[<scope>.network.rules]]
| Field | Type | Default | Description |
|---|---|---|---|
allow |
string[] | (none) | Hostnames, *.wildcard names, IP addresses, CIDRs, or "*" (public internet). Exactly one of allow or deny is required. |
deny |
string[] | (none) | Hostnames, *.wildcard names, IP addresses, CIDRs, or "*". Exactly one of allow or deny is required. |
proto |
string | "tcp" |
"tcp", "udp", or "any". |
ports |
(int | string)[] | (all) | Port numbers (1–65535) or "a-b" port ranges. |
name |
string | "" |
Label shown in jailoc traffic (rule=<name>) and validation errors. |
Implicit rules and built-in broker behavior
In addition to [[defaults.network.rules]] and [[workspaces.<name>.network.rules]], the broker applies:
rule=agent(allow,proto = "tcp",ports = [443]): appended automatically for the workspace's selected coding agent (or thefromagent of a custom agent). A matchingdenyrule still overrides it:
agent |
Implicit rule=agent allowlist (tcp/443) |
|---|---|
opencode, opencode1, opencode2, omo |
api.anthropic.com, api.openai.com, auth.openai.com, chatgpt.com, generativelanguage.googleapis.com, oauth2.googleapis.com, api.githubcopilot.com, *.githubcopilot.com, openrouter.ai, opencode.ai, *.opencode.ai, models.dev |
claude |
api.anthropic.com, claude.ai, console.anthropic.com, platform.claude.com |
copilot |
api.github.com, github.com, api.githubcopilot.com, *.githubcopilot.com, copilot-proxy.githubusercontent.com |
agy |
generativelanguage.googleapis.com, cloudcode-pa.googleapis.com, oauth2.googleapis.com, accounts.google.com |
rule=legacy(proto = "any"): synthesized in memory when deprecatedallowed_hosts,allowed_networksordns_blocked_zonesare set.- DoT and QUIC exclusion on
"*": a wildcard rule (allow = ["*"]) without explicitportsexcludes TCP port853(DNS over TLS) and UDP port443(QUIC/HTTP/3) so DNS stays on the gateway resolver and HTTPS clients fall back to TCP port443(where TLS SNI is inspected whenever hostname rules or hostnamedenyrules apply on that port) unless those ports are listed inports. - Direct DNS block: TCP and UDP port
53to any address other than the gateway (192.168.127.1) is denied (reason=dns-filtered). - Gateway services (
192.168.127.1): the broker serves DHCP, DNS (port53), SNTP clock sync (UDP port123), SSH/GPG agent relays (ssh_auth_sock,gpg_agent), and configuredhost_portson the gateway; all other connections to the gateway, guest subnet (192.168.127.0/24,fd00:127::/64), host interface addresses, and hard-denied ranges are always denied. - Default
dev-depsrule in new configs: when jailoc creates a missing~/.config/jailoc/config.toml, it writes an explicit[[defaults.network.rules]]entry nameddev-depsinto the file (GitHub, GitLab.com, npm, Yarn, Node.js, Bun, PyPI, Astral, Go proxy, crates.io, Rustup, Docker Hub, GHCR, Quay, GCR, MCR, Ubuntu/Debian/Alpine mirrors, and Nix caches). Removing or editing that block inconfig.tomlremoves or changes those rules.
Example
[defaults.network]
ipv6 = true
dns = "9.9.9.9"
[[defaults.network.rules]]
name = "dev-deps"
allow = ["github.com", "api.github.com", "*.githubusercontent.com", "registry.npmjs.org"]
[[defaults.network.rules]]
name = "no-paste"
deny = ["pastebin.com", "gist.github.com", "*.ngrok.io"]
[[workspaces.api.network.rules]]
name = "db"
allow = ["10.20.5.10"]
proto = "tcp"
ports = [5432]
[defaults.network]
ipv6 = true
dns = "9.9.9.9"
[[defaults.network.rules]]
name = "dev-deps"
allow = ["github.com", "api.github.com", "*.githubusercontent.com", "registry.npmjs.org"]
[[defaults.network.rules]]
name = "no-paste"
deny = ["pastebin.com", "gist.github.com", "*.ngrok.io"]
[[workspaces.api.network.rules]]
name = "db"
allow = ["10.20.5.10"]
proto = "tcp"
ports = [5432]
[workspaces.<name>]
Each workspace is a TOML table under [workspaces], keyed by name. Names must match ^[a-z0-9-]+$.
| Field | Type | Default | Description |
|---|---|---|---|
paths |
string[] | [] |
Host directories shared into the VM over virtio-fs at their original absolute paths. The first path is the working directory; with no paths, the working directory is /home/agent. |
backend |
string | (inherit) | "vm" or empty. "docker" is rejected as a jailoc v1 setting. |
agent |
string | (inherit) | Coding agent of the workspace. See Coding agents. |
agents |
table | {} |
Settings per coding agent; a set field overrides [defaults.agents.<name>]. See agents. |
image |
string | (none) | Pre-built image used directly, bypassing all build steps. Cannot be combined with dockerfile or build_context. |
dockerfile |
string | (none) | Local path (/..., ~/...) or HTTP(S) URL to an overlay Dockerfile built on top of the base image. Build failure is fatal. Maximum file size for HTTP sources: 1 MiB. |
build_context |
string | (none) | Build context of the overlay build. When empty, the Dockerfile's parent directory (local path) or a temporary directory (URL). |
mounts |
string[] | [] |
Directory shares for this workspace, merged with [defaults].mounts. |
snapshots |
string[] | [] |
Read-only snapshots, merged with [defaults].snapshots. |
allowed_hosts |
string[] | [] |
Merged with [defaults].allowed_hosts. |
allowed_networks |
string[] | [] |
Merged with [defaults].allowed_networks. |
ports |
(int | string)[] | [] |
Merged with [defaults].ports. |
host_ports |
(int | string)[] | [] |
Merged with [defaults].host_ports. |
env |
string[] | [] |
Overrides [defaults].env entries with the same key. |
env_file |
string[] | [] |
Loaded after [defaults].env_file. |
secrets |
table | {} |
Overrides default secrets with the same name. |
network |
table | {} |
Same fields as [defaults.network]; a set field wins. |
ssh_auth_sock, gpg_agent, git_config, import_agent_config, cpu, memory, docker_disk, timezone, enable_docker, protect_git, isolated_build, ca_bundle |
(inherit) | Same meaning as in [defaults]; the workspace value wins. |
Example
[workspaces.my-project]
paths = ["~/projects/my-project", "~/shared/libs"]
allowed_hosts = ["api.example.com"]
snapshots = ["~/.npmrc:/home/agent/.npmrc"]
ports = [3000, "8080:80"]
dockerfile = "~/projects/my-project/overlay.Dockerfile"
Coding agents
Each workspace runs one coding agent. Sessions start it in unattended mode, because the VM is the security boundary.
agent |
Agent | Package | Session command | Continue (-c) |
Resume (-s ID) |
Persistent home directories | Environment |
|---|---|---|---|---|---|---|---|
opencode2 |
OpenCode 2 | @opencode/cli |
opencode --standalone --auto |
--continue |
--session ID |
~/.local/share/opencode, ~/.local/state/opencode |
OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CLI_CONFIG_CONTENT |
opencode1 |
OpenCode 1 | opencode-ai |
opencode |
--continue |
--session ID |
~/.local/share/opencode, ~/.local/state/opencode, ~/.omo |
OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_TUI_CONFIG |
claude |
Claude Code | @anthropic-ai/claude-code |
claude --dangerously-skip-permissions --settings /etc/jailoc-tui-plugin/claude-settings.json |
--continue |
--resume ID |
~/.claude |
CLAUDE_CONFIG_DIR=/home/agent/.claude, DISABLE_AUTOUPDATER=1 |
copilot |
GitHub Copilot CLI | @github/copilot |
copilot --allow-all |
--continue |
--resume ID |
~/.copilot |
COPILOT_AUTO_UPDATE=false |
omo |
OmO | omo-ai |
omo --permission-preset full-access --extension /etc/jailoc-tui-plugin/omo.js |
--continue |
--session ID |
~/.omo |
PI_SKIP_VERSION_CHECK=1, OMO_RUNTIME=node |
agy |
Antigravity CLI | GitHub release google-antigravity/antigravity-cli |
agy --dangerously-skip-permissions |
--continue |
--conversation ID |
~/.gemini |
AGY_CLI_DISABLE_AUTO_UPDATE=1 |
opencode is an alias of opencode2: it runs the same harness and shares its persistent home directories, while agents.opencode and agents.opencode2 are separate tables. OpenCode 2 runs with --standalone, so each session has its own server and environment instead of sharing a background service.
Every agent shows the workspace name; OPENCODE_TUI_CONFIG, OPENCODE_CLI_CONFIG_CONTENT, --settings and --extension load the jailoc indicator, and for copilot and agy jailoc adds a statusLine to ~/.copilot/settings.json and ~/.gemini/antigravity-cli/settings.json when it sets none (and storeTokenPlaintext = true in ~/.copilot/settings.json when unset, so Copilot stores logins without a system keyring). See Workspace indicator.
The package versions are pinned by the jailoc release; version in agents installs another one. The session command line is: the command above, the continue or resume flag, then args from agents.
jailoc installs the agent with npm install -g, or for agy downloads the release asset of the VM's architecture (x64 or arm64, glibc), checks its pinned SHA-256 and installs the binary as root into /usr/local/bin, in a separate layer on top of the base image, before the workspace dockerfile overlay, in every image it builds: the base image, a [base] dockerfile, and defaults.image with a workspace dockerfile. An image used directly (workspace image, or defaults.image without a workspace dockerfile) must contain the agent binary. Before a session starts, jailoc checks that the binary is on PATH in the VM.
Changing agent or the agent's profile of a running workspace takes effect after jailoc restart; sessions fail until then.
agents
Settings per agent live in [defaults.agents.<name>] and [workspaces.<ws>.agents.<name>], where <name> is a built-in agent or a custom agent. Only the settings of the selected agent apply.
| Field | Type | Default | Description |
|---|---|---|---|
from |
string | Custom agents only: the built-in agent it is derived from. | |
install |
bool | true |
Install the agent in the images jailoc builds. With false, the image must provide the binary, for example through the workspace dockerfile. |
version |
string | (pinned) | npm or release version installed instead of the version pinned by jailoc, e.g. "2.1.400". Must be an exact version; dist-tags such as latest are rejected. For agy, jailoc reads the checksums of another version's assets from the GitHub API. |
args |
string[] | [] |
Arguments appended to every session command line. A workspace list replaces the [defaults] list; args = [] clears it. |
mounts |
string[] | [] |
Mounts added while the agent is selected, same syntax and validation as mounts. An empty host (":/home/agent/.agents") removes a mount added by the agent or an earlier table. See Agent mounts. |
profile |
false or string |
(unset) | Where the agent's persistent home directories live. Unset: per workspace. false: per workspace, emptied on every VM start. A name: shared profile ~/.local/share/jailoc/profiles/<name>. See Agent profiles. |
[workspaces.my-project]
paths = ["~/projects/my-project"]
agent = "copilot"
[workspaces.my-project.agents.copilot]
version = "1.0.110"
args = ["--model", "gpt-5"]
Custom agents
An agents table whose name is not a built-in agent defines a custom agent: the built-in agent named by from under another name, with its own settings. Select it with agent like a built-in one. It is installed, started and persisted like the agent it is derived from.
[defaults.agents.claude]
version = "2.1.400"
[defaults.agents.claude-opus]
from = "claude"
args = ["--model", "opus"]
[workspaces.my-project]
paths = ["~/projects/my-project"]
agent = "claude-opus"
A custom agent defined in [defaults] can be selected and configured in any workspace; one defined in a workspace only there. Each field except mounts and profile takes the first value set in: the workspace's table of the agent, the defaults' table of the agent, then, for a custom agent, the workspace's and the defaults' table of the agent it is derived from. In the example, claude-opus installs Claude Code 2.1.400.
Agent mounts
The mounts of the selected agent are merged by guest path; a later entry replaces an earlier one with the same guest path, an empty host removes it. The order is:
- the built-in mounts of the agent's harness, including
~/.agents:/home/agent/.agents:ro mountsof the built-in agent's table in[defaults], then in the workspace- for a custom agent,
mountsof its own table in[defaults], then in the workspace [defaults].mounts, then the workspace'smounts
[defaults.agents.opencode-legacy]
from = "opencode"
mounts = ["~/.config/opencode-legacy:/home/agent/.config/opencode"]
A mount at the destination of an import_agent_config copy replaces the copy.
Agent profiles
profile selects where the agent's persistent home directories (see Coding agents) are stored. It is read from the selected agent's own tables: the workspace's, then the defaults'. A custom agent does not inherit profile from the agent it is derived from.
| Value | Storage | Lifetime |
|---|---|---|
| unset | ~/.cache/jailoc-vm/workspaces/<workspace>/guest/home/<dir> |
until jailoc down --purge |
false |
per workspace | emptied on every VM start |
"<name>" |
~/.local/share/jailoc/profiles/<name>/<dir> |
until deleted by hand; jailoc down --purge keeps it |
<dir> is the name of the harness's state directory (opencode2-data, opencode2-state for OpenCode 2, opencode-data, opencode-state for OpenCode 1, plus opencode1-omo for the OmO plugin's ~/.omo, claude, copilot, omo, antigravity), so one profile can hold the state of several harnesses, and two agents of the same harness share it when they name the same profile. ~/.cache stays per workspace. A mount over a persistent home directory replaces it.
A named profile is used by one running workspace at a time: jailoc up and jailoc restart fail while another workspace using the profile is running.
Warning
Every workspace using a named profile can change what the harness executes in the others (hooks, plugins, MCP servers, instructions stored in its state directory), read the login and resume the sessions stored there. Share a profile only between workspaces of the same trust level. See Threat Model.
[workspaces.old-project.agents.opencode]
profile = "opencode-legacy"
Validation Rules
jailoc v1 settings
These settings fail config load with <setting> is a jailoc v1 setting; run `jailoc migrate` to update the config:
backend = "docker"in[defaults]or a workspace- top-level
modeandpassword_mode expose_portin[defaults]or a workspace
Any backend value other than "vm" and empty is rejected. See Migrate from jailoc v1.
Moved network settings
filtered_dns, dns_upstream and dns_blocked_zones directly in [defaults] or a workspace fail config load with [defaults]: filtered_dns moved to the [defaults.network] table; run `jailoc migrate` to update the config (or the workspace's network table). jailoc migrate moves them.
paths
~ is expanded. An empty list is allowed; empty entries are rejected. Paths equal to or under these prefixes are rejected: /home/agent, /usr, /etc, /var, /bin, /sbin, /lib, /lib64, /opt, /root, /proc, /sys, /dev, /run, /tmp, /certs.
Several workspaces may share a path, for example to run different agents in the same project. When the current directory matches several workspaces at the same depth, jailoc fails and asks for -w.
Each path is a separate virtio-fs share. A VM boots with at most 11 shares on linux/amd64 hosts and 120 on arm64 hosts (macOS and Linux), one fewer with enable_docker. Paths, mounts with a host directory and the state directories of a named agent profile count towards the limit; jailoc up and jailoc reload fail above it. List a common parent directory instead of many sibling directories.
mounts
Format: host:container[:mode], mode ro or rw (default rw). Mounts are virtio-fs directory shares.
host: absolute or~path. Must be a directory; a file mount fails atjailoc up. Usesnapshotsfor files. An empty host (":/home/agent/.agents") removes an inherited mount with that destination.container: absolute, or~/..., where~stands for the guest home/home/agent. Rejected when equal to or under/,/usr,/etc,/var,/bin,/sbin,/lib,/lib64,/opt,/root,/proc,/sys,/dev,/run,/tmp,/certs. Destinations under/home/agent/...are allowed.
Host paths equal to or under these entries are rejected: /, /boot, /dev, /etc, /private, /proc, /sys, /run, /var, ~/.ssh, ~/.gnupg, ~/.aws, ~/.azure, ~/.config/gcloud, ~/.config/gh, ~/.docker, ~/.kube, ~/.netrc, ~/.config/jailoc, ~/.cache/jailoc-vm. Parents of the entries under ~ are rejected too, so ~, ~/.config and ~/.cache cannot be shared whole. Entries under ~ are also compared after resolving symlinks, so a symlink to ~/.ssh, or a dotfiles directory that ~/.ssh links into, is rejected as well. The error explains why the matched entry is protected; for credential directories it shows SSH or GPG agent forwarding or a file secret holding the one file a tool needs, and for ~/.config/jailoc and ~/.cache/jailoc-vm it suggests keeping shared directories outside them or using an agent profile. See Pass a credential file.
snapshots
Format: host:container, optionally :ro; :rw is rejected because snapshots are read-only. Host and destination rules are the same as for mounts, except that the host may be a file or a directory. An empty host removes an inherited snapshot.
jailoc copies the sources when the VM starts and again on every jailoc reload, which also applies added and removed entries. At jailoc up and jailoc reload, the destination must not equal, contain or lie inside a shared path (workspace paths, mounts destinations, the persistent home directories), and the source must exist. A symlink given as the source root is resolved; symlinks inside a copied directory are copied unchanged.
git_config
When true, jailoc adds read-only snapshots of the host Git configuration at jailoc up and jailoc reload:
| Host | Guest |
|---|---|
~/.gitconfig |
/home/agent/.gitconfig |
$XDG_CONFIG_HOME/git/config |
/home/agent/.config/git/config |
$XDG_CONFIG_HOME/git/ignore |
/home/agent/.config/git/ignore |
$XDG_CONFIG_HOME/git/attributes |
/home/agent/.config/git/attributes |
$XDG_CONFIG_HOME defaults to ~/.config. Missing files are skipped. Other files in the directory are not copied. The Git credential stores ~/.git-credentials, $XDG_CONFIG_HOME/git/credentials and ~/.config/git/credentials are never copied, whichever path or link names them.
Files referenced by the copied configuration files are copied to the same path relative to /home/agent when they lie under the host home directory: include.path and includeIf.<condition>.path (followed recursively, relative paths resolved against the including file; the included file must stay under the home directory or $XDG_CONFIG_HOME), core.excludesFile, core.attributesFile, commit.template, gpg.ssh.allowedSignersFile, and user.signingKey when its value is a path. The home directory check uses the path as written, so a symlink under the home directory may point elsewhere, such as a dotfiles checkout or the Nix store. The credential checks apply to both the path as written and the file it resolves to, including hard links to files in ~/.ssh, ~/.gnupg and ~/.aws and in the directories symlinks there point to, and the copy holds the content that was checked. A referenced file that is missing, outside the home directory, not a regular file of at most 1 MiB, a Git credential store, in ~/.ssh, ~/.gnupg or ~/.aws, or whose path in the VM is already taken by a different file is not copied and produces a warning. gpg.ssh.allowedSignersFile and user.signingKey are exceptions to the directory rule but copy public data only: the user.signingKey copy holds the first SSH public key in the file, and the gpg.ssh.allowedSignersFile copy holds its signer lines (principals, the cert-authority, namespaces, valid-after and valid-before options, and the public key) without comments. A user.signingKey file without an SSH public key, or an allowed signers file with any other line, is not copied and produces a warning. At most 256 configuration files are read.
Configuration files are copied with two rewrites: values of the path variables above that lie under the host home directory become ~/..., and includeIf "gitdir:~/..." and includeIf "gitdir/i:~/..." conditions use the host home directory, which workspace paths keep inside the VM.
Each copy is a default snapshot: a snapshots entry with the same destination replaces it, an entry with an empty host removes it, and a directory snapshot that contains its destination replaces it.
import_agent_config
When true, jailoc adds read-only snapshots of the selected agent's host configuration at jailoc up and jailoc reload:
| Agent | Host, copied to the same path under /home/agent |
|---|---|
opencode, opencode2 |
~/.config/opencode, without node_modules |
opencode1 |
~/.config/opencode, without node_modules; ~/.omo/omo.jsonc, ~/.omo/omo.json |
claude |
~/.claude/CLAUDE.md, ~/.claude/RTK.md, ~/.claude/settings.json, ~/.claude/agents, ~/.claude/commands, ~/.claude/hooks, ~/.claude/skills, ~/.claude/output-styles |
copilot |
~/.copilot/copilot-instructions.md, ~/.copilot/agents, ~/.copilot/hooks, ~/.copilot/skills, ~/.copilot/mcp-config.json |
agy |
~/.gemini/GEMINI.md |
omo |
~/.omo/omo.jsonc, ~/.omo/omo.json |
Missing sources are skipped. Logins, sessions and the agents' own config.json and settings.json of copilot stay in the persistent state directory. The OpenCode copy gets the .gitignore OpenCode would write (unless the host directory has one), and OpenCode doesn't install the dependencies of local plugins in the read-only directory: plugins listed in plugin still install into ~/.cache, but a local plugin that imports a package needs it installed in the image.
The copies inside a persistent state directory (~/.claude, ~/.copilot, ~/.omo, ~/.gemini) hide what is there in the VM: a skill the agent installs into ~/.claude/skills is not visible while that directory is copied. When the snapshot is dropped, the empty mount points it left in the state directory are removed at the next start.
Each copy is a default snapshot like the Git configuration: a snapshots entry with the same destination replaces it, an entry with an empty host removes it (snapshots = [":/home/agent/.claude/settings.json"]), and a directory snapshot that contains its destination replaces it. A copy whose destination equals, contains or lies inside a shared path, such as a mount of ~/.claude or a named profile state directory, is skipped.
ports
Each entry is a port number (3000: guest 3000 on host 3000) or "HOST:GUEST" ("8080:80": guest 80 on host 8080), with ports 1 to 65535. A host port may appear only once per list. A workspace entry replaces a [defaults] entry with the same host port. Ports are published on host 127.0.0.1 only.
host_ports
Each entry makes a host service reachable from the guest:
| Entry | Guest port | Host target |
|---|---|---|
9222 |
9222 | 127.0.0.1:9222 |
"8931:3845" |
8931 | 127.0.0.1:3845 |
"7100:/tmp/mcp.sock" |
7100 | Unix socket /tmp/mcp.sock (absolute or ~/) |
Ports are 1 to 65535. A guest port may appear only once per list; a workspace entry replaces a [defaults] entry with the same guest port. Guest ports 22, 53, 2375, 7022 and 7023 are reserved.
Inside the guest each port is reachable on the gateway 192.168.127.1, on the names host.jailoc.internal and host.docker.internal, and on 127.0.0.1 and [::1] when nothing in the guest already listens on that port. TCP targets are always host loopback. A Unix socket that does not exist yet produces a warning; connections fail until it exists.
jailoc warns when an entry targets port 9222 (Chrome DevTools Protocol) or a container runtime socket (docker.sock, containerd.sock, podman.sock, buildkitd.sock).
Warning
Host services reached through host_ports bypass the network policy. Anything in the guest gets the same access to them as a local process on the host.
allowed_hosts and allowed_networks
allowed_networks entries must parse with Go's net.ParseCIDR; IPv4 and IPv6 CIDRs are accepted. allowed_hosts entries are resolved on the host for A and AAAA records, and each address is allowed as a /32 or /128. Some IPv6 ranges stay denied regardless of these lists; see Network isolation.
Filtered DNS
When network.filtered_dns = true, an effective dns_upstream and at least one effective dns_blocked_zones entry are required. dns_upstream must be a numeric IPv4 address other than unspecified, loopback, link-local or multicast. Each zone must be a DNS name of labels with letters, digits or internal hyphens (63 characters per label, 253 total); a trailing dot is accepted and "." is rejected. Queries for names in blocked zones get NXDOMAIN, matched case-insensitively including subdomains, and so do answers that name a blocked zone anywhere, for example through a CNAME. Queries with more or fewer than one question are refused. Direct DNS to port 53 of other servers is blocked.
With filtering on, an effective allowed_hosts entry equal to or under an effective blocked zone is rejected at config load.
ca_bundle
- Omitted or
true: automatic discovery. The non-emptySSL_CERT_FILEenvironment variable is used first, thenNIX_SSL_CERT_FILE. If the selected variable points at an invalid source,jailoc upfails without trying the next one. If neither is set, no bundle is forwarded. false: no bundle.- Non-empty string: custom bundle path, absolute or
~. Symlinks are resolved; the target must be a readable regular file with at least oneCERTIFICATEPEM block and no private-key PEM block. - Empty string or any other TOML type: rejected at config load.
The bundle is added to the VM's system trust store, which its Docker daemon also uses, and to the builder VM's Docker daemon. Sessions get NODE_USE_SYSTEM_CA=1 when a bundle is forwarded.
enable_docker
With enable_docker = true, jailoc starts dockerd inside the workspace VM; an image without dockerd fails jailoc up. enable_docker = true together with protect_git = true is rejected, because Docker access is root access inside the VM; protect_git alone turns Docker off. In the images jailoc builds, Docker is installed only when enable_docker is on; see Docker layer.
cpu, memory and docker_disk
cpu must be a finite number greater than 0. memory and docker_disk must match ^[1-9][0-9]*[kmgKMG]?$ (for example 512m, 4g). At jailoc up, memory must be at least 512m and docker_disk at least 1g; docker_disk is rounded up to whole MiB. When the existing data disk is larger than docker_disk, jailoc up fails instead of shrinking it.
timezone
timezone must match ^[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+)*$; absolute paths and POSIX TZ rules are rejected. Without it, the VM takes the host's zone from TZ when set, otherwise from /etc/localtime. jailoc-guest points /etc/localtime at the zone in the image's /usr/share/zoneinfo, or copies the host's data for that zone when the image lacks it, and writes the name to /etc/timezone. A zone that neither the image nor the host has leaves the image's zone in place. jailoc reload applies a changed timezone to a running VM.
image, dockerfile, build_context
image cannot be combined with dockerfile or build_context, and must not be whitespace-only. dockerfile accepts absolute paths, ~ paths and http/https URLs with a non-empty host (http:///path is rejected). Relative paths and other schemes are rejected.
agent and agents
agent must be opencode, opencode1, opencode2, claude, copilot, omo, agy or a custom agent defined in the workspace's or the defaults' agents. A key of agents that is not a built-in agent is a custom agent: its name must match ^[a-z0-9-]+$ and from, set in that table or in the defaults' table of the same name, must be a built-in agent (custom agents cannot be derived from custom agents). from is rejected on built-in agents. version must be an exact version (1.2.3, optionally with a -prerelease and +build suffix). mounts follows the rules of mounts. profile must be false or a name matching ^[a-z0-9-]+$; true is rejected.
env
Each entry must be KEY=VALUE with a non-empty key. Reserved keys, which jailoc sets itself: JAILOC, JAILOC_WORKSPACE, NODE_USE_SYSTEM_CA, SSH_AUTH_SOCK, and the agent variables listed in Coding agents for every agent: OPENCODE_DISABLE_AUTOUPDATE, OPENCODE_TUI_CONFIG, OPENCODE_CLI_CONFIG_CONTENT, CLAUDE_CONFIG_DIR, DISABLE_AUTOUPDATER, COPILOT_AUTO_UPDATE, PI_SKIP_VERSION_CHECK, OMO_RUNTIME, AGY_CLI_DISABLE_AUTO_UPDATE.
env_file
Paths must be absolute or start with ~, and the files must exist at config load time. Format: KEY=VALUE lines, # comments, empty lines ignored, quoted values allowed, inline comments stripped. Values are literal; no host variable expansion. Entries are checked against the reserved keys.
secrets
Secrets live under [defaults.secrets.env.<NAME>], [defaults.secrets.file.<NAME>], [workspaces.<ws>.secrets.env.<NAME>] or [workspaces.<ws>.secrets.file.<NAME>]. The sub-table sets the destination, the field sets the source.
| Field | Type | Description |
|---|---|---|
from_env |
string | Host environment variable to read. Must match ^[A-Za-z_][A-Za-z0-9_]*$. |
from_file |
string | Host file to read. Absolute or ~ path. |
from_command |
string | Command run on the host with /bin/sh -c; its standard output is the value. Must not be blank. |
ttl |
string | Duration (time.ParseDuration, e.g. "15m", "1h") for which from_command output is cached in the host system keychain per workspace. Only valid with from_command; must be positive. |
Exactly one of from_env, from_file and from_command must be set.
| Destination | <NAME> rule |
In the guest |
|---|---|---|
secrets.env.<NAME> |
^[A-Za-z_][A-Za-z0-9_]*$; not HOME, PATH or a reserved env key |
exported as <NAME> in session environments |
secrets.file.<NAME> |
^[a-zA-Z0-9_-]+$ |
/run/secrets/<NAME> |
A name may not appear in both secrets.env and secrets.file of the same scope.
At jailoc up, jailoc reload and jailoc add, jailoc reads every source on the host: a from_env variable must be set and non-empty, a from_file path must be a regular file, and a from_command must exit with status 0, print something and finish within two minutes. Commands run with the terminal attached and JAILOC_WORKSPACE set to the workspace name. When ttl is set on a from_command secret, jailoc caches the command output in the host system keychain (macOS login keychain or Linux kernel user keyring) for that duration, keyed by configuration file, workspace, secret destination, secret name and command; jailoc reload --purge-cache evicts the cached entries and runs the commands again, and jailoc down --purge deletes them. Both fail when the keychain refuses to delete an entry and keep it recorded, so the next purge retries it. Env secret values lose trailing \r and \n characters and must not contain line breaks or NUL. Values travel to the guest over SSH; file secrets are written to /run/secrets (tmpfs, mode 0400, owned by the agent) and env secrets go to the session environment only. jailoc never writes them to plain files on host disk. jailoc reload re-reads and re-pushes secrets and removes files of secrets no longer configured; running sessions keep their old environment. See the secrets how-to.
Merge Semantics
Environment variables merge in this order, later wins per key:
[defaults].env[defaults].env_file(in list order)- workspace
env_file(in list order) - workspace
env
allowed_hosts and allowed_networks are unions of the defaults and workspace lists, deduplicated in order.
mounts merges three layers keyed by destination: built-in default mounts of the workspace's agent, [defaults].mounts, workspace mounts. The last layer to set a destination wins; an empty host removes it. Built-in default mounts:
| Agent | Destination | Host source | Mode |
|---|---|---|---|
| all | /home/agent/.agents |
~/.agents |
ro |
The agent's configuration is copied instead of mounted; see import_agent_config. The OmO plugin (oh-my-openagent) of opencode1 writes its configuration, state and lock files to ~/.omo, which is a persistent home directory of the workspace. To share the host's ~/.omo instead, mount it: [defaults.agents.opencode1] mounts = ["~/.omo:/home/agent/.omo:rw"].
snapshots merges [defaults].snapshots and workspace snapshots keyed by destination. git_config, import_agent_config and ssh_auth_sock add lowest-priority snapshots (the Git configuration files, the agent configuration, /home/agent/.ssh/known_hosts), which a snapshots entry for the same destination replaces or, with an empty host, removes.
ports merges by host port; the workspace entry wins. host_ports merges by guest port; the workspace entry wins.
secrets merges by name. A workspace secret replaces a default secret with the same name entirely, also across env and file. An inherited default secret can't be unset in a workspace.
Scalar settings (agent, ssh_auth_sock, gpg_agent, git_config, import_agent_config, cpu, memory, docker_disk, timezone, enable_docker, protect_git, isolated_build, ca_bundle, and network.ipv6, network.filtered_dns) take the workspace value when set, otherwise [defaults], otherwise the built-in default. The same applies to each field of agents.<name>; a custom agent then falls back to the tables of the agent it is derived from (see Custom agents). agents.<name>.mounts merges instead (see Agent mounts), and profile is not inherited from the derived-from agent. An empty workspace network.dns_upstream and an omitted network.dns_blocked_zones inherit the defaults; a set dns_blocked_zones replaces the default list.
Persistent state
~/.cache/jailoc-vm/workspaces/<workspace>/guest/home keeps the agent's persistent home directories (see Coding agents) and ~/.cache across restarts. jailoc down --purge deletes it. An agent profile moves the agent's directories to a shared profile or makes them ephemeral.
See the workspace configuration how-to for step-by-step setup.