{"id":50900725,"url":"https://github.com/katspaugh/machine","last_synced_at":"2026-06-16T02:04:35.587Z","repository":{"id":357645295,"uuid":"1237879977","full_name":"katspaugh/machine","owner":"katspaugh","description":"One isolated Lima VM per GitHub project — sandboxed Claude Code/Codex, Docker, Node, signed git","archived":false,"fork":false,"pushed_at":"2026-06-10T07:24:15.000Z","size":2220,"stargazers_count":7,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-10T09:13:13.601Z","etag":null,"topics":["ai-agents","claude-code","developer-tools","lima","macos","sandbox","vm"],"latest_commit_sha":null,"homepage":"https://runmachine.dev","language":"Python","has_issues":false,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/katspaugh.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-13T15:41:44.000Z","updated_at":"2026-06-10T07:24:19.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/katspaugh/machine","commit_stats":null,"previous_names":["katspaugh/machine"],"tags_count":11,"template":false,"template_full_name":null,"purl":"pkg:github/katspaugh/machine","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/katspaugh%2Fmachine","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/katspaugh%2Fmachine/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/katspaugh%2Fmachine/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/katspaugh%2Fmachine/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/katspaugh","download_url":"https://codeload.github.com/katspaugh/machine/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/katspaugh%2Fmachine/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34387479,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-16T02:00:06.860Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ai-agents","claude-code","developer-tools","lima","macos","sandbox","vm"],"created_at":"2026-06-16T02:04:35.046Z","updated_at":"2026-06-16T02:04:35.580Z","avatar_url":"https://github.com/katspaugh.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# machine — one ready-to-work dev VM per project\n\n[![CI](https://github.com/katspaugh/machine/actions/workflows/ci.yml/badge.svg)](https://github.com/katspaugh/machine/actions/workflows/ci.yml)\n[![Smoke](https://github.com/katspaugh/machine/actions/workflows/smoke.yml/badge.svg)](https://github.com/katspaugh/machine/actions/workflows/smoke.yml)\n\n[runmachine.dev](https://runmachine.dev/)\n\n![machine](assets/banner.svg)\n\nIsolated VMs are table stakes — an empty sandbox still costs you an afternoon before an agent can do anything in it. `machine` boots each GitHub project a Lima VM that's ready to work: Docker, Node, agent CLIs (Claude Code, Codex), GitHub CLI (`gh`), signed git, and opt-in tool profiles (Cypress, Playwright, Supabase + flyctl, modern CLI tools). No host filesystem mount, no cross-project bleed, and your private keys never leave the host.\n\nClaude Code comes pre-installed with the official marketplace and these plugins enabled: `frontend-design`, `superpowers`, `github`, `typescript-lsp`, `security-guidance`, `commit-commands`, `chrome-devtools-mcp`, `supabase`. Permission `defaultMode` is set to `auto`.\n\n## Why\n\nAI coding agents are most useful with full autonomy — and full autonomy on\nyour host means access to your keys, your other projects, and everything\n`npm install` drags in. A bare sandbox fixes the safety problem and creates a\nsetup problem. `machine` solves both: each project gets a disposable VM that\ncomes up already provisioned for agent work — toolchain installed, git auth\nand signing wired through the forwarded SSH agent (private keys stay on the\nhost; the VM can use them through the agent while you're connected, but never\nread them — see [Threat model](#threat-model)), secrets rendered into tmpfs\nand gone on reboot. \"Yes to everything\" stops risking your laptop, and there's\nno morning of setup before it's a useful answer.\n\nRead the guide: [Sandboxing Claude Code](https://runmachine.dev/sandboxing-claude-code/).\n\n### Claude Code already has a sandbox — why a VM?\n\nClaude Code's built-in sandbox wraps individual commands in OS-level rules on\nyour host (Seatbelt on macOS, bubblewrap on Linux): writes and network are\nallow-listed, reads mostly aren't, and it's still your kernel, your user\naccount, and your real working copy. Anything a command legitimately needs\noutside the rules lands you back at permission prompts — or at \"run\nunsandboxed\". A VM moves the whole session onto a separate machine instead of\nfencing commands on yours, and solves the environment problem (toolchain,\nbrowsers, Docker) in the same move. The two compose: the built-in sandbox\nkeeps working inside the VM as defense-in-depth. Full comparison — including\ndevcontainers, Tart, and Apple's `container` — in the\n[sandboxing guide](https://runmachine.dev/sandboxing-claude-code/#comparison).\n\n## Install\n\n`machine` runs on **macOS 13 (Ventura) or newer — Apple Silicon or Intel**.\nThe VMs boot through Lima's `vz` driver (Apple's Virtualization framework), so\nmacOS is the only supported host; Linux and Windows hosts won't work. Guests\nare Ubuntu 24.04, arm64 or amd64 to match your CPU.\n\n```sh\nbrew install katspaugh/machine/machine\n```\n\nThe formula pulls in `lima` (**2.0 or newer is required** — template composition and `mode: data` provisioning don't exist in 1.x) and `python@3.12`. The tap repo is [katspaugh/homebrew-machine](https://github.com/katspaugh/homebrew-machine); each release is pinned to a tagged tarball + SHA256. See [docs/TAP.md](docs/TAP.md) for the release runbook.\n\nPrefer to run from a clone (dev mode)? Skip the brew install and:\n\n```sh\ngit clone git@github.com:katspaugh/machine.git ~/Sites/machine\n~/Sites/machine/bin/machine doctor\n```\n\nIn dev mode `projects.toml` lives at the repo root; under brew it lives at `~/.config/machine/projects.toml` (override with `MACHINE_CONFIG_DIR`).\n\n### Nix\n\nThe repo is also a flake — no Homebrew needed:\n\n```sh\nnix profile install github:katspaugh/machine   # install\nnix run github:katspaugh/machine -- doctor     # or run one-off\n```\n\nThe flake pins its own Lima (≥ 2.0) and Python from nixpkgs-unstable. Pin a release with `github:katspaugh/machine/v0.2.0`.\n\n## Prerequisites\n\n- A Mac on macOS 13 or newer (Apple Silicon or Intel) — see [Install](#install).\n- An SSH key on the host, served by an agent the VM can forward. Either:\n  - **macOS Keychain** (default): `ssh-add --apple-use-keychain ~/.ssh/id_ed25519`\n  - **1Password**: enable 1Password → Settings → Developer → *Use the SSH agent* — `machine` detects the agent socket and forwards it automatically (see [SSH agent](#ssh-agent) below).\n- That key registered as a **signing key** on GitHub (Settings → SSH and GPG keys → New SSH key → Key type: Signing).\n- Host `git config --global user.name` and `user.email` set (or override via `GIT_NAME` / `GIT_EMAIL`).\n\nRun `machine doctor` to verify everything resolves.\n\n## Setup\n\nNo setup needed — `machine up \u003cname\u003e` for a new name launches the\n`machine create` wizard, which asks for repos, profiles, and shell, writes\nthe entry to `~/.config/machine/projects.toml`, and continues straight into\nprovisioning. A bare `machine up` does the same on first run; once a\n`default` VM (or `[default]` entry) exists, it boots that non-interactively.\n`machine up default` always skips the wizard and gives a config-less scratch\nVM. To author the config by hand instead:\n\n```sh\nmachine init                  # writes ~/.config/machine/projects.toml from the bundled example\n$EDITOR ~/.config/machine/projects.toml\n```\n\n`machine create \u003cname\u003e` re-run on an existing project edits its entry —\nevery prompt defaults to the current value.\n\n(In dev mode: `cp projects.toml.example projects.toml \u0026\u0026 $EDITOR projects.toml` from the repo root.)\n\nExample `projects.toml`:\n\n```toml\n# Projects without `profiles` get the base VM only. To opt every project\n# into a profile by default: default_profile = \"cypress\"\n\n[blog]\nrepos = [\"git@github.com:you/blog.git\"]\n\n# Multi-repo: sibling-clones in one VM. The first is the \"primary\" —\n# `machine ssh wallet` opens at its directory. A heavy monorepo gets\n# more than the default 4 CPUs / 8GiB / 30GiB disk.\n[wallet]\nprofiles = [\"cypress\"]\ncpus = 8\nmemory = \"16GiB\"\ndisk = \"60GiB\"\nrepos = [\n  \"git@github.com:you/safe-wallet-dev-env.git\",\n  \"git@github.com:you/safe-wallet-monorepo.git\",\n  \"git@github.com:you/safe-client-gateway.git\",\n]\n\n# Multiple profiles stack.\n[playground]\nprofiles = [\"cypress\", \"supabase-fly\"]\nrepos = [\"git@github.com:you/playground.git\"]\n```\n\n## How it works\n\n`machine up \u003cproject\u003e` generates a tiny Lima template in `.build/\u003cproject\u003e/lima.yaml`:\n\n```yaml\nbase:\n- \u003crepo\u003e/templates/cypress.yaml     # one entry per profile (reversed)\n- \u003crepo\u003e/templates/base.yaml        # the whole base VM, declaratively — listed\n                                    # last so its provisioning runs first\n```\n\nLima merges the stack (`base:` composition), boots the VM, and runs the\nprovisioning declared in the templates: `provision/*.sh` scripts and\n`mode: data` dotfiles, applied by cloud-init on **every boot** — so\nre-provisioning is just `machine down \u0026\u0026 machine up`. Your git identity and\nsigning key flow in as Lima params (`--set`) at create time and render into\n`~/.gitconfig` inside the VM. Ports: Lima auto-forwards any listening guest\nport to `127.0.0.1` on the host.\n\nTo update the toolchain in place: `machine down \u003cp\u003e \u0026\u0026 machine up \u003cp\u003e`\n(provision scripts re-run; apt picks up new versions). To start truly fresh:\n`machine destroy \u003cp\u003e \u0026\u0026 machine up \u003cp\u003e`. Changing your git identity, signing\nkey, `forward_agent`, or VM resources (`cpus`/`memory`/`disk`) requires a\nrecreate (they're fixed when the VM is created).\n\n### SSH config\n\nLima writes a per-VM SSH config at `~/.lima/\u003cproject\u003e/ssh.config`. Add one line to `~/.ssh/config`:\n\n```\nInclude ~/.lima/*/ssh.config\n```\n\nThen `ssh lima-\u003cproject\u003e` works everywhere.\n\n## Quickstart\n\n```sh\nmachine up blog            # new name: wizard writes the [blog] entry, then creates + starts + provisions VM \"blog\", clones the repo\nmachine up default         # zero-config: creates + starts a base VM named \"default\", no wizard\nmachine ssh blog           # interactive shell, cwd = ~/code/blog\n```\n\n![demo](assets/machine.gif)\n\nThe first `up` bakes a provisioned base disk into `~/.cache/machine` (one-time per\ntemplate/provision change); subsequent boots reuse it. `limactl start` blocks\nuntil provisioning finishes — on failure it points you at\n`limactl shell \u003cvm\u003e sudo tail -100 /var/log/cloud-init-output.log`.\n\nInside the VM, each repo is at `~/code/\u003crepo-basename\u003e/`. JS deps are installed automatically on first clone (yarn / pnpm / npm, picked from `packageManager` in `package.json`). For env vars, drop a `.env` file in the project — Node's `dotenv` (or your framework) reads it directly. For secrets you'd rather not write to disk, see [1Password env injection](#1password-env-injection).\n\nHost browser → VM web app: Lima auto-forwards any listening guest port to `127.0.0.1` on the host.\n\n## IDE integration (VS Code, Cursor, JetBrains Gateway)\n\nWith the `Include` line from [SSH config](#ssh-config) in place, any IDE that reads\nSSH config sees every VM. The host alias for a project is `lima-\u003cproject\u003e`. In VS\nCode → Remote-SSH: open the host picker, pick `lima-\u003cproject\u003e`, then open\n`/home/\u003cvm-user\u003e.linux/code/\u003crepo\u003e`. Same\nflow in Cursor and JetBrains Gateway. Lima's config sets `ForwardAgent yes` (unless the\nproject opts out with `forward_agent = false`), so commit signing and `gh` work in the\nIDE's integrated terminal just like in `machine ssh`.\n\nBecause Lima owns the config file, it stays correct across `up`/`down`/`destroy`\nautomatically — there is no host `~/.ssh/config` block for `machine` to manage.\n\nPrefer a terminal editor? Skip the Remote-SSH plugin entirely and run one inside the\nmachine: `machine run wallet hx` launches [Helix](https://helix-editor.com/) (likewise\n`vim`, `nvim`, `nano`) over your existing connection, opening in the project's repo\n(`~/code/\u003crepo\u003e`) so you can view and edit any file without anything editor-shaped on the\nhost.\n\n## Commands\n\n| Command | What |\n|---|---|\n| `machine up [p]` | Create if needed, start, provision, clone the repo(s). Idempotent — re-running re-applies the provision scripts. New names (and a bare `up` with no `default` VM yet) run the create wizard first; `up default` stays a config-less base VM. |\n| `machine create [p]` | Wizard: add a project entry to `projects.toml` (repos, profiles, shell, agent forwarding) — or edit an existing one; prompts default to current values, comments in the file are preserved. |\n| `machine down \u003cp\u003e` | Stop the VM (preserves disk). Re-provision in place with `machine up \u003cp\u003e` afterwards. |\n| `machine ssh \u003cp\u003e` | Interactive shell (cwd = `~/code/\u003cprimary-repo\u003e`). |\n| `machine claude \u003cp\u003e` | Launch `claude` in a tmux session in the VM (cwd = `~/code/\u003cprimary-repo\u003e`). Detach with `ctrl-b d` — claude keeps running; re-run to reattach. Exiting `claude` ends the session. |\n| `machine run \u003cp\u003e \u003ccmd\u003e...` | Run a command in the VM (cwd = `~/code/\u003cprimary-repo\u003e`). stdio is passed straight through, so full-screen TUIs work too — e.g. `machine run wallet hx` launches the [Helix](https://helix-editor.com/) editor inside the machine, in the project's repo, to view and edit any file. |\n| `machine list` | List VMs (`limactl list`) plus configured-but-not-yet-created projects. |\n| `machine destroy \u003cp\u003e` | Delete the VM. `-y` skips confirmation. |\n| `machine bake` | Build/refresh the cached base disk in `~/.cache/machine` used by `up`. `--force` rebuilds even if the cache hash is fresh. |\n| `machine secrets \u003cp\u003e [--repo \u003cr\u003e]` | Render 1Password Environment(s) into VM tmpfs ([1Password env injection](#1password-env-injection)). `--clear` wipes them. |\n| `machine init` | Write `projects.toml` to `~/.config/machine/` from the bundled example. |\n| `machine doctor` | Preflight host checks: lima, SSH agent keys, git identity, signing-key resolution, `op` CLI note, `projects.toml` presence. |\n\n## Repository layout\n\n```\nmachine/\n├── bin/machine             # host CLI: renders the Lima stack, drives limactl\n├── templates/              # Lima templates composed via `base:`\n│   ├── base.yaml           #   the whole base VM (resources, params, dotfiles, base.sh)\n│   ├── cypress.yaml        #   one file per profile — points at provision/\u003cname\u003e.sh\n│   ├── supabase-fly.yaml\n│   ├── files -\u003e ../files   #   symlinks (Lima v2 forbids '../' in file: locators)\n│   └── provision -\u003e ../provision\n├── provision/              # provision scripts run by cloud-init inside the VM\n│   ├── base.sh             #   apt repos + packages, Docker, Node, Claude, npm globals\n│   ├── base-user.sh        #   per-user setup (shell, claude plugins)\n│   ├── cypress.sh          #   profile scripts, one per templates/\u003cname\u003e.yaml\n│   └── supabase-fly.sh\n├── files/                  # data placed into each VM via `mode: data`\n│   ├── zsh/                #   ~/.zshrc\n│   ├── profile.d/          #   /etc/profile.d snippets (PATH, direnv)\n│   ├── direnv/             #   `use op_env` helper for 1Password env injection\n│   └── ssh/                #   pre-seeded known_hosts\n├── projects.toml.example   # template for your projects.toml (the real one is gitignored)\n├── completions/            # bash/zsh/fish completions for the `machine` CLI\n├── tests/                  # tests/lint.sh, tests/unit.sh (host); tests/smoke-*.sh (in-VM)\n├── assets/                 # README gif/banner + VHS recording script (not deployed)\n└── .github/workflows/      # CI: lint + unit\n```\n\n```mermaid\nflowchart TB\n    user([\"You (host)\"]) --\u003e projects[\"projects.toml\"]\n    user --\u003e cli[\"bin/machine\"]\n    cli --\u003e projects\n    cli --\u003e|render .build/\u0026lt;p\u0026gt;/lima.yaml| stack[\"base: profiles… + base.yaml\"]\n    stack --\u003e tpls[\"templates/*.yaml\"]\n    cli --\u003e|\"--set .param.gitName/Email/signingKey/shell\"| params[\"Lima params\"]\n    cli --\u003e|limactl create / start| vm[(\"Lima VM\")]\n    tpls --\u003e vm\n    params --\u003e|render ~/.gitconfig \u0026\u003cbr/\u003eallowed_signers| vm\n    vm --\u003e|cloud-init, every boot| prov[\"mode:data dotfiles\u003cbr/\u003e+ provision/*.sh\"]\n```\n\nEverything under `files/` is data placed into the VM (`mode: data` entries in `templates/base.yaml`). `provision/*.sh` are the scripts cloud-init runs inside the VM on every boot. `bin/machine`, the `templates/`, `projects.toml.example`, `completions/`, and `tests/` are host-side code/config. `assets/` contains README media only; nothing under `assets/` is pushed to a VM.\n\nWhat happens on `machine up \u003cp\u003e`:\n\n- If no fresh base disk is cached, `machine` bakes one into `~/.cache/machine` (a provisioned base VM exported once per template/provision change).\n- Render `.build/\u003cp\u003e/lima.yaml`: a `base:` stack of the project's `templates/\u003cprofile\u003e.yaml` (reversed) plus `templates/base.yaml` last, with the cached base disk prepended as the top-priority image.\n- If the VM doesn't exist, `limactl create --name=\u003cp\u003e --set '.param.gitName=…' …` against that template (git identity + signing key arrive as params), then `limactl start \u003cp\u003e` — which blocks until the provisioning probe passes.\n- cloud-init applies the `mode: data` dotfiles and runs `provision/base.sh` then the profile scripts, on every boot, idempotently.\n- Clone the listed `repos` into `~/code/\u003cbasename\u003e/`.\n\nGitHub auth and commit signing both use the forwarded SSH agent. Private keys never leave the host — but forwarding cuts both ways: while a session is open, anything inside the VM can ask the agent to sign and authenticate with **every** key it holds, not just for this project's repos. See [Threat model](#threat-model) for what that grants and [Restricting the forwarded agent](#restricting-the-forwarded-agent) for stricter setups.\n\n## Provisioning\n\nA profile is a small `templates/\u003cname\u003e.yaml` + `provision/\u003cname\u003e.sh` pair. The template\nlists the script as a `provision:` entry; the generated per-project stack composes the\nbase plus each profile via Lima's `base:` mechanism. Provisioning is just shell — there\nis no separate config format to learn.\n\n- **base** (`templates/base.yaml` + `provision/base.sh`, `base-user.sh`) — always applied.\n  Third-party apt repos (Docker, GitHub CLI, NodeSource for Node), apt packages, Docker,\n  Node + corepack package managers, Claude Code + its marketplace/plugins, npm globals,\n  the dotfiles under `files/`, and git identity/signing rendered from params.\n- **`cypress`** — Cypress runtime libs + Chrome (amd64) or Chromium (arm64), Xvfb.\n- **`playwright`** — OS deps for Playwright's browsers (via `playwright install-deps`);\n  browser binaries stay per-repo (`npx playwright install`, no sudo needed).\n- **`supabase-fly`** — Supabase CLI (GitHub `.deb` release) + flyctl (vendor installer).\n\nTo add a profile: copy an existing `templates/\u003cname\u003e.yaml`, point it at a new\n`provision/\u003cname\u003e.sh`, and reference the profile name in `projects.toml`. Scripts run as\nroot by default (`mode: system`); use `mode: user` for per-user steps. Keep them\nidempotent — they re-run on every boot.\n\n## Verifying\n\n```sh\nbash tests/run-all.sh \u003cproject\u003e     # full VM smokes (boot + docker + node + git-sign + …)\nbash tests/unit.sh                  # host-side Python unit tests (no VM)\nmachine doctor                      # preflight host environment\n```\n\n`tests/run-all.sh` requires a provisioned VM (set `MACHINE_NAME=\u003cproject\u003e` or pass the project as arg 1). `tests/unit.sh` runs offline.\n\n## Shell completion\n\nBash, zsh, and fish completions ship under `completions/`:\n\n```sh\n# bash\necho 'source /path/to/machine/completions/machine.bash' \u003e\u003e ~/.bashrc\n\n# zsh (somewhere in $fpath)\nln -s \"$PWD/completions/_machine\" /usr/local/share/zsh/site-functions/_machine\n\n# fish\nln -s \"$PWD/completions/machine.fish\" ~/.config/fish/completions/machine.fish\n```\n\n## SSH agent\n\n`machine` picks the agent to forward automatically: if 1Password's SSH agent socket exists (Settings → Developer → *Use the SSH agent*), it forwards that — keys never touch `~/.ssh`, every signature prompts for Touch ID. Otherwise it forwards whatever the host's `SSH_AUTH_SOCK` points at — on macOS that's launchd's agent, which serves keys you loaded with `ssh-add --apple-use-keychain` (passphrase cached in Keychain).\n\nTo force the Keychain agent while 1Password's agent is enabled, point `ONEPASS_SOCK` at a non-socket path (e.g. `ONEPASS_SOCK=/dev/null machine up \u003cproject\u003e`).\n\nFor the git signing pubkey, the resolution order is:\n\n1. `GIT_SIGNING_KEY=\u003cliteral pubkey string\u003e`\n2. `OP_SIGNING_KEY_REF=op://Vault/Item/public_key` — fetched via `op read` (requires `op` CLI; triggers Touch ID once at `up` time)\n3. `GIT_SIGNING_PUBKEY_FILE=\u003cpath\u003e`\n4. Host `git config --global user.signingkey` — literal pubkey or path to a `.pub` file (default; whatever you sign host commits with)\n\n### Restricting the forwarded agent\n\nForwarding is the convenience default, and it is a real grant: an SSH agent\nperforms arbitrary auth operations, so while a forwarded connection is open,\nanything inside the VM — a compromised dependency, a prompt-injected agent —\ncan authenticate and sign as you with **every** key the agent holds. `git push`\nto any repo your key can access is in scope, not just this project's. (Lima\nkeeps a persistent SSH master alive from your first shell until the VM stops,\nso treat the channel as open whenever the VM is running and you've connected.)\nPick the friction that matches the trust level of the project:\n\n- **Per-use approval (1Password agent).** 1Password can require approval /\n  Touch ID for each agent request and lets you choose how long an approval\n  lasts (Settings → Developer → Security). Keep the authorization window short\n  for untrusted work, and an injected `git push` becomes a prompt you get to\n  decline.\n- **Confirmation-gated key (Keychain agent).** Load the key with\n  `ssh-add -c ~/.ssh/id_ed25519` — the agent then asks for confirmation on\n  every use. macOS needs an askpass helper to show the dialog\n  (`brew install theseal/ssh-askpass/ssh-askpass`).\n- **No forwarding + a per-project deploy key (strictest).** Set\n  `forward_agent = false` for the project in `projects.toml` — the generated\n  template overrides `ssh.forwardAgent`, and the VM gets no channel to your\n  agent at all. Generate a key inside the VM (`ssh-keygen -t ed25519`) and add\n  its pubkey to the repo as a [deploy key](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys)\n  (write access only if it should push): a compromised VM can then reach only\n  that repo. With repos listed, the first `machine up` warns that the clone\n  needs the deploy key instead of failing. Commit signing can't use your host\n  key either — either register the VM key as a signing key on GitHub and\n  `git config --global user.signingkey ~/.ssh/id_ed25519.pub` inside the VM,\n  or `git config --global commit.gpgsign false` there. Like the git params,\n  changing `forward_agent` takes effect on recreate\n  (`machine destroy \u003cp\u003e \u0026\u0026 machine up \u003cp\u003e`).\n\n## 1Password env injection\n\nFor project secrets you don't want to write to disk, drop a `.envrc` in the repo referencing a 1Password [Environment](https://developer.1password.com/docs/cli/environments/) ID:\n\n```sh\necho 'use op_env \u003cenvironment-id\u003e' \u003e .envrc\ndirenv allow\n```\n\nThen on the host:\n\n```sh\nmachine secrets \u003cproject\u003e               # syncs every .envrc using `use op_env` in that VM\nmachine secrets \u003cproject\u003e --repo \u003crepo\u003e # narrow to one repo within a multi-repo project\n```\n\n`machine secrets` reads the Environment from 1Password (Touch ID), pipes the rendered KEY=value pairs into the VM, and writes them to `$XDG_RUNTIME_DIR/dev-secrets/\u003cenv-id\u003e.env` (tmpfs, mode 600, gone on reboot). The `op_env` direnv helper loads that cache when you `cd` into the project. No host-side disk path is involved.\n\nCreate an Environment in 1Password desktop: Developer → Environments → New. Copy its ID via Manage environment → Copy ID.\n\n## Threat model\n\nNo host filesystem is mounted. Each project gets its own VM, so a compromise of one project can't reach another's code or env. The host exposes two narrow channels:\n\n- The **forwarded SSH agent**. Private keys never leave the host and the VM cannot read them — but it can *use* them: while a forwarded connection is open, anything inside the VM can ask the agent to sign commits and to authenticate as you with every key the agent holds. That means a compromised or prompt-injected agent in one VM could `git push` to **any repo your key authorizes**, not just this project's. The isolation here is read-protection of the key material and a channel that dies with the VM — not a per-project scope. To narrow the grant, use per-use approval (1Password), a confirmation-gated key (`ssh-add -c`), or drop forwarding entirely in favor of a per-project deploy key — see [Restricting the forwarded agent](#restricting-the-forwarded-agent).\n- **`machine secrets`** pushing rendered 1Password Environments into tmpfs (no disk persistence). A fully compromised VM cannot read the 1Password vault — only the secrets a repo explicitly rendered, and only while that tmpfs lives.\n\n## Override knobs\n\n| Env var | Default |\n|---|---|\n| `GIT_NAME` / `GIT_EMAIL` | from host `git config --global` |\n| `GIT_SIGNING_PUBKEY_FILE` | path to a `.pub` file (overrides host `user.signingkey`) |\n| `GIT_SIGNING_KEY` | literal pubkey string (overrides everything) |\n| `OP_SIGNING_KEY_REF` | 1Password secret reference for the signing pubkey (e.g. `op://Personal/SSH/public key`) |\n| `ONEPASS_SOCK` | 1Password agent socket path (default `~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock`); auto-forwarded when it exists |\n| `PROJECTS_FILE` | `\u003crepo\u003e/projects.toml` in dev mode; `~/.config/machine/projects.toml` under Homebrew |\n| `MACHINE_CONFIG_DIR` | config-directory location (`~/.config/machine` by default) |\n| `MACHINE_STATE_DIR` | generated-state location (`\u003crepo\u003e/.build` in dev mode; `~/.local/state/machine` under Homebrew) |\n\n## Troubleshooting\n\nRun `machine doctor` first — it catches most of these before a VM is involved.\n\n**`machine up` fails before the VM boots with a template error** (`unknown\nfield`, `failed to unmarshal`, or similar). You're on Lima 1.x — `machine`\nneeds Lima ≥ 2.0 (`base:` template composition and `mode: data` provisioning\ndon't exist in 1.x). Check `limactl --version`, then `brew upgrade lima`.\n\n**`git clone` / `git push` inside the VM fails with `Permission denied\n(publickey)`.** The forwarded agent has no usable keys. On the host, `ssh-add\n-l` must list at least one key: for the Keychain agent run `ssh-add\n--apple-use-keychain ~/.ssh/id_ed25519`; for 1Password, unlock the app (a\nlocked 1Password agent serves nothing). If the agent was empty or locked when\nthe VM connection was first opened, just re-run `machine ssh` — it detects the\nstale connection and rebuilds it against the live agent.\n\n**Pushes work but commits show \"Unverified\" on GitHub.** GitHub registers SSH\nkeys for *authentication* and *signing* separately. Add the same public key a\nsecond time at Settings → SSH and GPG keys → New SSH key, with Key type:\n**Signing**. (The reverse confusion — key added only as Signing — makes\nsigning work and `git clone` fail.)\n\n**`git commit` fails with `agent refused operation`.** The agent declined to\nsign — with 1Password that means the app is locked or you dismissed the Touch\nID prompt. Unlock 1Password and expect one Touch ID prompt per signature.\n\n**Provisioning fails or `machine up` times out.** Read the cloud-init log:\n`limactl shell \u003cvm\u003e sudo tail -100 /var/log/cloud-init-output.log`. Then\nre-run `machine up \u003cp\u003e` — provisioning is idempotent, and transient apt or\nnetwork failures usually clear on the second pass.\n\n**The VM hangs at boot after your host disk filled up.** A full host disk can\nleave the guest's ext4 filesystem flagged with errors, and the VM never comes\nup. Free host space first; then `machine destroy \u003cp\u003e \u0026\u0026 machine up \u003cp\u003e` — the\ncached base disk makes the rebuild fast. If you need data out of the old VM,\nits disk image (under `~/.lima/\u003cp\u003e/`) can be repaired offline with `e2fsck`\nbefore you destroy it.\n\n**Changed `GIT_NAME` / `GIT_EMAIL` / signing key, but the VM still uses the\nold one.** Identity flows in as Lima params, fixed when the VM is created.\n`machine destroy \u003cp\u003e \u0026\u0026 machine up \u003cp\u003e` to re-create with the new values.\n\n**`machine secrets` fails to read an Environment.** It needs the `op` CLI\n(`brew install 1password-cli`) and the desktop-app integration enabled:\n1Password → Settings → Developer → *Integrate with 1Password CLI*.\n\n**1Password's agent is enabled but you want the Keychain agent.** Point\n`ONEPASS_SOCK` at a non-socket path: `ONEPASS_SOCK=/dev/null machine up \u003cp\u003e`.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) — the profile-authoring walkthrough\nlives there. Bug reports should include `machine doctor` output.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkatspaugh%2Fmachine","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkatspaugh%2Fmachine","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkatspaugh%2Fmachine/lists"}