# CLI reference: network, security, and agent commands

> Syntax, subcommands, and flags for devvm network, secret, agent, git-protect, clone, ssh-agent, link, and group.

- Repository: devvmhq/devvm
- GitHub: https://github.com/devvmhq/devvm
- Human docs: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853
- Complete Markdown: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853/llms-full.txt

## Source Files

- `internal/cli/network.go`
- `internal/cli/secret.go`
- `internal/cli/agent.go`
- `internal/cli/agentlogin.go`
- `internal/cli/gitprotect.go`
- `internal/cli/sshagent.go`
- `internal/cli/link.go`
- `internal/cli/group.go`

---

---
title: "CLI reference: network, security, and agent commands"
description: "Syntax, subcommands, and flags for devvm network, secret, agent, git-protect, clone, ssh-agent, link, and group."
---

The network, security, and agent commands in `internal/cli` configure per-VM network confinement tiers, host-injected secrets, coding agent credentials and settings stores, `.git` hook and config immutability locks, isolated repository clones, SSH agent forwarding, and inter-VM port links and groups. Commands that target the current project folder's VM resolve the VM through `vm.Env` and update either the VM's state file (`~/.local/share/devvm/vms/<hash>/vm.json`) or host-wide stores under `~/.config/devvm` and `~/.local/share/devvm`.

## Command inventory and shared conventions

All commands in this group share global persistent flags, exit codes, and—for commands that operate on a project folder's VM—folder-resolution flags.

| Command | Subcommands | Scope | Supports `--json` |
| --- | --- | --- | --- |
| `devvm network` | `allow`, `deny`, `rm`, `log` | Project VM or host default (`-g`) | `network`, `network -g`, `network log` |
| `devvm secret` | `set`, `rm`, `use`, `unuse` | Host secret store and project VM bindings | `secret` (root only) |
| `devvm agent` | `login`, `import`, `save` | Host agent store and project VM | No |
| `devvm git-protect` | None | Project VM (`.git` locks on host) | `git-protect` (when showing) |
| `devvm clone` | None | Project VM (remake with/without repo clone) | `clone` (when showing) |
| `devvm ssh-agent` | `add`, `rm` | Project VM forwarding mode and `devvm` SSH agent | `ssh-agent` (when showing) |
| `devvm link` | `add`, `rm`, `publish`, `unpublish` | Project VM links and published ports | `link`, `link publish` (when showing) |
| `devvm group` | `join`, `leave` | Project VM group membership and auto-links | `group` (root only) |

### Shared flags and exit codes

<ParamField body="--verbose, -v" type="boolean" default="false">
  Inherited global flag on all commands. Stream the full output of external commands `devvm` runs instead of displaying a single status line.
</ParamField>

<ParamField body="--no-input" type="boolean" default="false">
  Inherited global flag on all commands. Never prompt interactively; fail immediately where `devvm` would ask a question and name the flag that answers it (such as `--force` or `--stdin`).
</ParamField>

<ParamField body="--here" type="boolean" default="false">
  Available on commands that act on the current folder's VM. Use this folder's own VM, never inheriting the VM of an enclosing parent folder. Mutually exclusive with `--parent` and `--global` (`-g`).
</ParamField>

<ParamField body="--parent" type="boolean" default="false">
  Available on commands that act on the current folder's VM. Use the VM of the nearest enclosing parent folder that has one, even across a nested Git repository boundary. Mutually exclusive with `--here` and `--global` (`-g`).
</ParamField>

<ParamField body="--json" type="boolean" default="false">
  Available on inspection and listing commands. Print output as a single indented JSON object with `"version": 1` as the first key. On commands that both show a setting when run bare and change it when given an argument or mutating flag (`network`, `git-protect`, `clone`, `ssh-agent`, `link publish`), passing `--json` together with a positional value or mutating flag is refused as a usage error (`showsOnly`).
</ParamField>

| Exit code | Meaning |
| --- | --- |
| `0` | Command succeeded, or an interactive confirmation prompt was declined cleanly. |
| `1` | Runtime failure, backend error, layout version check failure, or non-interactive prompt refusal (`--no-input`). |
| `2` | Usage error: unknown command or flag, invalid argument or flag value, blank string argument/flag (`noBlanks`), or mutually exclusive flags combined. |
| `130` | Interrupted by `SIGINT`, `SIGTERM`, or `SIGHUP`. |

```mermaid
flowchart LR
  subgraph CLI["internal/cli Commands"]
    NetCmd["network / secret"]
    AgentCmd["agent (login, import, save)"]
    IsoCmd["git-protect / clone / ssh-agent"]
    LinkCmd["link / group"]
  end

  subgraph Services["internal/vm Services"]
    VMNet["vm.Net"]
    VMAgent["vm.AgentLogin / AgentImport / AgentSave"]
    VMSettings["vm.Settings / vm.Lifecycle"]
    VMLinks["vm.Links / vm.Groups"]
  end

  subgraph Storage["Host Stores & Per-VM State"]
    PolicyFiles["~/.config/devvm/policy\n~/.local/share/devvm/vms/<hash>/policy"]
    SecretStore["macOS Keychain / ~/.local/share/devvm/secrets\n~/.local/share/devvm/logins/<agent>"]
    AgentStore["~/.config/devvm/agents/<agent>\n~/.local/share/devvm/agent-keys.json"]
    VMState["~/.local/share/devvm/vms/<hash>/vm.json\n(net, secrets, git, clone, agent, links)"]
  end

  NetCmd --> VMNet
  AgentCmd --> VMAgent
  IsoCmd --> VMSettings
  LinkCmd --> VMLinks

  VMNet --> PolicyFiles
  VMNet --> SecretStore
  VMNet --> VMState
  VMAgent --> SecretStore
  VMAgent --> AgentStore
  VMSettings --> AgentStore
  VMSettings --> VMState
  VMLinks --> VMState
```

---

## `devvm network`

Show or change the network confinement tier of the current folder's VM, manage allow and deny policy rules, or inspect the proxy connection log.

```bash
devvm network [open|internet|allowlist|none] [-g] [--json] [--here | --parent]
```

### Network tiers and transitions

| Tier | Traffic behavior | Transition behavior on a running VM |
| --- | --- | --- |
| `allowlist` | Default for new VMs. Allows built-in base, shared-tool, and layer policies plus user and per-VM rules. Denies beat allows. | Switching between `allowlist`, `internet`, and `none` reloads the host proxy (`devvm _proxy`) immediately without restarting the VM. |
| `internet` | Allows any public host on any port (`network/internet.policy`) plus secret hosts; only deny rules (`!rule`) from user and layer policy files apply. | Immediate proxy reload when switching to or from `allowlist` or `none`. |
| `none` | Allows only the token endpoints and API hosts required by the VM's installed coding agents and bound secrets; only deny rules from user and layer policy files apply. | Immediate proxy reload when switching to or from `allowlist` or `internet`. |
| `open` | Unconfined direct networking without forcing traffic through the host policy proxy. | Switching into or out of `open` takes effect on the VM's next start; in an interactive terminal, `devvm` offers to restart a running VM immediately. |

<Note>
  When `devvm network` (or `devvm ls`) inspects a running VM whose confined tier failed its post-boot confinement self-check, it reports the policy as `unenforced` instead of the tier name. Changing a VM's tier is refused if the VM is currently in a group (`devvm group`) or if the new tier would violate the tier invariant of an active link (`target.tier >= client.tier`).
</Note>

### Root flags

<ParamField body="--global, -g" type="boolean" default="false">
  Show or change the default network tier saved in `settings.json` for newly created VMs instead of acting on the current folder's VM. Fails if `network` is pinned in `~/.config/devvm/config.toml`. Mutually exclusive with `--here` and `--parent`.
</ParamField>

<ParamField body="--json" type="boolean" default="false">
  Output the current VM's policy and ordered rule files (or, with `-g`, the default tier and its config source) as version 1 JSON. Refused when a tier argument is provided.
</ParamField>

<ParamField body="--here" type="boolean" default="false">
  Target this folder's own VM.
</ParamField>

<ParamField body="--parent" type="boolean" default="false">
  Target the nearest enclosing parent folder's VM.
</ParamField>

### Subcommands

#### `devvm network allow`

```bash
devvm network allow RULE... [-g] [--here | --parent]
```

Add one or more allow `RULE` entries to this folder's VM policy file (`~/.local/share/devvm/vms/<hash>/policy`), or with `-g` (`--global`) to the user-wide policy file (`~/.config/devvm/policy`). Changes apply on the proxy's next connection.

- **Rule syntax (`RULE`):**
  - Exact hostname: `example.com` (matches ports `443` and `80` by default).
  - Subdomain wildcard: `'*.example.com'`.
  - Any host wildcard: `'**'`.
  - IPv4/IPv6 address or CIDR block: `10.0.0.5` or `10.0.0.0/8`.
  - Port-qualified rule: `example.com:8443` or `10.0.0.5:5432` (matches only that port instead of `443` and `80`).
- Passing a rule prefixed with `!` to `devvm network allow` is refused with a usage error.

#### `devvm network deny`

```bash
devvm network deny RULE... [-g] [--here | --parent]
```

Add one or more deny rules to the VM's policy file, or with `-g` (`--global`) to `~/.config/devvm/policy`. `devvm` automatically prepends `!` if not already present. Deny rules override any allow rule across all confined tiers (`allowlist`, `internet`, and `none`) from the next connection onward.

#### `devvm network rm`

```bash
devvm network rm RULE... [-g] [--here | --parent]
```

Remove matching rules from the VM's own policy file, or with `-g` (`--global`) from `~/.config/devvm/policy`. To remove a deny rule, include its leading `!` (for example, `devvm network rm '!example.com'`).

#### `devvm network log`

```bash
devvm network log [-n N] [--follow] [--blocked] [--json] [--here | --parent]
```

Display connection and request events recorded by the VM's host proxy and inter-VM link sessions, ordered oldest first.

<ParamField body="--lines, -n" type="uint" default="50">
  Number of most recent connection and request events to show. Mutually exclusive with `--blocked`.
</ParamField>

<ParamField body="--follow" type="boolean" default="false">
  Stream new log events as they occur until interrupted. Mutually exclusive with `--blocked` and `--json`.
</ParamField>

<ParamField body="--blocked" type="boolean" default="false">
  Aggregate blocked connections by host and port, showing the last timestamp, block count, and the `devvm network allow` command that permits each host. Mutually exclusive with `--follow` and `--lines`.
</ParamField>

<ParamField body="--json" type="boolean" default="false">
  Print log events (`{"version": 1, "events": [...]}`) or blocked hosts (`{"version": 1, "blocked": [...]}`) as JSON. Mutually exclusive with `--follow`.
</ParamField>

### Examples and `--json` payloads

<CodeGroup>
```bash title="Manage tiers and rules"
devvm network
devvm network internet
devvm network -g none
devvm network allow example.com '*.example.com' 10.0.0.5:5432
devvm network deny -g '*.telemetry.example.com'
devvm network rm '!*.telemetry.example.com'
devvm network log --blocked
```

```json title="devvm network --json"
{
  "version": 1,
  "policy": "allowlist",
  "allowlist": [
    {
      "builtin": "layers/base.policy",
      "rules": [
        "archive.ubuntu.com",
        "security.ubuntu.com"
      ],
      "not_rules": []
    },
    {
      "file": "/Users/alice/.config/devvm/policy",
      "denies_only": false,
      "rules": [
        "example.com"
      ],
      "not_rules": [
        {
          "line": 2,
          "text": "invalid rule line"
        }
      ]
    }
  ]
}
```

```json title="devvm network log --blocked --json"
{
  "version": 1,
  "blocked": [
    {
      "last": "2026-10-01T12:00:00Z",
      "count": 3,
      "host": "registry.npmjs.org",
      "port": 443,
      "allow": "devvm network allow registry.npmjs.org"
    }
  ]
}
```
</CodeGroup>

---

## `devvm secret`

Manage host-stored tokens and bind them to project VMs. A bound VM receives a per-VM salted placeholder in its environment, and its host proxy substitutes the real secret into HTTPS (`443`) requests addressed to the secret's allowed hosts.

```bash
devvm secret [--json]
```

### Known secret names

Every `devvm secret` subcommand validates `NAME` against `secrets.Known`:

| `NAME` | Purpose | Guest environment variable | Allowed target hosts (`443` only) | VM binding behavior |
| --- | --- | --- | --- | --- |
| `claude` | Long-lived token printed by `claude setup-token` (must start with `sk-ant-oat01-`). | `CLAUDE_CODE_OAUTH_TOKEN` (placeholder prefix `sk-ant-oat01-devvm-proxy-managed-<salt>`) | `api.anthropic.com` | Automatically bound on any VM whose shared tools include `claude`. Can also be bound with `secret use claude`. |
| `github` | GitHub personal access token or `gh auth token`. | `GH_TOKEN` (placeholder prefix `devvm-proxy-managed-github-<salt>`) | `api.github.com`, `github.com`, `codeload.github.com`, `raw.githubusercontent.com` | Opt-in per VM via `devvm secret use github`. |
| `proxy` | Password for the upstream corporate proxy configured in `config.toml`. | None (`HostOnly: true`) | Sent only by `devvm _proxy` to the upstream corporate proxy | Never bound to or exposed inside any VM; `secret use proxy` and `secret unuse proxy` are refused. |

<Info>
  Secret values are stored in the macOS Keychain under service `devvm` (or `$DEVVM_SECRET_SERVICE`), or in `0600` files under `~/.local/share/devvm/secrets/<name>` on hosts without Keychain support. Saving a `claude` token removes any stored Claude Code OAuth login so `devvm` retains only the newest Claude credential.
</Info>

### Subcommands

#### `devvm secret set`

```bash
devvm secret set NAME [--stdin]
```

Save a secret value on the host. Running VM proxies pick up `claude` and `github` token updates immediately; `proxy` is read when a VM's proxy starts. Tokens cannot be empty or contain double quotes (`"`), backslashes (`\`), or control characters.

<ParamField body="NAME" type="string" required>
  One of `claude`, `github`, or `proxy`.
</ParamField>

<ParamField body="--stdin" type="boolean" default="false">
  Read the secret from a single line on standard input instead of prompting interactively. Required when `--no-input` is set or standard input is not a terminal.
</ParamField>

#### `devvm secret rm`

```bash
devvm secret rm NAME [-f | --force]
```

Delete a saved secret from the host store. Running VM proxies stop injecting the secret immediately.

<ParamField body="NAME" type="string" required>
  One of `claude`, `github`, or `proxy`.
</ParamField>

<ParamField body="--force, -f" type="boolean" default="false">
  Skip the interactive confirmation prompt (required in scripts or with `--no-input`).
</ParamField>

#### `devvm secret use` and `devvm secret unuse`

```bash
devvm secret use NAME [--here | --parent]
devvm secret unuse NAME [--here | --parent]
```

Bind (`use`) or unbind (`unuse`) `claude` or `github` on the current folder's VM.
- The VM's proxy begins or stops injecting the secret immediately, and updates the VM's `secrets.policy` allowlist.
- The guest environment variable placeholder (`GH_TOKEN` or `CLAUDE_CODE_OAUTH_TOKEN`) is populated on the VM's next shell launch.
- Running `devvm secret unuse claude` on a VM whose image includes the `claude` shared tool is refused because VMs with Claude Code always bind `claude`.

### Examples and `--json` payload

<CodeGroup>
```bash title="Configure and bind a GitHub token"
gh auth token | devvm secret set github --stdin
devvm secret use github
devvm secret
devvm secret unuse github
devvm secret rm github --force
```

```json title="devvm secret --json"
{
  "version": 1,
  "secrets": [
    {
      "name": "claude",
      "saved": false,
      "used_by": []
    },
    {
      "name": "github",
      "saved": true,
      "used_by": [
        "devvm-repo-0123456789abcdef"
      ]
    },
    {
      "name": "proxy",
      "saved": false,
      "used_by": []
    }
  ]
}
```
</CodeGroup>

---

## `devvm agent`

Manage `devvm`'s isolated host store of coding agent settings (`~/.config/devvm/agents/<agent>`) and logins (`~/.local/share/devvm/logins/<agent>`). VMs never mount or read the agent configuration folders directly from your host home directory (`~/.claude`, `~/.codex`, `~/.config/opencode`). Running `devvm agent` with no subcommand prints its help text.

```bash
devvm agent [login | import | save]
```

### Supported `AGENT` values

All `devvm agent` subcommands validate agent names against `claude`, `codex`, and `opencode`.

| Agent identifier | Settings store path (`~/.config/devvm/agents/<agent>/...`) | Login store file (`~/.local/share/devvm/logins/<agent>/...`) |
| --- | --- | --- |
| `claude` | `.claude/` (settings, skills, plugins) | `.claude.json` (MCP server secrets and subset) and `.claude/.credentials.json` (OAuth login) |
| `codex` | `.codex/` | `.codex/auth.json` |
| `opencode` | `.config/opencode/` | `.local/share/opencode/auth.json` |

### Subcommands

#### `devvm agent login`

```bash
devvm agent login AGENT [--setup-token] [--device-code | --api-key] [--provider PROVIDER] [--stdin] [--here | --parent]
```

Authenticate `AGENT` inside the current folder's VM (starting the VM first if stopped) and capture the resulting credential into `devvm`'s host login store. Every VM with that agent receives salted placeholders instead of raw tokens. The target VM must have `AGENT` installed in its shared tools.

- **`claude` login flows:**
  - Default (`devvm agent login claude`): runs `claude auth login --claudeai` inside the VM (with `CLAUDE_CODE_OAUTH_TOKEN` unset). Displays a URL to open in a browser on any machine; paste the code back into the terminal. The VM proxy captures the OAuth token response into `.claude/.credentials.json` and deletes any saved `claude` setup-token.
  - `--setup-token`: runs `claude setup-token` inside the VM, captures the long-lived token as the `claude` secret, and deletes any stored Claude OAuth login.
- **`codex` and `opencode` login flows:**
  - Default browser login: temporarily forwards host loopback port `1455` (and fallback `1457`) into the VM while running `codex -c cli_auth_credentials_store="file" login` or `opencode auth login openai --method chatgpt-browser`. The VM's proxy intercepts the token exchange and stores the credentials on the host.
  - `--device-code`: runs `codex ... login --device-auth` or `opencode auth login openai --method chatgpt-headless` with no loopback port forwarding.
  - `--api-key`: prompts for an API key on the host (or reads one line with `--stdin`), saves it to the host login store without running the agent's interactive login CLI, writes the placeholder copy into the VM, and—for `opencode`—runs the guest `opencode.sh` import script.

<Warning>
  `devvm agent login` enforces strict flag combinations before execution:
  - `--device-code` and `--api-key` are mutually exclusive.
  - `--setup-token` is only valid with `claude`.
  - `--device-code`, `--api-key`, and `--stdin` are refused when `AGENT` is `claude`.
  - `--stdin` is only valid when `--api-key` is also set.
  - `--provider` is required when `AGENT` is `opencode` with `--api-key`, must be one of `anthropic`, `openai`, or `openrouter`, and is refused without `--api-key` or for other agents.
</Warning>

<ParamField body="AGENT" type="string" required>
  Agent to log in: `claude`, `codex`, or `opencode`.
</ParamField>

<ParamField body="--setup-token" type="boolean" default="false">
  With `claude`, obtain a long-lived Claude Code token via `claude setup-token` instead of an OAuth subscription login.
</ParamField>

<ParamField body="--device-code" type="boolean" default="false">
  With `codex` or `opencode`, authenticate using a one-time device code in any browser without forwarding local ports.
</ParamField>

<ParamField body="--api-key" type="boolean" default="false">
  With `codex` or `opencode`, save an API key directly into the host store instead of running a browser OAuth flow.
</ParamField>

<ParamField body="--provider" type="string">
  Required with `devvm agent login opencode --api-key`. Target provider: `anthropic`, `openai`, or `openrouter`.
</ParamField>

<ParamField body="--stdin" type="boolean" default="false">
  With `--api-key`, read the API key from a single line on standard input without prompting.
</ParamField>

#### `devvm agent import`

```bash
devvm agent import [AGENT...] [-f | --force]
```

Copy agent settings and logins from your host home directory into `devvm`'s host store (`~/.config/devvm/agents` and `~/.local/share/devvm/logins`). With no `AGENT` arguments, imports all found agents (`claude`, `codex`, `opencode`).

- Never imports conversation history or project state.
- Opens each host agent folder via `os.OpenRoot` so symlinks pointing outside the agent's folder (or symlinks to directories) are never followed and are skipped with a warning.
- Rewrites host plugin paths in `.claude/plugins/*.json` to point to the `devvm` agent store path.
- For `opencode` v2+, runs `opencode auth export` in the host home directory to extract database-stored logins into the store's `auth.json` format.

<ParamField body="AGENT..." type="string[]">
  Zero or more of `claude`, `codex`, `opencode`. Defaults to all three.
</ParamField>

<ParamField body="--force, -f" type="boolean" default="false">
  Overwrite existing settings or logins in `devvm`'s store without prompting for confirmation.
</ParamField>

#### `devvm agent save`

```bash
devvm agent save [AGENT...] [--here | --parent]
```

Copy modified agent settings (and for `claude`, the non-history settings, account state, and MCP server subset of `~/.claude.json`) from the current folder's VM back into `devvm`'s host store so all VMs with that agent receive them.

- Starts the VM first if it is stopped.
- Reads the VM's untrusted stream with a hard byte cap, parses settings files whole, and restores host secret values in place of VM placeholders (`agentlogin.Restore`). Any file containing an unrecognized placeholder or failing to parse is skipped with a warning.
- Prints a redacted unified line diff (`diffShowMax = 20000` lines) of all added, modified, and deleted files and asks for confirmation.
- Because `devvm agent save` has no `--force` flag, it always requires an interactive terminal without `--no-input`.

<CodeGroup>
```bash title="Agent login, import, and save"
devvm agent login claude
devvm agent login codex --device-code
devvm agent login opencode --api-key --provider anthropic
devvm agent import claude codex
devvm agent save claude
```
</CodeGroup>

---

## `devvm git-protect`

Show or change `.git` protection for the current folder's VM. On macOS, `.git` protection locks `.git/hooks`, `.git/config`, `.git/config.worktree`, linked worktree `commondir` files, and submodule Git directories using the user-immutable file flag (`chflags uchg`) so processes inside a VM mounting the folder read-write cannot plant hooks or config directives that execute code on the host.

```bash
devvm git-protect [on|strict|off] [--json] [--here | --parent]
```

### Modes

| Mode | Description |
| --- | --- |
| `on` | Default on macOS for mounted-folder VMs. Locks `.git/hooks`, `.git/config`, worktree configs, and submodule Git directories with `uchg` while the VM is stopped, and audits `.git` for symlinks, `commondir` redirections, and config `include.path` delegations on every launch. |
| `strict` | Applies all locks from `on` and sets `diff.ignoreSubmodules = all` in `.git/config` so host `git status` and `git diff` do not recurse into untrusted submodule working trees. Switching from `strict` to `on` or `off` unsets `diff.ignoreSubmodules`. |
| `off` | Removes `uchg` locks from `.git` and submodules. Default on Linux hosts, where unprivileged user-immutable flags are unavailable (`on` and `strict` require macOS). |
| `clone` | Reported by `devvm git-protect` when the VM is a `--clone` VM (which does not mount the host folder or `.git`). Not a valid input argument to `devvm git-protect`. |

<Note>
  Changing `.git` protection with `devvm git-protect on|strict|off` applies immediately if the VM is stopped. If the VM is running, the new mode is recorded in `vm.json` (`git.next`) to take effect when the VM next stops, and in an interactive terminal `devvm` offers to restart the VM immediately.
</Note>

### Flags and `--json` output

<ParamField body="--json" type="boolean" default="false">
  When run with no mode argument, print the active mode as `{"version": 1, "mode": "on"}`. Refused if `on`, `strict`, or `off` is passed.
</ParamField>

<ParamField body="--here" type="boolean" default="false">
  Target this folder's own VM.
</ParamField>

<ParamField body="--parent" type="boolean" default="false">
  Target the nearest enclosing parent folder's VM.
</ParamField>

<CodeGroup>
```bash title="Show and change .git protection"
devvm git-protect
devvm git-protect strict
devvm git-protect off
```

```json title="devvm git-protect --json"
{
  "version": 1,
  "mode": "on"
}
```
</CodeGroup>

---

## `devvm clone`

Show whether the current folder's VM works on an isolated Git clone inside the VM (`on`) or mounts the host project folder (`off`), or remake the VM from its base image to switch modes.

```bash
devvm clone [on|off] [-f | --force] [--no-export] [--clean-home] [--no-clone-check] [--json] [--here | --parent]
```

### Switching behavior (`on` vs. `off`)

Switching modes with `devvm clone on` or `devvm clone off` remakes the VM from its image, preserving the VM's home directory (unless `--clean-home` is set) and its `vm.json` settings while discarding the rest of its root disk, then opens a shell in the new VM:

1. **`devvm clone on`:**
   - Requires the project folder to be the top-level directory of a Git repository with at least one commit and a checked-out branch (not a detached `HEAD`).
   - Unlocks host `.git` protection from the old machine, sets `git.mode` to `clone`, initializes a repository at the same path inside the new VM with `receive.denyCurrentBranch=updateInstead`, pushes the host repo's branches into the VM over pinned SSH (`GIT_SSH_COMMAND`), and adds a host Git remote (`devvm`, `devvm-<slug>`, or the VM name) pointing to `<vm>:<dir>`.
   - Warns if the host working tree has uncommitted tracked changes, which are not copied.
2. **`devvm clone off`:**
   - Unless `--no-clone-check` is passed, starts the VM if stopped and checks the VM's clone and up to 100 linked worktrees for unfetched commits and uncommitted changes.
   - If unfetched commits exist on VM branches, offers to fetch them into local host branches under `saved/<remote>/<branch>`. If uncommitted changes exist, prompts before remaking.
   - Removes the host Git remote (preserving any previously fetched remote-tracking commits with unique history under `saved/<remote>/<branch>`), remakes the VM with the host project folder mounted read-write, and enables `.git` protection (`on` on macOS).

### Flags

<ParamField body="--force, -f" type="boolean" default="false">
  Skip the remake confirmation prompt (required without a terminal or with `--no-input`). Only valid when `on` or `off` is given.
</ParamField>

<ParamField body="--no-export" type="boolean" default="false">
  Do not export coding agent conversation history to `~/.local/share/devvm/exports` before replacing the VM's disk. Only valid when `on` or `off` is given.
</ParamField>

<ParamField body="--clean-home" type="boolean" default="false">
  Initialize the remade VM with a fresh home directory from the image instead of preserving the existing VM's home directory. Only valid when `on` or `off` is given.
</ParamField>

<ParamField body="--no-clone-check" type="boolean" default="false">
  When switching `off`, skip checking the VM's clone for unfetched commits and uncommitted changes (any unsaved work in the clone is lost). Only valid when `on` or `off` is given.
</ParamField>

<ParamField body="--json" type="boolean" default="false">
  When run with no arguments, print `{"version": 1, "clone": "on"}` or `"off"`. Refused when `on`, `off`, or any remake flag is passed.
</ParamField>

<CodeGroup>
```bash title="Switch between mounted folder and repo clone"
devvm clone
devvm clone on
devvm clone off
devvm clone on --force --clean-home
```

```json title="devvm clone --json"
{
  "version": 1,
  "clone": "off"
}
```
</CodeGroup>

---

## `devvm ssh-agent`

Show or change SSH agent forwarding for the current folder's VM, or manage keys in `devvm`'s dedicated host SSH agent (`~/.local/share/devvm/agent.sock`).

```bash
devvm ssh-agent [on|devvm|off] [--json] [--here | --parent]
```

### Forwarding modes

| Mode | Description |
| --- | --- |
| `off` | Default. No SSH agent socket is forwarded into the VM. |
| `devvm` | Forwards `devvm`'s own isolated host `ssh-agent` (`~/.local/share/devvm/agent.sock`), which holds only the keys explicitly registered via `devvm ssh-agent add` (`~/.local/share/devvm/agent-keys.json`). |
| `on` | Forwards your host's primary `SSH_AUTH_SOCK` into the VM. On launch, if your host agent holds no keys and `~/.ssh/id_ed25519`, `id_ecdsa`, or `id_rsa` exists, `devvm` offers to run `ssh-add` (`--apple-use-keychain` on macOS). |

<Warning>
  While SSH agent forwarding (`devvm` or `on`) is active and you are connected to the VM, any process running as your user inside the VM can request signatures from the forwarded agent's loaded keys. Mode changes apply from the VM's next start; in a terminal, `devvm` offers to restart a running VM immediately.
</Warning>

### Subcommands

#### `devvm ssh-agent add`

```bash
devvm ssh-agent add KEY_FILE [--lifetime DURATION] [--confirm]
```

Start `devvm`'s dedicated SSH agent if it is not running, load `KEY_FILE` into it via `ssh-add`, and record the key's physical parent path, `SHA256:...` fingerprint, and options in `~/.local/share/devvm/agent-keys.json`.
- Whenever a VM with `ssh-agent devvm` starts, `devvm` automatically reloads any recorded key whose lifetime expired or after a host reboot, verifying that the file's fingerprint still matches the pinned `SHA256:...` fingerprint.
- `devvm` refuses to add or reload any key located inside a directory that any project VM mounts.

<ParamField body="KEY_FILE" type="string" required>
  Path to the private SSH key file on the host.
</ParamField>

<ParamField body="--lifetime" type="string" default="12h">
  Duration the agent retains the key in memory once loaded (for example, `30m`, `8h`, `1d`, `2w`), or `forever`. Passed to `ssh-add -t`.
</ParamField>

<ParamField body="--confirm" type="boolean" default="false">
  Require confirmation via an `ssh-askpass` program (`$SSH_ASKPASS` or `ssh-askpass` on `PATH`) before each signature use (`ssh-add -c`). Fails if no askpass program is available.
</ParamField>

#### `devvm ssh-agent rm`

```bash
devvm ssh-agent rm KEY_FILE
```

Unload `KEY_FILE` (matched by its recorded fingerprint) from `devvm`'s SSH agent and remove its entry from `~/.local/share/devvm/agent-keys.json`. The private key file on disk is untouched and does not need to still exist.

### Examples and `--json` payload

<CodeGroup>
```bash title="Manage devvm SSH agent keys and forwarding"
devvm ssh-agent add ~/.ssh/deploy_key --lifetime 8h --confirm
devvm ssh-agent devvm
devvm ssh-agent
devvm ssh-agent rm ~/.ssh/deploy_key
devvm ssh-agent off
```

```json title="devvm ssh-agent --json"
{
  "version": 1,
  "mode": "devvm",
  "keys": [
    {
      "file": "/Users/alice/.ssh/deploy_key",
      "fingerprint": "SHA256:abc123def456...",
      "confirm": true,
      "lifetime": "8h",
      "loaded": true
    }
  ]
}
```
</CodeGroup>

---

## `devvm link`

List or manage one-way service links from the current folder's VM (the client) to TCP ports published by your other project VMs (targets), or publish and unpublish ports on the current VM.

```bash
devvm link [--json] [--here | --parent]
```

### How links resolve and enforce policy

- Inside a client VM, each active link allocates a dedicated loopback address in `127.77.0.0/16` (starting at `127.77.0.1` per target VM) and resolves both `NAME` and `NAME.devvm.internal` to that address, forwarding `NAME:PORT` and `NAME.devvm.internal:PORT` (as well as HTTP proxy requests to those hostnames) to `PORT` on the target VM.
- **Network tier rule (`policyRule`):** A client VM can only link to a target VM whose network tier is at least as strict as the client's tier (`none` > `allowlist` > `internet` > `open`). A confined VM cannot bypass its network tier by linking to a less-restricted VM.
- **Link status (`applies` / `reason`):** When `devvm link` lists links, `APPLIES` is `yes` (in JSON: `"applies": true, "reason": ""`) or one of five non-applying reasons:
  - `gone`: the target VM was deleted.
  - `replaced`: the target VM was remade and now has a different `state.VM.ID`.
  - `unpublished`: the target VM does not currently publish `PORT`.
  - `policy`: the target VM's network tier is less strict than the client VM's tier.
  - `ungrouped`: the link was created by a group (`devvm group`) and the two VMs are no longer in the same group.

### Subcommands

#### `devvm link add`

```bash
devvm link add TARGET PORT [--as NAME] [--here | --parent]
```

Create a link from the current folder's VM to `PORT` (`1`–`65535`) on `TARGET`. Applied immediately on a running VM.

- `TARGET` can be a project folder path (such as `../api`), a folder basename (such as `db`, if unambiguous across your VMs), or a full VM name (`devvm-db-...`). A VM cannot link to itself.
- The target VM must already publish `PORT` via `devvm link publish`, satisfy the network tier rule, and run on a backend with `FeatureLink`.

<ParamField body="TARGET" type="string" required>
  Target VM name, project folder name, or folder path.
</ParamField>

<ParamField body="PORT" type="integer" required>
  Single TCP port number (`1`–`65535`) published by `TARGET`.
</ParamField>

<ParamField body="--as" type="string">
  Hostname label (`NAME`) by which the client VM reaches `TARGET` (default: the target VM's folder slug). Must be a single lowercase DNS label (`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`), not all digits, and not `localhost` or the client VM's own hostname slug. All links from a client to the same target VM share one name.
</ParamField>

#### `devvm link rm`

```bash
devvm link rm TARGET PORT [--here | --parent]
```

Remove the link to `PORT` on `TARGET` (where `TARGET` is the link's `NAME`, the target VM name, or folder name/path). Links actively managed by a group cannot be removed with `devvm link rm` while both VMs remain in that group (use `devvm group leave` or `devvm link unpublish` instead).

#### `devvm link publish` and `devvm link unpublish`

```bash
devvm link publish [PORTS] [--json] [--here | --parent]
devvm link unpublish PORTS [--here | --parent]
```

- With no arguments, `devvm link publish` prints the ports and ranges currently published by this folder's VM (or `none`). Supports `--json`.
- With `PORTS` (comma-separated ports and ranges such as `5432,8000-8010`), `devvm link publish PORTS` adds those ports to the VM's published list, and `devvm link unpublish PORTS` withdraws them.
- If the VM belongs to a group (`devvm group`), publishing or unpublishing ports immediately synchronizes the group's links across all other group members under the host-wide groups lock (`~/.local/share/devvm/groups.lock`).

### Examples and `--json` payloads

<CodeGroup>
```bash title="Publish ports and link two VMs"
# In ../db
devvm link publish 5432

# In ./app
devvm link add ../db 5432 --as pg
devvm link
devvm link rm pg 5432
```

```json title="devvm link --json"
{
  "version": 1,
  "links": [
    {
      "name": "pg",
      "port": 5432,
      "target": "devvm-db-0123456789abcdef",
      "target_folder": "/Users/alice/work/db",
      "target_status": "Running",
      "applies": true,
      "reason": ""
    }
  ]
}
```

```json title="devvm link publish --json"
{
  "version": 1,
  "ports": "5432,8000-8010"
}
```
</CodeGroup>

---

## `devvm group`

Show the group of VMs the current folder's VM belongs to, or join and leave a group. Every VM in a group automatically gets links (`NAME:PORT` and `NAME.devvm.internal:PORT`) to all ports published with `devvm link publish` by every other member of the group.

```bash
devvm group [--json] [--here | --parent]
```

### Group invariants and synchronization

- **Single group per VM:** A VM can belong to at most one group at a time. The group is created when its first member runs `devvm group join NAME` and ceases to exist when its last member leaves or is deleted.
- **Uniform network tier:** Because group links are bidirectional between all members that publish ports, every VM in a group must be on the exact same network tier. `devvm network <tier>` is refused while a VM is in a group.
- **Link precedence:** Explicit links created with `devvm link add` take precedence over group-generated links. If a user-added link in a VM uses the same `NAME` for a different target VM, `devvm` warns of the clash and omits the conflicting group member's auto-links in that VM.
- **Concurrency lock:** All group joins, leaves, and `link publish`/`unpublish` updates acquire `~/.local/share/devvm/groups.lock`.

### Subcommands

#### `devvm group join`

```bash
devvm group join NAME [--as NAME] [--here | --parent]
```

Add the current folder's VM to group `NAME` and synchronize published-port links across all members of the group. Re-running `devvm group join NAME` for the group the VM is already in re-synchronizes all members' group links.

<ParamField body="NAME" type="string" required>
  Group name. Must be a valid lowercase DNS label (`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) and not all digits.
</ParamField>

<ParamField body="--as" type="string">
  Hostname (`NAME`) by which other VMs in the group reach this VM (default: this VM's folder slug). Must be a valid DNS label, cannot be `localhost`, and cannot collide with any existing member's group name or folder hostname slug.
</ParamField>

#### `devvm group leave`

```bash
devvm group leave [--here | --parent]
```

Remove the current folder's VM from its group and remove all group-generated links from both this VM and the remaining members. Explicit links created with `devvm link add` are preserved.

### Examples and `--json` payload

<CodeGroup>
```bash title="Join, inspect, and leave a VM group"
devvm group join web
devvm group join web --as api2
devvm group
devvm group leave
```

```json title="devvm group --json"
{
  "version": 1,
  "group": "web",
  "members": [
    {
      "name": "api2",
      "vm": "devvm-api-89abcdef01234567",
      "folder": "/Users/alice/work/api",
      "published": "8080"
    },
    {
      "name": "db",
      "vm": "devvm-db-0123456789abcdef",
      "folder": "/Users/alice/work/db",
      "published": "5432"
    }
  ]
}
```
</CodeGroup>

## Related pages

<CardGroup cols={2}>
  <Card title="Network tiers and host proxy" href="/network-tiers-and-proxy">
    Traffic confinement across allowlist, internet, open, and none tiers, host-side TLS SNI and HTTP inspection via devvm _proxy, and per-VM CA rotation.
  </Card>
  <Card title="Configure coding agents and credentials" href="/coding-agents-and-credentials">
    Run Claude Code, Codex, and opencode inside VMs, import and save agent settings, complete OAuth or API-key logins, and bind host secrets.
  </Card>
  <Card title="Host and Git isolation boundaries" href="/host-and-git-isolation">
    Read-only .git mount protection, symlink and hardlink containment, set-id file audits, secret placeholder injection, and isolated repo clones.
  </Card>
  <Card title="Connect VMs with links, groups, and port forwards" href="/vm-links-groups-and-ports">
    Forward guest ports to host 127.0.0.1, publish ports and link VMs under *.devvm.internal hostnames, and join VMs into mutually reachable groups.
  </Card>
  <Card title="Isolate work with repository clones" href="/working-with-repo-clones">
    Create VMs with independent Git clones using devvm --clone, switch existing VMs between mounted folders and clones, and fetch VM branches to the host.
  </Card>
  <Card title="Configuration (config.toml) and --json schema reference" href="/configuration-and-json-reference">
    Complete reference for ~/.config/devvm/config.toml keys, settings.json precedence, environment variables, state directory layout, and version 1 --json payloads.
  </Card>
</CardGroup>
