# Host and Git isolation boundaries

> Read-only .git mount protection, symlink and hardlink containment, set-id file audits, secret placeholder injection, and isolated repo clones.

- 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/gitprotect/gitprotect.go`
- `internal/gitprotect/own.go`
- `internal/hostsafety/hostsafety.go`
- `internal/hostgit/hostgit.go`
- `internal/vm/clone.go`
- `internal/secrets/secrets.go`

---

---
title: "Host and Git isolation boundaries"
description: "Read-only .git mount protection, symlink and hardlink containment, set-id file audits, secret placeholder injection, and isolated repo clones."
---

`devvm` isolates host filesystems, Git repositories, and credentials from untrusted code running with passwordless `sudo` inside project VMs across six package boundaries: `internal/project` (mount boundary validation), `internal/gitprotect` (immutable `.git` hooks and config locking plus repository ownership checks), `internal/hostgit` (hardened host-side `git` subprocess execution), `internal/hostsafety` (Linux set-user-id and set-group-id file audits), `internal/secrets` with `internal/agentlogin` (salted placeholder injection and settings redaction), and `internal/vm` (isolated `--clone` repositories and tar stream sanitization).

## Host and guest boundary architecture

A standard project VM mounts only its project directory read-write at the same path, plus shared EROFS tool images and agent configuration directories (`~/.local/share/devvm/agents/<agent>`) read-only. It never mounts the host home directory (`~`), `~/.ssh`, or `devvm` state directories.

```mermaid
flowchart TB
  subgraph Host["Host Machine"]
    direction TB
    CLI["devvm CLI & Starter (internal/vm)"]
    GitProtect["Git Protector (internal/gitprotect)<br/>chflags uchg / nouchg"]
    HostGit["Hardened Git Runner (internal/hostgit)<br/>GIT_CONFIG_COUNT & GIT_SSH_COMMAND"]
    HostSafety["Host Safety Audit (internal/hostsafety)<br/>SetIDFiles & SSHRemote"]
    SecretStore["Secrets & Login Store (internal/secrets)<br/>macOS Keychain / 0600 files"]
    Proxy["Per-VM Host Proxy (devvm _proxy)<br/>TLS SNI & Header Injection"]
    ProjectDir[("Host Project Folder<br/>Read-Write Mount + Locked .git")]
  end

  subgraph Guest["Project VM (Untrusted Guest Root)"]
    direction TB
    GuestWorktree["Mounted Worktree or<br/>Isolated Clone (devvm --clone)"]
    GuestEnv["Guest Environment & Agent Configs<br/>Salted Placeholders + devvm-redacted"]
  end

  CLI --> GitProtect
  CLI --> HostSafety
  CLI --> HostGit
  CLI --> SecretStore
  GitProtect -- "Locks hooks, config,<br/>worktrees, submodules" --> ProjectDir
  HostSafety -- "Scans setuid/setgid<br/>(skips .git & other devices)" --> ProjectDir
  ProjectDir <-->|"virtiofs / 9p ( or omitted in --clone )"| GuestWorktree
  HostGit <-->|"Pinned SSH receive-pack / upload-pack"| GuestWorktree
  SecretStore -- "Writes bindings &<br/>per-VM salt" --> Proxy
  SecretStore -- "In-memory tar stream<br/>(sync.sh)" --> GuestEnv
  GuestEnv -- "HTTPS with placeholder" --> Proxy
```

### Unsafe project directory refusals

Before creating or starting a VM that mounts a project folder, `project.Unsafe` (`internal/project/project.go`) resolves symlinks via `paths.Physical` and rejects directories that would expose host credentials, `devvm` binaries, layer build scripts, or other VMs' disks:

| Check method | Condition | Refused paths | Threat prevented |
| --- | --- | --- | --- |
| `Unsafe.Holds(dir)` | `paths.Within(paths.Physical(p), dir)` | `$HOME`, `~/.config/devvm`, `~/.local/share/devvm`, `~/.local/share/devvm/lima` (and `/`) | Mounting the user home directory, secret stores, proxy CA private keys, or VM disks read-write into the guest. |
| `Unsafe.Inside(dir)` | `paths.Below(dir, paths.Physical(p))` | Subdirectories below `~/.config/devvm`, `~/.local/share/devvm`, or the Lima home | A VM inside `~/.config/devvm/layers` rewriting build scripts executed by future image builds, or modifying host binaries in the data directory. |
| `project.Nth(dir, ...)` | Path contains `,` or `unicode.IsControl` | Any path containing a comma or control character (`BadPathError`) | Breaking Lima mount option parsing (which splits on commas) or line-oriented guest setup scripts. |

<Note>
Passing `--unsafe-folder` overrides `Unsafe.Holds` and `Unsafe.Inside` with a warning. A `--clone` VM does not mount the host project folder at all, so `devvm` does not apply unsafe-folder mount refusals to `--clone` VMs.
</Note>

### Folder identity and nested project replacement checks

`devvm` records the project directory's physical path (`st.Dir`) and filesystem identity (`st.Folder` via `platform.FolderID`, which combines device, inode, and birth time where supported) in `vm.json`.

When a project with its own VM sits inside a parent folder that also has a VM, code inside the parent VM can replace the nested project directory with a symlink (for example, pointing to `$HOME`). `starter.bringUp` and `starter.start` (`internal/vm/start.go`) guard against this with `folderChanged(st)`:

1. Before running host checks on a stopped VM, `bringUp` verifies `filepath.EvalSymlinks(dir) == dir` and `platform.FolderID(dir) == st.Folder`. If either fails, startup aborts with `project_folder_changed`.
2. Immediately before `b.Start` and again immediately after `b.Start` returns, `starter.start` re-evaluates `folderChanged(st)`. If the directory was swapped during boot, `devvm` immediately stops the VM and proxy and returns `project_folder_changed_stopped`.

---

## Read-only `.git` mount protection

Because the project directory is mounted read-write, a guest process could write `.git/hooks/post-checkout` or set `core.fsmonitor`, `core.hooksPath`, `core.sshCommand`, or `filter.*.clean` in `.git/config` to execute arbitrary code on the host during the next host `git` invocation. A read-only bind mount inside the guest cannot prevent this because guest `root` can `umount` it.

On macOS, `gitprotect.Protector` (`internal/gitprotect/gitprotect.go`) locks executable and configuration paths inside `.git` on the host filesystem using the BSD user-immutable flag (`chflags uchg`), which guest `root` cannot clear across `virtiofs`.

<Warning>
Linux hosts do not provide an unprivileged user-immutable file flag that blocks guest writes across `9p` or `virtiofs`. On Linux, `Protector.CanLock` (`platform.HasImmutableFlag`) is `false`: `devvm` warns with `git_protect_needs_macos_new` when creating a VM for a repository, sets the mode to `off`, and refuses `devvm git-protect on` or `strict`. Use `devvm --clone` on Linux hosts to isolate `.git`.
</Warning>

### Protected paths and pre-created placeholders

`Protector.paths` and `Protector.lock` discover and lock the following paths across the top-level `.git` directory, `.git/modules/**` (any directory containing `HEAD`, `objects/`, and `refs/`), and working-tree submodules discovered via `git -c core.fsmonitor=false ls-files -s -z` (mode `160000`, up to `maxDepth = 8`):

| Path pattern | Lock behavior when present | Behavior when missing |
| --- | --- | --- |
| `<gitDir>/hooks` | Locked recursively (`chflags -R uchg` without following symlinks) | Created empty (`os.Mkdir(path, 0o700)`) and locked so the VM cannot create `.git/hooks` later. |
| `<gitDir>/config` | Locked (`chflags uchg`) if regular file | Skipped until created. |
| `<gitDir>/config.worktree` | Locked when `extensions.worktreeConfig` is `true` | Created empty (`O_WRONLY\|O_CREATE\|O_EXCL`, mode `0600`) and locked when `extensions.worktreeConfig` is `true`. |
| `<gitDir>/worktrees/<name>/commondir` | Locked if regular file | Skipped. |
| `<gitDir>/worktrees/<name>/config.worktree` | Locked when `extensions.worktreeConfig` is `true` | Created empty and locked when `extensions.worktreeConfig` is `true`. |
| `.git` (when a regular file) | Locked (`chflags uchg`) in a linked worktree | N/A |

### Protection modes and state schema

A VM's `.git` protection state is stored under the `"git"` section (`gitprotect.InVM`) in `~/.local/share/devvm/vms/<hash>/vm.json`:

```json
{
  "mode": "on",
  "next": "strict",
  "made": {
    "protected": true,
    "git_id": "16777220:4829103:1730990000",
    "git_dirs": [
      ".git/modules/sub",
      "vendor/sub/.git"
    ],
    "delegations": "9a4f2c81e03b7d12"
  }
}
```

| Mode | Description |
| --- | --- |
| `on` | Default on macOS (unless `--no-git-protect` is passed). Locks all discovered hooks, config, worktree, and submodule paths while the VM is stopped. |
| `strict` | Locks everything `on` locks, and temporarily unlocks `.git/config` to set `diff.ignoreSubmodules = all` before re-locking it. Prevents host `git status` and `git diff` from reading newly added submodule configs before the next VM stop/launch locks them. Switching back to `on` or `off` unsets `diff.ignoreSubmodules`. |
| `off` | Unlocks all currently discovered paths plus every path previously recorded in `made.git_dirs` (so submodules removed from `.git/index` by the VM are also unlocked). |
| `clone` | Set automatically for `--clone` VMs, which mount no host `.git` directory. Cannot be changed with `devvm git-protect`. |

### Race-free locking lifecycle and launch audits

`devvm` never modifies `uchg` flags while a VM is running because a process in the VM could race the check and swap a directory for a symlink.

- **Changing mode on a running VM:** `devvm git-protect <mode>` saves the target mode in `git.next` (`gitprotect.SetNext`) and prompts to restart the VM immediately. If declined or run with `--no-input`, `devvm` applies `git.next` the next time it finds the VM stopped (`starter.hostChecks` or `devvm stop`).
- **TOCTOU check on every flag change:** `Protector.flag` checks `realInDir(path)` (verifying the path is below `p.Dir`, is not a symlink via `os.Lstat`, and its parent resolves without symlinks via `filepath.EvalSymlinks`), records `before := devIno(path)`, applies `Chflags`, and then re-verifies `devIno(path) == before && p.realInDir(path)`. If the inode moved during the call, `Protector.flag` immediately clears `nouchg` at the destination and warns with `git_lock_raced`.
- **Audits on launch and stop (`Protector.Check`):** On every launch of an existing VM, history export, and `devvm stop`, `protect` compares the live `.git` tree against `InVM.Made` and warns when it detects conditions a file lock cannot prevent:
  - **Replaced `.git` (`git_replaced`):** `Made.GitID` no longer matches `folderID(.git)` (the VM renamed `.git` and created a new one). `protect` warns and stops further lock updates so the user can inspect `.git`.
  - **`.git/commondir` present (`git_commondir`) or stray worktrees (`git_worktree_stray`):** `.git/commondir` exists in `.git`, or `.git/worktrees/<name>/commondir` does not resolve back to the repository's physical `.git` directory.
  - **New repository or submodule (`git_appeared`, `git_embedded_new`):** `.git` or a submodule git directory appeared since the last lock.
  - **Unlocked hooks (`git_hooks_unlocked`):** A `hooks` directory is unlocked and contains regular files other than `*.sample`.
  - **Working-tree hooks path (`git_hooks_path`):** `core.hooksPath` resolves inside the project directory.
  - **Delegations into the project tree or outside it (`git_include_inside`, `git_hook_link_inside`, `git_gitdir_outside`):** `include.path` or `includeIf.*.path` targets a file inside the project folder, a hook under `hooks/` is a symlink pointing into the project folder, or a submodule's `gitdir:` points outside the project folder. Hashed in `Made.Delegations` (first 16 hex chars of SHA-256) so unchanged delegations are only reported on creation/`on` or when changed.

<CodeGroup>
```sh title="Inspect and switch .git protection"
# Show current mode (on, strict, off, or clone)
devvm git-protect
devvm git-protect --json

# Enable strict mode (sets diff.ignoreSubmodules=all in locked .git/config)
devvm git-protect strict

# Temporarily unlock .git on a stopped VM to edit .git/config on the host
devvm stop
devvm git-protect off
git remote add upstream git@github.com:org/repo.git
devvm git-protect on
```
</CodeGroup>

---

## Hardened host Git execution (`internal/hostgit`)

`devvm` runs `git` on the host to read `user.name` and `user.email`, inspect `.git` and submodule indexes, verify `--clone` repositories, and push or fetch branches for `--clone` VMs. Because a mounted project repository (or a nested repository inside a parent VM's mount) can be modified by the guest, a structure test in `internal/structure` enforces that **every** host `git` invocation in `devvm` goes through `internal/hostgit`.

### Sanitized environment and config overrides

`hostgit.Command(ctx, args...)` constructs an `*exec.Cmd` with a scrubbed environment (`environ`) and high-precedence `GIT_CONFIG_COUNT` environment key-value pairs that override any `.git/config`, worktree config, or global config:

| Layer | Variable or config key | Value set by `hostgit` | Purpose |
| --- | --- | --- | --- |
| Environment | `GIT_*` (inherited) | Stripped completely | Prevents caller environment variables (`GIT_DIR`, `GIT_WORK_TREE`, `GIT_CONFIG_GLOBAL`, `GIT_INDEX_FILE`, `GIT_SSH_COMMAND`) from redirecting `git`. |
| Environment | `PATH` | Filtered to `filepath.IsAbs(d)` only | Prevents `git` from executing binaries (such as `ssh` or helpers) from relative `PATH` entries inside the repo working tree. |
| Environment | `GIT_TERMINAL_PROMPT` | `0` | Prevents interactive credential or host-key prompts on the terminal. |
| Environment | `GIT_NO_LAZY_FETCH` | `1` | Prevents partial clones (`extensions.partialClone`) from connecting to a malicious promisor remote during `rev-list` or `status`. |
| Environment | `GIT_ALLOW_PROTOCOL` | `ssh` | Overrides even per-protocol `protocol.<name>.allow=always` settings in `.git/config`; blocks `file://`, `ext::`, `http://`, `https://`, and `git://`. |
| Environment | `GIT_SSH_COMMAND` / `GIT_SSH_VARIANT` | `false` / `ssh` | Disables network transport and overrides `core.sshCommand` and `ssh.variant` unless explicitly replaced by `clones.gitSSH`. |
| `GIT_CONFIG_*` | `core.fsmonitor` | `false` | Disables `fsmonitor` daemon or Watchman hooks on `status` and `ls-files`. |
| `GIT_CONFIG_*` | `core.hooksPath` | `os.DevNull` (`/dev/null`) | Disables all directory-based Git hooks. |
| `GIT_CONFIG_*` | `core.alternateRefsCommand` | `true` | Neutralizes custom commands used to list refs in alternate object stores. |
| `GIT_CONFIG_*` | `protocol.allow` / `protocol.ssh.allow` | `never` / `always` | Restricts transport protocols to SSH at the config level. |
| `GIT_CONFIG_*` | `maintenance.auto` / `gc.auto` | `false` / `0` | Prevents background `git maintenance` or `git gc` from spawning on the host. |
| `GIT_CONFIG_*` | `hook.<event>.enabled` | `false` (all 27 `githooks(5)` events) | Disables config-defined hooks (`hook.*`) introduced in newer Git versions. |

When `hostgit.Command` is called with `-C <dir>`, `repoOff(ctx, dir)` first runs a hardened `git -C <dir> config --name-only --get-regexp ^(filter|hook)\.` query and appends config overrides for every named hook (`hook.<name>.enabled=false`) and every named filter driver (`filter.<name>.clean=""`, `filter.<name>.smudge=""`, `filter.<name>.process=""`, `filter.<name>.required=false`). In addition, `hostgit.Status(ctx, dir, args...)` always passes `--no-optional-locks` and `--ignore-submodules=all`.

---

## Symlink and hardlink containment

Because a VM can plant symlinks or hardlinks inside mounted folders, agent configuration directories, tar streams, or `.git`, `devvm` enforces strict path resolution and containment across five distinct boundaries.

### 1. Path resolution primitives (`internal/paths/resolve.go`)

`internal/paths` defines three canonical resolution strategies before comparing paths with `paths.Within(path, dir)` or `paths.Below(path, dir)`:

- `paths.Physical(path)`: Resolves symlinks along the deepest existing prefix of `path` and joins the non-existent remainder as named. Used for checking project folders against `devvm`'s own directories (`project.Unsafe`) and checking where a not-yet-created directory will land.
- `paths.PhysicalParent(path)`: Resolves symlinks in `filepath.Dir(path)` while keeping `filepath.Base(path)` unresolved. Used when the final path component may itself be a symlink that `devvm` must not follow, or a file the VM could create (such as paths under `.git`, recorded key paths, and Seatbelt profile rules).
- `filepath.EvalSymlinks(path)`: Requires the entire path to exist and resolves all symlinks. Used for existing directories (`devvm` working directory, `devvm link` targets, `mise` binary paths).

### 2. `os.Root` confinement for host file reads

Whenever `devvm` reads files from a directory that could contain untrusted symlinks, it opens the directory with Go's `os.OpenRoot` (`*os.Root`), which prevents symlink traversal (`..` or absolute links) outside the root at the OS syscall level:

- **`.devcontainer/devcontainer.json`:** Opened via `os.Root` with `O_NONBLOCK` (so a FIFO planted by a VM does not block `devvm`), verified as a regular file on the open file descriptor, and capped at 1 MiB.
- **`devvm agent import` (`internal/vm/agentimport.go`):** Opens `~/.claude`, `~/.codex`, or `~/.config/opencode` as an `*os.Root`. Regular files and symlinks that resolve to regular files *within* that `os.Root` are read and written as regular files into `~/.local/share/devvm/agents/<agent>`. Symlinks escaping the root, symlinks to directories, and symlinked login files (`~/.codex/auth.json`, `~/.claude.json`, etc.) are skipped with `agent_import_link_left_out`. Pruning stale files in the store (`pruneStore`) also operates through `os.OpenRoot`.

### 3. `devvm agent save` tar stream validation (`internal/vm/agentsave_read.go`)

When `devvm agent save` reads modified agent settings back from a running VM, `AgentSave.read` streams a `tar` archive from the guest and validates every entry in memory via `readSent`:

- **Resource caps:** Total stream capped at `saveMaxBytes` (128 MiB), stderr at `saveMaxStderr` (1 MiB), individual files at `saveMaxFile` (16 MiB), and total tar headers at `saveMaxEntries` (20,000). Exceeding any cap aborts the save before touching the host store.
- **Path hygiene (`entryName`):** Every tar header name must be relative, clean (`path.Clean(name) == name`), contain no `..` segments, be valid UTF-8, and consist solely of graphic characters (`unicode.IsGraphic`, blocking terminal escape sequences and bidirectional overrides in diffs).
- **Allowlisted hierarchy (`listedPath`):** Every entry must match `agentFilesOf` or `agentDirsOf` (or `opencode`'s `.config/opencode` excluding `node_modules` and lockfiles), and its parent directory must already exist in the stream as a real directory (`kind == 'd'`), rejecting entries placed beneath a symlinked directory.
- **Bounded symlink resolution (`sent.resolve`):** Symlinks inside settings folders are never written to the host store as symlinks. Instead, `sent.resolve` follows relative symlinks in memory within the same top-level settings folder (`top`) for at most `saveLinkHops = 8` hops through real directories in the stream. If the chain resolves to a regular file inside `top`, it is saved as a regular file copy; otherwise, the symlink is skipped with `agent_save_link_left_out` and the existing store entry is preserved.

### 4. Remake home directory tar filter (`internal/vm/home.go`)

When `devvm layer add`, `devvm layer rm`, `devvm update`, or `devvm clone` remakes a VM, `saveHome` streams the old VM's home directory (`tar -czf - --one-file-system --ignore-failed-read .`) through `copyHome` in Go memory without ever unpacking it on the host:

```go title="internal/vm/home.go"
name, ok := homePath(h.Name)
if !ok || skipped(name, skips) || throughLink(name, kept) || !keepType(h, kept) {
	continue
}
out := &tar.Header{
	Typeflag: h.Typeflag, Name: name, Mode: h.Mode & 0o777, ModTime: h.ModTime, Format: tar.FormatPAX,
}
```

- **Path and symlink parent check:** `homePath` drops `.`, absolute paths, `..` prefixes, and control characters (`< 0x20` or `0x7f`). `throughLink(name, kept)` walks `path.Dir(name)` upward and drops any entry whose ancestor was previously recorded in `kept` as `tar.TypeSymlink`, preventing `tar -xzf` in the new VM from following a parent symlink outside `~`.
- **Hardlink containment (`keepType`):** Only `tar.TypeReg`, `tar.TypeDir`, `tar.TypeSymlink`, and `tar.TypeLink` (hardlinks) are kept; FIFOs, block/char devices, and sockets are dropped. A `tar.TypeLink` entry is kept **only** if its `h.Linkname` passes `homePath` and names a `tar.TypeReg` file that was already kept earlier in the same stream (`kept[target] == tar.TypeReg`), blocking hardlinks to `/etc/shadow` or files outside the kept home tree.
- **Mode and owner stripping:** `Mode` is masked with `0o777` (stripping `setuid`, `setgid`, and `sticky` bits), and `Uid`/`Gid`/`Uname`/`Gname` are omitted.
- **Setup exclusions (`homeSkips`):** `.config/devvm`, `.ssh/authorized_keys`, `.cache`, agent login files, and mounted/copied agent settings are stripped on the host and regenerated fresh after `tar -xzf - --skip-old-files` restores the archive in the new VM.

### 5. History import symlink and SQLite sanitization (`internal/vm/agents.go`)

- **Archive path and symlink filter (`historyImport`):** When importing an agent history `.tar.gz` inside a VM, `historyImport` lists archive members with `tar -tzf "$t/export" --absolute-names --quoting-style=escape` and aborts with `devvm-history-unsafe` if any path falls outside `historyPaths` (`.claude`, `.claude.json`, `.codex`, `.local/share/opencode`, `.local/state/opencode`) or contains `..`. After unpacking into a temporary directory with `--no-same-owner --no-same-permissions --anchored` and excluding `importSkips()`, `find "$t/x" -type l` deletes every symlink whose target starts with `/`, is `..`, or contains `../`, `/..`, or `/../`.
- **Opencode SQLite WAL login scrubbing (`opencodeClean`):** Because opencode 2 stores sessions and credentials together in `.local/share/opencode/opencode*.db` in WAL mode, both `historyExport` and `historyImport` exclude raw `.db`, `.db-wal`, `.db-shm`, and `.db-journal` files and run `devvm_opencode_clean` inside the VM using `sqlite3 -bail -cmd 'PRAGMA trusted_schema = OFF'`. It snapshots the database and WAL via `VACUUM INTO 'clean.db'`, drops all triggers, deletes rows from all credential/account/token/share tables (`opencodeLogins`) plus `wellknown:config:%` rows in `kv` and the `20260805200742_import_legacy_credentials` migration record, runs `PRAGMA journal_mode = DELETE; PRAGMA secure_delete = ON; VACUUM;` to purge free pages, and verifies zero rows remain before including `clean.db`.

---

## Set-id file audits (`internal/hostsafety`)

Inside a VM, the default user has passwordless `sudo` and can `chmod u+s,g+s` binaries inside the mounted project folder. On macOS, `virtiofs` drops `setuid` and `setgid` bits on the host, so they have no effect. On Linux hosts, guest `setuid` and `setgid` bits persist on the host filesystem: a `setuid` binary created by the VM is owned by the host user and runs as that user if executed by anyone else on the host, while a root-owned `setuid`/`setgid` binary runs with host root privileges.

### Audit behavior and execution timing

`hostsafety.SetIDFiles(dir)` (`internal/hostsafety/hostsafety.go`) walks the project directory on the host and returns up to `setIDMax = 5` regular files with `fs.ModeSetuid` or `fs.ModeSetgid` set:

- **Single-filesystem and `.git` skip:** `SetIDFiles` skips `.git` directories (which hold most files in large repositories and are not executed by path) and skips subdirectories on different filesystems (`!platform.SameDevice(root, fi)`).
- **Root vs. user classification:** `rootSetID(m, uid, gid)` marks `SetIDFile.Root = true` when `(m&fs.ModeSetuid != 0 && uid == 0) || (m&fs.ModeSetgid != 0 && gid == 0)`.
- **When it runs:** Because walking 200,000 files takes roughly 1 second, `starter.checkSetID` (`internal/vm/start.go`) and `devvm stop` run `SetIDFiles` only on Linux hosts (`s.OS == "linux"`), only when a VM is newly created (`made == true`) or when `devvm` finds or places the VM in the `stopped` state—never when attaching to an already-running VM, and never for `--clone` VMs.
- **Warnings emitted:** Root set-id files trigger `msg.SetidRootFiles` (advising `sudo rm`), and non-root set-id files trigger `msg.SetidFiles` (advising `chmod u-s,g-s FILE`).

`hostsafety.SSHRemote(ctx, dir)` also checks `remote.origin.url` in `.git/config` via `hostgit.Output` when creating a new VM; if the URL starts with `git@` or `ssh://` and SSH agent forwarding is disabled, `devvm` emits `msg.SSHRemoteHint`.

---

## Secret placeholder injection and settings redaction

No project VM—even on the `open` network tier—ever receives a real secret token or agent refresh token. Real credentials remain on the host in the macOS Keychain (under service `devvm`, or `DEVVM_SECRET_SERVICE` when set) or in `0600` files inside `~/.local/share/devvm/secrets/` (`0700` directory) and `~/.local/share/devvm/logins/<agent>/` (excluded from Time Machine backups on macOS via `ExcludeFromBackup`).

### Known secrets and placeholder format (`internal/secrets/secrets.go`)

Each VM has a random per-VM salt (`netpolicy.Salt(ctx, st)`), distinct from its proxy password. `secrets.Known` defines three managed secrets:

| Secret name | `HostOnly` | Guest env var | Placeholder format (`s.Placeholder(salt)`) | Target HTTPS `:443` hosts | Header injected by proxy |
| --- | --- | --- | --- | --- | --- |
| `claude` | `false` | `CLAUDE_CODE_OAUTH_TOKEN` | `sk-ant-oat01-devvm-proxy-managed-<salt>` | `api.anthropic.com` | `Authorization` |
| `github` | `false` | `GH_TOKEN` (also sets `DEVVM_GITHUB=1`) | `devvm-proxy-managed-github-<salt>` | `api.github.com`, `github.com`, `codeload.github.com`, `raw.githubusercontent.com` | `Authorization` |
| `proxy` (`proxy.UpstreamSecret`) | `true` | Never exposed to VMs | None (never bound to a VM) | Sent only to the upstream corporate proxy configured in `config.toml` | `Proxy-Authorization` |

<Info>
`secrets.Token(name, given)` strips whitespace (or trims leading/trailing whitespace for `proxy`), rejects empty values or values containing `"`, `\`, or ASCII control characters (`< 0x20` or `0x7f`), and requires `claude` setup tokens to start with `sk-ant-oat01-`.
</Info>

### Agent login files and OAuth capture

`secrets.LoginRel(kind)` maps agent login files stored under `~/.local/share/devvm/logins/<agent>/`:

| Login kind | Relative path (`LoginRel`) | How the VM receives it (`syncer.stageSecrets`) |
| --- | --- | --- |
| `codex` | `.codex/auth.json` | Copied to `~/.codex/auth.json` (`0600`) with API keys and OAuth tokens replaced by salted placeholders (including unsigned JWT placeholders carrying account claims and a far-off `exp` so Codex does not attempt to refresh them inside the VM). |
| `opencode` | `.local/share/opencode/auth.json` | Copied to `~/.local/share/opencode/auth.json` (`0600`) plus `opencode-import.json` for opencode 2 database import, with supported provider credentials (`anthropic`, `openai`, `openrouter`) replaced by salted placeholders. |
| `claude` | `.claude.json` | Staged as `claude.json` (`0600`) and merged into `~/.claude.json` by `guest/sync.sh`, with MCP server secrets replaced by salted placeholders. |
| `claude-oauth` | `.claude/.credentials.json` | Copied to `~/.claude/.credentials.json` (`0600`) when Claude Code uses an OAuth login instead of a `claude setup-token` secret. |

When an agent logs in inside a VM (`devvm agent login`), the VM's host proxy intercepts the token endpoint response (`Store.CaptureRequest` and `Store.Capture`), writes the real credentials to `~/.local/share/devvm/logins/<agent>/` on the host, and returns a response containing only salted placeholders to the VM. When the proxy needs a credential for an outbound request (`Store.Get`), `agentlogin.File.Get` reads the host login file and refreshes expired OAuth tokens on the host so the VM never holds a refresh token.

### Redacting and restoring agent settings (`internal/agentlogin/redact.go`)

On every launch, `syncer.stage` (`internal/vm/sync.go`) stages `.claude/settings.json`, `.codex/config.toml`, and `.config/opencode/*.{json,jsonc,toml}` in memory and runs `agentlogin.Redact(format, data)`:

1. **Whole-file parsing:** Files are decoded completely as JSON/JSONC or TOML. Any file that fails to parse is dropped (`config_not_copied`) so unparsed lines never reach the VM. In `.config/opencode`, non-settings files other than `.md` (such as `.env` files) are also withheld (`withheld(rel)`).
2. **Key and table matching:** `Redact` replaces string values with `"devvm-redacted"` when:
   - The key matches `(?i)(key|token|secret|passw|^authorization$|^proxy-authorization$|^cookie$)` (excluding `keybind`/`keymap` tables).
   - The value sits inside an `env`, `environment`, `headers`, `http_headers`, or `shell_environment_policy.set` map (`wholeTable`), unless it is a boolean/port literal (`^(?i:true|false|[0-9]{1,5})$`), a non-credential URL, or a known non-credential Claude Code setting (`claudeEnv`, such as `ANTHROPIC_MODEL`, `CLAUDE_CODE_*`, `DISABLE_*`, `MAX_*`, `*_TIMEOUT_MS` not matching `credName`).
   - The string matches a known secret prefix/shape (`secretShape`), or contains credentials in URLs (`scrubURL`), command arguments (`scrubArgs`), or free text (`scrubText`).
3. **Preserved indirection (`redactKeep`):** Empty strings, uppercase environment variable names (`^[A-Z][A-Z0-9_]*$`), variable/file references (`{env:X}`, `{file:X}`, `$X`, `${X}`), and file paths (`/`, `~/`, `./`) are kept unchanged.
4. **Restoring on `devvm agent save`:** When `devvm agent save` reads a settings file or `.claude.json` subset back from a VM, `agentlogin.Restore(format, vmData, givenToVM, oldStoreData)` parses the file and replaces `"devvm-redacted"` markers and the VM's salted placeholders with the original secret values from `oldStoreData`. If a file contains a placeholder that cannot be matched back to the store, `devvm agent save` refuses to save that file (`agent_save_placeholder`).

<CodeGroup>
```sh title="Manage host-stored secrets"
# List known secrets (claude, github, proxy), whether saved, and bound VMs
devvm secret
devvm secret --json

# Save a GitHub token from stdin and bind it to the current folder's VM
gh auth token | devvm secret set github --stdin
devvm secret use github

# Unbind or delete the token
devvm secret unuse github
devvm secret rm github
```
</CodeGroup>

---

## Isolated repository clones (`internal/vm/clone.go`)

Passing `--clone` when creating a VM (`devvm --clone`) or switching an existing VM (`devvm clone on`) eliminates the read-write project mount altogether (`projectMount(st)` returns `""`, and `gitprotect.Mode` is set to `"clone"`). Instead, `devvm` creates an independent Git repository inside the VM at the exact same filesystem path (`t.f.Dir`) and transfers commits exclusively through hardened host-initiated `git push` and `git fetch` calls over pinned SSH.

```mermaid
sequenceDiagram
  autonumber
  participant Host as Host Repo (internal/vm/clone.go)
  participant HostGit as Hardened Git + gitSSHCommand
  participant Guest as VM Isolated Clone (same path)

  Note over Host: checkCloneRepo & gitprotect.OwnRepo<br/>Verify HEAD, branch, no alternates/symlinks
  Host->>Guest: exec: sudo mkdir -p "$dir" && chown && git init -q<br/>receive.denyCurrentBranch=updateInstead
  Host->>HostGit: git push --no-verify --no-recurse-submodules --no-signed
  HostGit->>Guest: ssh (pinned HostKeyAlias, BatchMode, no agent/forwards)<br/>exec git-receive-pack '$dir'
  Host->>Host: git remote add devvm <vm>:<dir><br/>Warn if host worktree has uncommitted changes
  Note over Guest: Agent or user edits files and commits<br/>Host working tree and .git are untouched
  Host->>HostGit: devvm rm / devvm clone off (cloneCheck)
  HostGit->>Guest: Check unfetched refs/heads/* & worktree status (up to 100)
  HostGit->>Guest: git fetch --no-tags --no-recurse-submodules --refmap=<br/>into refs/heads/saved/devvm/<branch>
```

### Repository ownership verification (`gitprotect.OwnRepo`)

Before creating or seeding a `--clone` VM (`checkCloneRepo` and `cloneSource`), `devvm` verifies that the host directory is the top level of a Git repository (`git rev-parse --show-toplevel == dir`) with at least one commit and an attached branch (`symbolic-ref -q HEAD`), and calls `gitprotect.OwnRepo(dir)` (`internal/gitprotect/own.go`) to prevent pushing objects borrowed from another repository on the host:

1. **`.git` directory or bidirectional linked worktree:**
   - If `.git` is a directory, `commonOf(.git)` must resolve to `.git` itself (no `.git/commondir` pointing elsewhere).
   - If `.git` is a regular file (`linkedWorktree`), its `gitdir:` must resolve to `<repo>/.git/worktrees/<name>`, that worktree directory's `gitdir` file must resolve back to the project folder's `.git` file, and both `<worktree>/commondir` and `<repo>/.git` must resolve to `<repo>/.git` with no further `commondir`.
2. **No alternates or symlinked objects/refs (`ownObjects`):** Neither `gitDir` nor `commonDir` may contain `objects/info/alternates`, nor may `objects`, `packed-refs`, `reftable`, `refs`, or any entry inside `objects/**` or `refs/**` be a symlink.
3. **Agreement with `git rev-parse`:** `cloneSource` verifies that `git rev-parse --path-format=absolute --absolute-git-dir --git-common-dir` resolves to the exact same `gitDir` and `commonDir` returned by `OwnRepo`.

### Seeding, pinned SSH transport, and remote setup

`clones.seedClone` (`internal/vm/clone.go`) initializes the repository in the running VM and pushes the host's branches:

- **What gets pushed (`seedRefs`):** For a standard repository, `seedRefs` pushes `--all` (all local branches under `refs/heads/*`). For a linked worktree, `seedRefs` pushes only the checked-out branch (`refs/heads/<branch>:refs/heads/<branch>`) plus its single upstream ref (`%(upstream)`) if valid and not a wildcard pattern. Tags, untracked files (such as `.env`), and uncommitted changes are never copied; if the host worktree is dirty, `seedClone` warns with `uncommitted_not_copied`.
- **Tamper-proof `GIT_SSH_COMMAND` (`gitSSHCommand`):** Both `seedClone` pushes and `cloneCheck` fetches run `hostgit.Command` with a custom shell function in `GIT_SSH_COMMAND`:

```go title="internal/vm/clone.go"
func gitSSHCommand(s vmssh.SSH, dir string) string {
	run := func(verb string) string {
		return "exec " + shell.QuoteJoin(append([]string{"ssh"}, s.CommandArgs(verb+" "+shell.Quote(dir))...))
	}
	return `f() { for c; do :; done; case $c in "git-receive-pack "*) ` + run("git-receive-pack") +
		` ;; "git-upload-pack "*) ` + run("git-upload-pack") + ` ;; esac; exit 1; }; f`
}
```

This function ignores every hostname, port, path, and option that `git` passes to `ssh` (defeating any `url.*.insteadOf`, `pushInsteadOf`, or modified remote URL in `.git/config`). It connects strictly to the VM's loopback port using `vmssh.SSH.CommandArgs` (`-F none`, pinned `UserKnownHostsFile` and `HostKeyAlias`, `StrictHostKeyChecking=yes`, `BatchMode=yes`, `IdentitiesOnly=yes`, `IdentityAgent=none`, `ForwardAgent=no`, `ClearAllForwardings=yes`) and executes only `git-receive-pack <dir>` or `git-upload-pack <dir>`.
- **Host remote registration (`freeRemote`):** Adds the first available remote name among `devvm`, `devvm-<slug>`, or `<vm-name>` pointing to `<vm-name>:<dir>`.

### Pre-deletion and remake safety checks (`cloneCheck`)

Before `devvm rm` deletes a `--clone` VM or `devvm clone off` remakes it to mount the host folder, `cloneCheck.run` starts the VM (if stopped) and inspects the guest clone (reading at most `cloneOutMax = 1 MiB` per command):

1. **Unfetched commits (`unfetchedRefs` & `fetchUnfetched`):** Reads `git for-each-ref --format=%(objectname) %(refname) refs/heads` from the VM. For each 40–64 hex SHA not already reachable from any ref in the host repository (`git rev-list -n1 <sha> --not --all`), `devvm` validates the branch name with `git check-ref-format` and offers to fetch the branches into `refs/heads/saved/<remote>/<branch>` (or `saved/<remote>-2` through `-99` via `savedPrefix`, never overwriting existing local branches) using `git fetch --no-tags --no-recurse-submodules --refmap=`.
2. **Uncommitted changes across worktrees (`changes`):** Runs `git worktree list --porcelain -z` in the VM, filters to clean absolute paths inside the clone directory (`insideClone`), and checks `git status --porcelain` in the main clone and up to `cloneWorktreesMax = 100` linked worktrees. Displays up to `cloneShown = 20` modified/untracked lines (sanitized through `msg.OneLine`) and prompts for confirmation before deleting or remaking. If the VM has more than 100 linked worktrees, it always counts as dirty and warns with `clone_worktrees_unchecked`.
3. **Preserving fetched remote-tracking branches (`removeRemote` & `saveFetched`):** When removing the host's `devvm` remote, `saveFetched` checks `refs/remotes/<remote>/*` for any commits not reachable from other refs (`--not --exclude=refs/remotes/<remote>/* --all`) and atomically creates local `saved/<remote>/<branch>` refs via `git update-ref --stdin` before running `git remote remove`.

<Note>
Both `fetchUnfetched` and `changes` require an interactive terminal confirmation and are never auto-answered by `--force` (if run with `--no-input` or without a TTY, `devvm` refuses and keeps the VM). Passing `--no-clone-check` to `devvm rm` or `devvm clone off` bypasses the guest inspection checks with a warning while still preserving already-fetched `refs/remotes/<remote>/*` commits into `saved/<remote>/*`.
</Note>

## Related pages

<CardGroup cols={2}>
  <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="Network tiers and host proxy" href="/network-tiers-and-proxy">
    Traffic confinement across allowlist, open, and offline 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 to allowed hosts.
  </Card>
  <Card title="CLI reference: network, security, and agent commands" href="/cli-security-network-and-agent-commands">
    Syntax, subcommands, and flags for devvm network, secret, agent, git-protect, clone, ssh-agent, link, and group.
  </Card>
</CardGroup>
