feat: profile secrets and templates - #3
Merged
Conversation
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>
There was a problem hiding this comment.
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.
…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>
There was a problem hiding this comment.
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
Runningand the VM stops beforeensureRunningchecks again, this invocation boots it withrendered == 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 copymay already have created a partial file atstaged, but this path returns beforePushUserFilecan 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 applyexits nonzero. Because this script does not useset -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
templatesis a directory in the bundle, not aprofile.yamlkey. The colon notation suggests a YAML section and conflicts with thetemplates/tree shown below.
- Through a profile's `templates:` (see [Credentials](#credentials) under
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>
Member
Author
|
Addressed all five suppressed comments from the latest review (pushed acf27c9..ff065d9; full VM integration suite 117/0):
|
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
force-pushed
the
worktree-profile-secrets
branch
from
August 20, 2026 15:19
ff065d9 to
b86faa8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.xmlwith 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 likegopass show …, or a literal), variables fromconfig.yaml. Rendered files are pushed agent-owned0600into the guest home — only atcode-vm start,code-vm profile apply, and boot-causing invocations.Design:
docs/superpowers/specs/2026-08-20-profile-secrets-design.mdPlan:
docs/superpowers/plans/2026-08-20-profile-secrets.mdTrust model
The predecessor mechanism was removed because it resolved host commands from an agent-authored workspace file. This design keeps that class closed:
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 ownsecrets.yaml.secrets.yamlexecutes anything (command:), and it lives in the same mount-guarded, host-trusted tree asconfig.yaml.code-vm secretsandprofile add/listsurface which secrets a profile declares;profile addprints the trust warning. Exfiltration stays bounded by the egress allowlist and the proxy log.suggest/descriptionstrings 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 inlima.yaml) or the world-readable guest profile tree, and root never writes into the agent home. A newinstall-user-file.shrelays each staged file through aroot:AGENT_GID 0640tmpfs 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) andvars:inconfig.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,0600agent-ownership, rotation viaprofile apply, unmapped-secret failure carrying the snippet,${env.…}passthrough, and the pre-existing "no workspace-authored credentials" guards all green.Notes
code-vm profile apply.🤖 Generated with Claude Code