Skip to content

feat: profile secrets and templates - #3

Merged
stempler merged 23 commits into
mainfrom
worktree-profile-secrets
Aug 20, 2026
Merged

feat: profile secrets and templates#3
stempler merged 23 commits into
mainfrom
worktree-profile-secrets

Conversation

@stempler

Copy link
Copy Markdown
Member

Summary

Profiles can now ship credentialed tool configuration. A profile carries a templates/ tree with ${secret:name}/${var:name} placeholders (e.g. a Maven ~/.m2/settings.xml with static proxies/mirrors plus per-user repo auth); secrets resolve on the host from the user's own ~/.config/code-vm/secrets.yaml (a host command like gopass show …, or a literal), variables from config.yaml. Rendered files are pushed agent-owned 0600 into the guest home — only at code-vm start, code-vm profile apply, and boot-causing invocations.

Design: docs/superpowers/specs/2026-08-20-profile-secrets-design.md
Plan: docs/superpowers/plans/2026-08-20-profile-secrets.md

Trust model

The predecessor mechanism was removed because it resolved host commands from an agent-authored workspace file. This design keeps that class closed:

  • Profiles only declare secret/var names with an inert suggest: hint. Nothing a profile ships ever executes on the host — the hint is display-only, offered as a copy-paste snippet the user must adopt into their own secrets.yaml.
  • Only the user's secrets.yaml executes anything (command:), and it lives in the same mount-guarded, host-trusted tree as config.yaml.
  • A mapped secret is readable by the agent (tools read their configs as the agent) — and therefore by every active profile's hook. code-vm secrets and profile add/list surface which secrets a profile declares; profile add prints the trust warning. Exfiltration stays bounded by the egress allowlist and the proxy log.
  • suggest/description strings are validated at load to single-line printable text — control chars, C1, and Unicode format/bidi-override characters (Trojan Source, CVE-2021-42574) are rejected, so a bundle can't conceal a different command in the snippet a user pastes.

Delivery

Rendered secrets never travel via mode: data (which persists in lima.yaml) or the world-readable guest profile tree, and root never writes into the agent home. A new install-user-file.sh relays each staged file through a root:AGENT_GID 0640 tmpfs drop to an agent-identity install — the same posture as profile-file installs. Git-identity seeding was migrated onto this relay, closing its former root-into-home write.

New surface

  • code-vm secrets — lists declared secrets/vars across active profiles with mapped/unmapped status (names only, never values) and ready-to-paste snippets for unmapped ones.
  • secrets.yaml (0600) and vars: in config.yaml.
  • templates/ in profile bundles; ${secret:…}/${var:…} placeholders (all other ${…} passes through).

Testing

Unit suites extended across profile, config, session, cli. Full VM integration suite: 117 PASS / 0 FAIL on the branch head — secret/var substitution, 0600 agent-ownership, rotation via profile apply, unmapped-secret failure carrying the snippet, ${env.…} passthrough, and the pre-existing "no workspace-authored credentials" guards all green.

Notes

  • Resolution never runs on a plain invocation against a running VM (no pinentry storms); a command needing interactive pinentry/tty is not supported.
  • Guest-script changes (the relay) reach a VM on its next start; a VM booted from a pre-branch binary is given an actionable "restart to upgrade" error.
  • Rotation: rotate at the source, then restart or code-vm profile apply.

🤖 Generated with Claude Code

stempler and others added 16 commits August 20, 2026 11:47
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add SecretSpec and VarSpec types to manifest declarations, enabling
profiles to declare environment variables and secrets they require.
Add Templates []File to Profile to load and validate a templates/
tree alongside files/. Factor loadFiles into a shared loadTree(dir,
subdir) function to handle both trees with identical symlink/validation
logic, parameter-driven error messages. Validate secret/var names via
ValidateName. Reject file/template path collisions and forbidden paths
(.claude/settings.*) in both trees. Extend empty-profile check to
include templates, secrets, and vars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement secrets.yaml loader (SecretsPathFor, LoadSecrets) with permission
warnings; add Config.Vars for non-secret template values with key validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add template.go with placeholder scanning, rendering, declaration merging,
and resolution. Wire undeclared-placeholder validation into Load to ensure
all ${secret:*} and ${var:*} references are declared in the manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
installContent's root install into an agent-owned home path is the TOCTOU
class the profile applier already closed for profile files. Factor stageFile
out of installContent (temp file + staging-dir + Copy) and add PushUserFile,
which relays the staged file through the new install-user-file.sh guest
script instead: root drops it into a root:AGENT_GID 0640 tmpfs area, then an
agent-identity setpriv install (mirroring apply-profiles.sh's run_as_agent_sh)
places it in the home. Migrate ApplyGitIdentity to PushUserFile so .gitconfig
never gets a direct root write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ensureRunning now reports whether it actually booted the VM, so callers
can gate secret/var resolution and template pushes to boots and
explicit apply, never a plain command against an already-running VM
(resolution may invoke the user's secret manager). Also adds a
defense-in-depth guard in session.PushUserFile against unsafe rel
paths for future callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hostCommand used CombinedOutput unconditionally, so a succeeding
secrets.yaml command that prints to stderr (gpg warnings, deprecation
notices) would silently corrupt the resolved secret with that chatter.
Use Output() so the value is stdout only, falling back to the
captured stderr as display text when the command actually fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…add/list

Reports the union of secrets/vars the active profiles declare, whether each
is mapped, and prints ready-to-paste secrets.yaml snippets for unmapped
ones (report, not a gate). `profile add` now warns when a cloned bundle
declares secrets, and `profile list` marks secret-declaring profiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the Profiles integration section with a secret/var-backed template:
substitution, rotation via re-apply, and a declared-but-unmapped secret
failing apply with the ready-to-paste snippet, before anything reaches the
guest. Adds a Credentials subsection under Profiles documenting the trust
model, secrets.yaml/vars, the Maven settings.xml pattern, resolution timing,
the boot-ordering caveat, and rotation, plus `code-vm secrets` in the
command list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-existing Configuration-section "### Credentials" (unqualified "no
credential injection mechanism") and the new Profiles-section one now
contradicted each other and collided on anchor. Rename the old one to
"No workspace credentials", keep what's still load-bearing (nothing agent-
or workspace-authored is ever a credential source; the removed
.sandbox-secrets.yaml class stays removed, pinned by the integration
suite), and forward-reference the Profiles Credentials section for the
host-trusted templates mechanism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Suggest/Description and top-level Manifest.Description are printed
verbatim by `code-vm secrets`, `profile list`, ResolveSecrets errors,
and copied into the ready-to-paste MissingSecretSnippet a user pastes
into secrets.yaml. A hostile bundle could hide a shell command behind
a terminal escape sequence (displays clean, copies with a hidden
suffix) or inject a newline to forge extra snippet entries. Reject any
value containing a control character (C0, DEL) at load time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Boot delivers rendered templates after the file tree (template wins)
while `profile apply` pushes rendered templates before re-laying the
file tree (files/ wins), so which entry actually wins a cross-profile
files/-vs-templates/ collision at the same Rel differs by path for the
same config. Order-respecting suppression can't be made consistent
across both, so LoadAll now rejects the collision outright, naming
both profiles and the path. Same-kind collisions (file/file,
template/template) keep resolving by list order, later wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… apply

newProfileApplyCmd ran PushProfiles and ApplyAllowlist before resolving
and rendering templates, so an unmapped-secret failure aborted only
after the new profile tree was staged and its domains were made live
(with the new tree's hooks then running unattended on the next boot,
without templates). Resolution needs nothing from the guest.

Split pushRenderedTemplates into resolveRendered (secrets/vars
resolution and template rendering, no guest I/O) and pushRendered (the
guest push of an already-rendered set). Profile apply now calls
resolveRendered first and pushes the result later, after
PushProfiles/ApplyAllowlist but before ApplyProfiles/hooks; start,
shell, mount and recreate keep the combined pushRenderedTemplates,
which is now just resolveRendered followed by pushRendered — no
caller resolves twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrap the Admin failure in PushUserFile so an opaque exec error (from a
VM booted with a pre-this-feature binary, missing
install-user-file.sh entirely) tells the user the fix: `code-vm stop
&& code-vm start`. The underlying error stays wrapped with %w.

Defense in depth at both ends of the relay: PushUserFile now also
rejects a rel that doesn't match the same conservative charset the
host-side profile loader enforces, and install-user-file.sh rejects an
absolute or ".."-carrying rel before using it, on its own end. Also
move `trap cleanup EXIT` to immediately after mktemp, so a failed
populate doesn't leave a placeholder drop on tmpfs.

Tied minors from the same review pass:
- CommandRunner's doc comment now describes the actual stdout/stderr
  split, not "combined output".
- hostCommand's doc comment and the README's Credentials section both
  note that a secrets.yaml command needing interactive pinentry will
  hang or fail: neither stdin nor a tty is wired up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
isSingleLinePrintable rejected C0 controls and DEL but let C1 controls
(0x80-0x9f) and Unicode Cf (format) characters through — bidi
overrides/isolates (U+202A-202E, U+2066-2069, the Trojan Source class,
CVE-2021-42574) and zero-width characters (U+200B, U+200E/F, U+061C,
…). These are exactly the concealment vectors the check exists to
close: a malicious `suggest` could still display one command while
pasting another. Reject C1 and unicode.Cf alongside the existing C0/
DEL check; ordinary non-ASCII letters (accented characters, CJK, …)
are unaffected since they are not in Cc/C1/Cf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

  • Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Pull request overview

Adds host-resolved profile secrets and variables, rendered into guest-home configuration templates.

Changes:

  • Adds profile template declarations, rendering, validation, and CLI integration.
  • Introduces an agent-owned secure file relay.
  • Extends documentation and automated coverage.

Reviewed changes

Copilot reviewed 30 out of 30 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
README.md Documents credentials and templates.
test-vm-sandbox.sh Adds VM integration coverage.
internal/session/userfiles.go Implements user-file delivery.
internal/session/userfiles_test.go Tests file delivery.
internal/session/stage.go Extracts shared staging logic.
internal/session/gitidentity.go Migrates Git identity delivery.
internal/session/gitidentity_test.go Updates Git identity tests.
internal/profile/template.go Implements rendering and resolution.
internal/profile/template_test.go Tests template behavior.
internal/profile/profile.go Adds template/declaration loading and validation.
internal/profile/profile_test.go Tests profile validation.
internal/guest/files/scripts/install-user-file.sh Adds the guest relay.
internal/guest/embed_test.go Verifies relay embedding.
internal/config/config.go Adds template variables.
internal/config/config_test.go Tests variable validation.
internal/cli/start.go Integrates rendering with startup.
internal/cli/start_test.go Updates startup tests.
internal/cli/shell.go Pushes templates after boot.
internal/cli/root.go Registers the secrets command.
internal/cli/recreate.go Integrates templates with recreation.
internal/cli/profile.go Adds profile secret warnings and application.
internal/cli/profile_test.go Extends profile CLI tests.
internal/cli/mount.go Refreshes templates after restart.
internal/cli/secrets.go Excluded from automated review.
internal/config/secrets.go Excluded from automated review.
internal/cli/secrets_test.go Excluded from automated review.
internal/config/secrets_test.go Excluded from automated review.
internal/cli/secrets_push_test.go Excluded from automated review.
docs/superpowers/plans/2026-08-20-profile-secrets.md Excluded from automated review.
docs/superpowers/specs/2026-08-20-profile-secrets-design.md Excluded from automated review.
Files excluded by content exclusion policy (7)
  • docs/superpowers/plans/2026-08-20-profile-secrets.md
  • docs/superpowers/specs/2026-08-20-profile-secrets-design.md
  • internal/cli/secrets.go
  • internal/cli/secrets_push_test.go
  • internal/cli/secrets_test.go
  • internal/config/secrets.go
  • internal/config/secrets_test.go

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/profile/template.go
Comment thread internal/profile/profile.go Outdated
Comment thread internal/session/userfiles.go
Comment thread internal/cli/start.go Outdated
Comment thread internal/cli/shell.go Outdated
Comment thread internal/cli/mount.go Outdated
Comment thread internal/cli/recreate.go Outdated
stempler and others added 4 commits August 20, 2026 16:32
…mount/recreate

start, shell, mount, and recreate all called pushRenderedTemplates (resolve
then push) after already booting, stopping, or deleting the VM, so a missing
or failing secret mapping surfaced only after guest state was already
mutated — most seriously in recreate, where it could destroy the VM before
the failure was known. Split each caller into resolveRendered (host-only,
no guest I/O) before the mutation, then pushRendered (guest push of the
already-resolved set) after.

shell's already-running fast path is preserved exactly: it still skips
resolution and pinentry entirely when the VM is already up, matching
today's behavior, since it is the per-invocation hot path rather than an
explicit command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The snippet is pasted verbatim into secrets.yaml, but suggest was emitted
unquoted as the command value. A suggest like `printf '#token'` had its
trailing `'#token'` reparsed as a YAML comment, and one containing ": "
would fork into extra mapping keys, so the saved command silently differed
from what the profile actually suggested.

Add yamlDoubleQuote, a minimal YAML double-quoted-scalar encoder (suggest is
already validated single-line printable, so only backslash and the double
quote itself need escaping), and use it for the command field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are Unicode Zl/Zp, not
Cf, so they passed isSingleLinePrintable and could visually split a
manifest description or secret suggest across what looks like one line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the Admin relay invocation fails (notably the supported old-VM/missing-
install-user-file.sh case), the staged copy was never removed, leaving a
rendered credential behind in the admin-only 0700 staging directory
indefinitely.

Defer a best-effort `rm -f` of the staged path via an independent, bounded
context (rather than the caller's ctx) so cleanup still runs when that ctx is
already cancelled or past its deadline. install-user-file.sh removes the
staged file itself on success, so this is a harmless double-delete on the
success path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stempler
stempler requested a balanced review from Copilot August 20, 2026 14:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

  • Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments.

Files excluded by content exclusion policy (7)
  • docs/superpowers/plans/2026-08-20-profile-secrets.md
  • docs/superpowers/specs/2026-08-20-profile-secrets-design.md
  • internal/cli/secrets.go
  • internal/cli/secrets_push_test.go
  • internal/cli/secrets_test.go
  • internal/config/secrets.go
  • internal/config/secrets_test.go
Suppressed comments (5)

internal/cli/shell.go:68

  • This second status check introduces a TOCTOU gap. If the first check observes Running and the VM stops before ensureRunning checks again, this invocation boots it with rendered == nil, so the required secret resolution and template push are skipped. Use one status decision for both pre-boot resolution and startup (for example, pass the observed status into the startup helper).
	started, err := ensureRunning(ctx, cl, c, profiles)

internal/session/stage.go:67

  • A failed or cancelled limactl copy may already have created a partial file at staged, but this path returns before PushUserFile can register its deferred cleanup. For rendered templates, that can leave credential bytes in the guest staging directory indefinitely. Attempt the same independent best-effort cleanup before returning the copy error.
	if err := d.Client.Copy(ctx, tmp.Name(), staged); err != nil {
		return "", err

test-vm-sandbox.sh:633

  • The integration assertion never verifies that profile apply exits nonzero. Because this script does not use set -e, an implementation that prints the snippet but succeeds still reaches the grep and passes this test. Branch on the assignment's exit status as well as checking the captured message.
    README.md:310
  • This claim is broader than the trust model and contradicts the profile hooks documented above, which do execute profile-provided code in the guest. The security guarantee is specifically that these display strings cannot trigger host execution; state that scope explicitly so readers do not treat profiles as inert.
`description` and `suggest` are inert display strings: a profile can never
make anything execute. Values come only from the user's own mapping:

README.md:138

  • templates is a directory in the bundle, not a profile.yaml key. The colon notation suggests a YAML section and conflicts with the templates/ tree shown below.
- Through a profile's `templates:` (see [Credentials](#credentials) under

stempler and others added 2 commits August 20, 2026 17:01
runDefault checked cl.Status once to decide whether to resolve
templates, then ensureRunning independently re-checked status to
decide whether to boot. If the VM stopped between the two calls, the
outer check would see "Running" (skip resolution) while ensureRunning
observed the new state and booted anyway, silently skipping template
rendering for that boot.

Split ensureRunning into a thin wrapper and
ensureRunningWithStatus(ctx, cl, c, profiles, status), which takes an
already-observed status instead of querying it again. runDefault now
queries status exactly once and feeds that single observation to both
the resolve gate and ensureRunningWithStatus. The other callers
(start/mount/recreate) keep using the ensureRunning wrapper unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stageFile's limactl copy can leave a partial file at the staged guest
path on failure or cancellation, and stageFile returned before
PushUserFile's deferred cleanup was ever registered — leaking rendered
credential bytes in the admin-only guest staging dir. Attempt the same
best-effort, independent-context removal (cleanupStaged, already used
by PushUserFile) before returning the Copy error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stempler

Copy link
Copy Markdown
Member Author

Addressed all five suppressed comments from the latest review (pushed acf27c9..ff065d9; full VM integration suite 117/0):

  • shell.go status TOCTOU (cc68b67): runDefault now takes a single cl.Status observation and passes it into a new ensureRunningWithStatus, so the resolve-gate and the boot decision key off the same read — closing the window where a VM stopping mid-invocation could boot with no templates rendered. start/mount/recreate keep the unchanged ensureRunning wrapper.
  • stage.go partial-file leak (ed9bcc8): a failed/cancelled limactl copy could leave partial credential bytes in the guest staging dir before PushUserFile's deferred cleanup registered; stageFile now runs the same best-effort independent-context rm -f on Copy error.
  • Suite exit-code assertion (ff065d9): the unmapped-secret check now requires a nonzero exit as well as the snippet, so a "print but succeed" implementation can no longer pass (the suite has no set -e).
  • README "inert" overclaim (ff065d9): scoped the guarantee to host execution and made explicit that profile hooks deliberately run guest-side as the agent, so the sentence no longer contradicts the hooks documentation.
  • README templates: notation (ff065d9): corrected to the templates/ directory form.

test-vm-sandbox.sh's unmapped-secret check only grepped for the
suggest snippet in captured output, never checking that `profile
apply` actually failed. Since the suite runs without `set -e`, a build
that printed the snippet but exited 0 would still pass. Capture and
assert the exit code alongside the snippet.

README: `templates:` read as a profile.yaml key; it is the `templates/`
directory. And the "inert display strings" claim about `description`/
`suggest` overclaimed — it's true of host execution only, and profile
hooks intentionally do run profile-shipped code in the guest as the
agent, so scope the guarantee to what it actually is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stempler
stempler force-pushed the worktree-profile-secrets branch from ff065d9 to b86faa8 Compare August 20, 2026 15:19
@stempler
stempler merged commit b62a72c into main Aug 20, 2026
7 checks passed
@stempler
stempler deleted the worktree-profile-secrets branch August 20, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants