Secrets
sand keeps a per-VM store of KEY=VALUE secrets on your host and applies
them into each VM's guest environment as it starts.
Where secrets live on the host
Secrets are stored in a single JSON file at:
The file is written mode 0600, and its parent directory 0700, so it is
not readable by other users on the host. That is the only protection it
gets — the file is unencrypted, plaintext JSON. Anything you put in it
is stored on disk exactly as you typed it. Decide what you're willing to
keep there accordingly.
For the exact schema and how it's kept there, see Files and State.
Scopes: global vs. per-directory
Secrets are organized into scopes. The global scope applies to the
whole VM. Any other scope is a directory path relative to the guest home,
such as foo/bar. This directory scope is entirely within one VM — it's
unrelated to which Connection Profile that VM
runs on. Since the same VM name can exist under two different profiles at
once (see Connection Profiles),
the secrets store also keys everything by which profile's connection a VM
belongs to first, so editing claude's secrets on your work profile never
touches a same-named claude on local. You never see or manage that
connection-scope key directly — e on a tile always opens the right VM's
secrets, on whichever profile that tile's VM actually lives on. See
Files and State
for how the two scope dimensions are kept distinct on disk.
Where each scope lands in the guest:
| Scope | Guest location |
|---|---|
| Global (default) | ~/.config/sandbar/secrets.env, sourced from both ~/.profile and ~/.bashrc |
foo/bar |
~/foo/bar/.env |
A scoped .env is written in direnv's dotenv format
and approved with direnv allow, so a repo-scoped secret shows up as an
.env file direnv picks up automatically the moment your shell cds into
that repo's working directory — you don't have to source anything by hand.
Claude Code sessions get the same treatment even though direnv's shell hook
only fires at interactive prompts: the provisioned ~/.claude/settings.json
carries SessionStart and CwdChanged hooks that run direnv export for
the session's working directory. So a session working in a repo sees that
repo's scoped secrets, wherever it was launched from — including sessions
dispatched via claude agents, which would otherwise inherit only the
environment of the directory the supervisor was started in.
Editing secrets
Press e on a tile to open the secrets editor. It's a plain text buffer:
one KEY=VALUE pair per line for the global scope, and a [scope] header
line to start a new section for anything else, for example:
Saving applies immediately to a running VM, or on the VM's next start if it's stopped — editing does not require the VM to be up.
How secrets reach the guest
Every secret value is streamed into the guest over the command's stdin as
it's written — never passed as a command-line argument — so a secret
never appears in a host ps listing. Each guest-side file is created at
mode 0600 before any bytes are written to it, so there is no instant at
which a world-readable file could hold a secret.
Deleting a VM (d on a tile) removes its host-stored secrets along with
its disk.
GitHub tokens
A key named GH_TOKEN gets special handling on top of the generic scope
mechanism above: for any non-empty (directory) scope, sand also wires
git/gh credentials for that subtree, via a git includeIf
"gitdir:~/<scope>/" stanza that points at a generated credential helper.
Put a token under [github.com/acme] and git/gh authenticate
automatically for anything under ~/github.com/acme/ — no manual gh auth
login, no per-repo credential setup. This is a convention read by a small,
fixed table of recognized token names (internal/provision/gitcred.go), not
a feature of the secrets store itself — the store only ever holds
(scope, KEY, VALUE) triples and has no idea what GitHub is.
The global scope is the one exception. A GH_TOKEN with no scope is
still delivered to the guest as a plain environment variable, but it does
not get the automatic git-credential wiring — that only fires for a
named, non-empty scope. See below for where the create-time clone token
lands by default.
Creating a fine-grained token
Use a GitHub fine-grained personal access token, scoped to the repositories the VM should touch and set to expire. It's important to give the agent a token with minimal access, so if it goes off track it's limited in the damage it can do. See further details in the Security Model.
Supplying it and where it lands
Provide the token at VM-create time — the TUI create form's GitHub token
field, or sand create --clone-token — alongside a repo URL to clone. From
there:
- It's written into the cloned repo's per-org
.envasGH_TOKEN(treat that file as a secret; it's what makes the git/gh wiring above work for that directory). - The create form seeds it into the host secrets store's global scope,
so — per the exception above — it does not get the automatic
git-credential wiring on its own. The per-org
.envwritten at clone time is what authenticates~/<host>/<org>/. To get the same token wired for other scopes too, copy it into a[host/org]section with the secrets editor (e). - It's applied to the guest's
~/.config/sandbar/secrets.envon every VM start (see scopes above), so it's always present in the environment, even after a reset.
Precedence and multiple orgs
GH_TOKEN takes precedence over any token gh auth login stored, because
gh is the configured git credential helper — git/gh over HTTPS use
whichever token is in the environment. For multiple organizations or
clients, prefer a separate VM per org over juggling several tokens on
one VM: VMs are disposable, and this keeps each context's credentials and
code fully isolated.
Rotating, expiring, and revoking
Fine-grained tokens must have an expiry. When one expires or you rotate it,
update the secret in the secrets editor (e on the VM's tile) or re-supply
the new token the next time you create a VM, then revoke the old token in
GitHub's settings.
Reset and the token
The token lives in the host secrets store, not in the managed-VM index (see Files and State), so it survives a reset and is re-applied to the rebuilt VM. But a reset re-clones the project during its finalize pass, which runs before stored secrets are written into the guest — so resetting a VM that cloned a private repo still needs the clone token re-supplied on the reset form, unless you enable the reset form's project-directory preserve toggle, which skips the re-clone entirely. See Resetting a VM.