Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
355a262
docs: add design spec for profile secrets and templates
stempler Aug 20, 2026
3c8fc8e
docs: add implementation plan for profile secrets and templates
stempler Aug 20, 2026
7e5766b
feat: profiles declare secrets/vars and ship templates
stempler Aug 20, 2026
274111d
feat: user-side secrets.yaml and config vars
stempler Aug 20, 2026
f99fcbb
feat: template rendering and secret/var resolution
stempler Aug 20, 2026
8f12acf
feat: agent-privilege user-file push; migrate git identity to it
stempler Aug 20, 2026
fc90349
feat: resolve and push rendered templates at start, apply, and boot
stempler Aug 20, 2026
2c46256
fix: keep stderr chatter out of resolved secret values
stempler Aug 20, 2026
85bb073
feat: add code-vm secrets and surface secret declarations in profile …
stempler Aug 20, 2026
f7c00be
test: cover profile secrets/templates in the suite; document credentials
stempler Aug 20, 2026
2bc68b5
docs: resolve duplicate Credentials headings in README
stempler Aug 20, 2026
21794f3
fix: reject control characters in profile description/suggest strings
stempler Aug 20, 2026
2edf58e
fix: reject cross-profile files/-vs-templates/ collisions in LoadAll
stempler Aug 20, 2026
2d96fab
fix: resolve secrets/templates before mutating guest state in profile…
stempler Aug 20, 2026
c876f1e
fix: actionable upgrade-path error and relay hardening for PushUserFile
stempler Aug 20, 2026
926eba9
fix: close Trojan Source / C1 gap in isSingleLinePrintable
stempler Aug 20, 2026
81897fb
fix: resolve secrets/vars before mutating guest state in start/shell/…
stempler Aug 20, 2026
bb07114
fix: YAML-quote the suggested command in MissingSecretSnippet
stempler Aug 20, 2026
a0992f5
fix: reject Zl/Zp line-breaking characters in isSingleLinePrintable
stempler Aug 20, 2026
acf27c9
fix: clean up staged file when the user-file relay call fails
stempler Aug 20, 2026
cc68b67
fix: close status TOCTOU between resolve and boot decisions
stempler Aug 20, 2026
ed9bcc8
fix: clean up partial staged file when Copy fails
stempler Aug 20, 2026
b86faa8
test: assert nonzero exit on unmapped secret; fix README wording
stempler Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 125 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ code-vm # interactive shell
| `code-vm recreate` | Delete and rebuild the guest from scratch |
| `code-vm proxy-log [all\|denied\|allowed\|follow]` | Read the Squid access log |
| `code-vm allow [domain...]` | Add domains to the allowlist and apply them live |
| `code-vm secrets` | List secrets/vars the active profiles declare, mapped or not |
| `code-vm doctor` | Check host prerequisites |

## Configuration
Expand Down Expand Up @@ -106,29 +107,38 @@ the accelerated one for the host:
| Linux | `qemu` | KVM | the `virtiofsd` package |
| macOS 13.5+ | `vz` | Hypervisor.framework (HVF) | Virtualization.framework |

### Credentials
### No workspace credentials

There is **no credential injection mechanism**. If a build needs a private
registry, write the credential file into the guest home once — it persists across
restarts, because the guest disk is the sandbox's durable state:
Nothing agent- or workspace-authored is ever a credential source. The
previous `.sandbox-secrets.yaml` mechanism was removed rather than fixed: it
resolved each secret by running its `source:` command **on the host** — from
a file inside the workspace, which the agent can write. That is host command
execution reachable from inside the sandbox, which defeats the boundary the
whole design exists to draw. Its stated protection did not hold either:
rendered files were group-readable by the agent, and the generated deny
rules only matched commands where the path appeared as a separate argument,
which `python -c` (an allowed command) sidesteps. That class of mechanism
stays removed, and the integration suite pins it.

```bash
code-vm # shell into the guest
$ install -d -m 0700 ~/.gradle
$ cat > ~/.gradle/gradle.properties # paste, or pipe it in
```
Credentials enter the sandbox in one of two ways:

Assume the agent can read anything you put there, and use credentials created
for the sandbox rather than your personal ones, so revoking them is cheap.
- Written directly into the guest home, once — it persists across restarts,
because the guest disk is the sandbox's durable state:

The previous `.sandbox-secrets.yaml` mechanism was removed rather than fixed. It
resolved each secret by running its `source:` command **on the host** — from a
file inside the workspace, which the agent can write. That is host command
execution reachable from inside the sandbox, which defeats the boundary the whole
design exists to draw. Its stated protection did not hold either: rendered files
were group-readable by the agent, and the generated deny rules only matched
commands where the path appeared as a separate argument, which `python -c` (an
allowed command) sidesteps.
```bash
code-vm # shell into the guest
$ install -d -m 0700 ~/.gradle
$ cat > ~/.gradle/gradle.properties # paste, or pipe it in
```

Assume the agent can read anything you put there, and use credentials
created for the sandbox rather than your personal ones, so revoking them
is cheap.

- Through a profile's `templates/` tree (see [Credentials](#credentials) under
Profiles) — the host-trusted mechanism for sharing a credentialed config's
shape while keeping the credential itself in the user's own
`secrets.yaml`.

### Extending the allowlist

Expand Down Expand Up @@ -249,6 +259,102 @@ Notes:
revert the shell, or delete files already in the home. `code-vm recreate`
is the clean-slate path.

### Credentials

Profiles can also ship **templates**: files rendered from placeholders
before delivery, so a team can share the whole shape of a credentialed
config (proxies, mirrors, server IDs) while each user supplies only their
own credential sources.

```
wetf-maven/
profile.yaml
templates/ # home-mirroring tree, rendered before delivery
.m2/settings.xml
```

`profile.yaml` gains two more sections:

```yaml
secrets:
wetf-repo-user:
description: Artifactory user for wetf-snapshots/releases
suggest: gopass show -o wetf/artifactory-user # inert hint, never executed
wetf-repo-password:
suggest: gopass show -o wetf/artifactory-password
vars:
artifactory-url:
description: Base URL of the Artifactory instance
```

A template uses `${secret:name}` and `${var:name}` placeholders; anything
else — Maven properties, `${env.FOO}` — passes through untouched. A shipped
`.m2/settings.xml` typically writes the static parts (proxies, mirrors)
verbatim and reserves placeholders only for the credentialed bits:

```xml
<settings>
<servers>
<server>
<id>wetf-snapshots</id>
<username>${secret:wetf-repo-user}</username>
<password>${secret:wetf-repo-password}</password>
</server>
</servers>
<proxies>...</proxies> <!-- shipped as-is, no placeholders needed -->
<mirrors>...</mirrors>
</settings>
```

`description` and `suggest` are inert display strings on the *host*: nothing
a profile ships is ever run there, and these two fields specifically are
never even parsed as commands. The user's own `secrets.yaml` `command` is
the only host execution in this mechanism. (Profile hooks are the deliberate
exception to "profiles don't execute" — they do run profile-shipped code,
but in the guest, as the agent, as described above.) Values come only from
the user's own mapping:

- **`~/.config/code-vm/secrets.yaml`** — 0600, host-trusted like
`config.yaml`, never distributed with the profile:

```yaml
secrets:
wetf-repo-user:
command: gopass show -o wetf/artifactory-user
wetf-repo-password:
command: gopass show -o wetf/artifactory-password
```

`command` runs on the host through the shell; its stdout, with one
trailing newline stripped, is the value. `value:` is also accepted for a
literal — a footgun for a real credential, fine for a low-value token.
(Neither stdin nor a tty is wired up, so a command that needs interactive
pinentry rather than an already-cached agent/keyring will hang or fail.)

- **`vars:` in `config.yaml`** — the same non-secret literal map as any
other config key, for things like `artifactory-url: https://...`.

Notes:

- Mapping a secret makes its value readable by the agent, and therefore by
every active profile's hook, not just the one that declared it. Install
profiles from sources you trust with the secrets you map, and map only
sandbox-appropriate credentials.
- A declared-but-unmapped secret or var fails `code-vm start` /
`profile apply` with the exact snippet to paste into `secrets.yaml` or
`config.yaml`; nothing partial reaches the guest. `code-vm secrets` lists
every secret and var the active profiles declare, mapped or not, without
ever printing a value.
- Resolution happens at `code-vm start`, `code-vm profile apply`, and any
invocation that has to boot the VM — never per invocation against an
already-running VM, so there is no per-command secret-manager prompt.
- On a cold boot, hooks run as part of the guest's own boot sequence,
before `code-vm start` pushes the first rendered template: a hook that
reads a profile-shipped template must tolerate it not existing yet.
- Rotation: rotate the credential at its source (gopass, pass, op, …), then
either restart the VM or run `code-vm profile apply` — code-vm
re-resolves and re-pushes every time, never caching an old value.

## Security model

The perimeter is the VM boundary. Inside it, the agent is separated from guest
Expand Down
Loading
Loading