allod command to save an encrypted credential into a secrets repo #182
Labels
No labels
blocked
bug
decision
duplicate
enhancement
help wanted
invalid
landed?
question
ready-to-merge
stale
wontfix
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
allod/tools#182
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Add an
allodcommand that saves a credential into a secrets repo, so the registry entry, the recipient list and the age ciphertext land together instead of being assembled by hand from a page of prose. The manual ritual works — it was just used to land a real credential end to end — but every safeguard in it is a sentence the operator has to have read first, and three of its failure modes exit 0.Primary goals:
credentials.nixentry, thesecrets.nixrecipient line, and the.ageciphertext, leaving one commit to review instead of three hand-edited files.secrets.nix, never against a recipient list assembled by hand, so the declared set and the encrypted set cannot drift apart.sitedoes, and only the host toolchain sets it. On a dev machineallod secretis an unknown word, not a command that refuses. Agents cannot add secrets by construction, and no prose has to say so.rotateexists for the credential from the first commit, and the registry stops being Forgejo-shaped: the last credential registered there carries a fakeservice: forgejoandui_token_namebecause nothing else fits.rotation_state = "pending"; the inventory check tolerates a missing file in that state and refuses one inactive; the command flips it when it writes the file.Current state and specifics
The ritual is documented in
memory'ssave-encrypted-credentialskill. What the command has to encode, each reproduced:error: path '<cwd>/secrets.nix' does not exist— agenix readssecrets.nixfrom the working directoryNo identity found to decrypt <file>—agenix -edecrypts an existing file first, so the next attempt is silently a rotation<file> wasn't changed, skipping re-encryption., exit 0, and the added machine still cannot decrypt. Onlyagenix -rrekeysThe registry gates both directions, so partial work cannot merge and the command must write all three pieces or none: a registered agenix consumer with no file fails at
secrets/flake.nix:577(ERROR: missing <path> for <name>), and asecrets.nixentry with no consumer record fails atsecrets/flake.nix:569(credential-inventory: secrets missing consumer records: <path>).A guard shape worth carrying over, because the obvious ones are wrong:
[ -n "$K" ] || { …; return; }does not stop anything outside a function, andexit 1in the same position kills an interactive shell. Anifblock is the form that neither falls through nor takes the terminal with it.The boundary
This is an operator command, run at the host terminal where the age identity lives. It is not a skill an agent follows: an agent never holds a plaintext credential, and a rule that depends on the agent remembering that is not a boundary. The
sitenamespace already has the shape (cmd/allod/site.go,//go:build site, switched on by the host home module'stags), so this follows it, and the wrapper change in the host repository that adds the tag is part of landing it.The agent's half is what it can legitimately do: name the credential, its kind and its recipient machines in an issue or a PR body, and hand over the command. Whether it may also land the two non-secret files ahead of the ciphertext is the one design question to settle before building. The default worth stating: the command accepts a
credentials.nixentry and asecrets.nixline that already exist and match what it was asked for, and writes only the missing ciphertext, so a branch an agent prepared is a resume state rather than a conflict; entries that exist and disagree with the request are refused with both shown, never reconciled silently.Recipients come from
secrets.nixand nowhere else. There is no flag that names a recipient set, for the same reasonallod site deployhas no flag that names a target: a command that can be pointed at the wrong recipients by an argument will be, and the ciphertext then decrypts on a machine the registry does not list.The whole landing
Two credentials landed this month by hand, and each touched the same set. The command owns the first four; the rest is ordinary reviewed work an agent does before the host step:
secrets.nixfrom stdin, staged on tmpfssecrets/<name>.agesecrets.nixkind,owner, consumer record androtation_statecredentials.nixPi credentials are not this: they have a registry of their own with derived recipients and
pi-providerowns their lifecycle. Neither are host and forge keys, which the three key tools stage, activate and retire.The flow
The agent builds everything around the secret as one PR: registry entries in
pendingstate, the identity export, the docs section, and the consumer module where the framework does not already have one. Nothing in that PR is secret and all of it is reviewable on its own, green, becausependingis a legal state.The human runs one command at the host terminal, on the agent's branch: it reads the plaintext, encrypts it against
secrets.nix, flips the state toactive, runs the repository's checks, commits, and pushes the branch. Not master; merging stays a separate human act. If the entries on the branch do not match what the command was asked to encrypt, it refuses with both shown. A rotation is the same gate on the same command, and the rekey path likewise.Scope
In scope: the create path, the rotate path, and the recipient-change path that has to rekey rather than edit; the build tag and the one-line change in the host repository's home module that sets it; the rotation registration and a registry shape that is not Forgejo-named; and the
pendingrotation state, whose check lives in the secrets template (allod/secrets), so that part lands there first. Landing this retires thesave-encrypted-credentialskill inallod/memoryrather than updating it, as the comment below says; its paste-safe relay shape moves to the relay conventions, because that was never about secrets. The rotate path likely subsumes allod/nexus#42, which teachesrotate-tokenone more value shape; decide that against this design rather than building both. Out of scope and tracked with the credential itself: creating the value at its source, revoking the value it replaces, rotation policy, and the consumer-side wiring — the module that mounts the secret, and the lock bump and rebuild that carry it to a machine.Background, not a dependency: allod/strategy#13 explores keeping long-lived secrets out of agent environments altogether. This command is the host-side half that any such design still needs.
Landing this retires the
save-encrypted-credentialskill rather than updating it, and that belongs in Scope.The skill exists only because the manual path has four ways to fail while exiting 0. A command that refuses all four leaves it nothing to teach. It is also the weaker instrument for the same reason this issue already gives: a skill is advisory, obeyed only by an agent that read it and chose to comply, while a namespace that does not compile outside the host is a boundary with nothing to obey.
One part of it does not retire, because it was never about secrets: the paste-safe relay shape — no backslash line continuations, never inline a ~110-character recipient token, and run an interactive
readas its own command because inside a pasted block the shell feeds it the next pasted line as the secret. That applies to any command handed to a human through a TUI that mangles copy, and this command makes it moot only for this one task by collapsing the handover to a single short line. It should move to the relay conventions rather than vanish with the skill.Until this lands, the skill is the only written procedure and stays as it is. The retirement is part of closing this, not separate cleanup.
Design settled with the owner before building. The command verifies and encrypts; it does not author. The rotate path is deferred;
createandrekeyland now. The rotation registry generalizes in place. Encryption isagefed over a pipe, so no plaintext is ever written to any filesystem.Decisions
allod secret create <name>verifies, then encrypts. It takes a credential name and the plaintext on stdin, nothing else. It refuses unless the branch already carries, for that name, acredentials.nixentry inrotation_state = "pending"with exactly oneagenixconsumer in this repo, asecrets.nixline for that consumer's path whose recipients include the host identity, and a rotation registry entry naming that credential and path, and no ciphertext exists yet. Every refusal shows what it found. There are no flags for kind, owner, recipients or format: the reviewed PR is the only authoring path, so there is one shape to get right and the recipient set is a diff that passed the inventory check before any ciphertext exists. A human landing a credential without an agent hand-edits the same three files. An untagged, agent-sideallod secret declarethat writes the three entries in canonical shape is a follow-up, not part of this issue.createandrekeyland now;rotateis deferred.rotate-tokenin nexus already rotates every registered format, carries host and user forward, and refreshes local auth, and allod/nexus#42 is closed and landed. Duplicating that in Go doubles the surface for no new capability.allod secret rekey <name>covers the recipient-change path: decrypt with the host identity, re-encrypt to the currentsecrets.nixrecipients, always rewriting so the agenix no-op cannot recur. Moving rotation intoallod secretis a follow-up once the registry below has settled.forgejo-token-groups.jsonand thelib.forgejoTokenGroupsexport keep their names; renaming is cross-repo and its own issue.servicebecomes an enum,forgejoornone;account,ui_token_nameand the revocation copy are required and printed only forforgejo. archetypes reads onlycredentials[]andlocal_auth_refreshand is unaffected.rotate-token's validator and printed copy change accordingly.agewith recipients evaluated fromsecrets.nix. The recipient list isnix evalof that file's entry for the path, the same source rotate-token and pi-provider use, and no flag can name a recipient. The plaintext goes from stdin to theageprocess over a pipe and the ciphertext is captured and written whole, so nothing unencrypted touches any filesystem, tmpfs included, and there is no partial file to leave behind. Rekey pipesage -dinto memory and back intoage -ethe same way.[<checkout>]for a worktree. It uses whatever branch is checked out there and refuses the default branch and a dirty tree. The human checks out the agent's branch with ordinary git first. After writing, it runs the repository's flake checks, restores both files if they fail, and otherwise commits and pushes the branch. Merging stays a separate human act.secret, and nexus setstags = [ "site" "secret" ]. The two tags name two capabilities, publishing sites and holding the age identity, that happen to coincide on one machine today.pendingstate. The inventory check acceptsrotation_state = "pending", requires the ciphertext absent in that state and present in every other, and otherwise treats a pending entry as it treats an active one for recipient coverage. That change lands first, inallod/secrets.Landing order
pendingstate in the inventory check and README. Refs this issue.secretnamespace behind its tag, the hidden-prompt code moved out of the site-only file so both tags can use it, tagged and untagged tests, checks for both tag directions, docs. Refs this issue.serviceenum in rotate-token's validator and copy with a test, docs. Closes this issue.age.mdpoints the landing at the command. The privatesave-encrypted-credentialskill retires by hand, per the comment above; its paste-safe relay shape already lives inagent-behavior.md.Residual risk
R3 for the tools PR: it writes to the authoritative secrets repository, pushes, and handles the age identity, though only on a non-default branch with a clean tree, with every file-system effect captured in memory first, and with the flake checks gating the commit. R2 for secrets and nexus. Rollback is a revert in each repo; a landed ciphertext is a commit on a branch that was never merged.
Implementation is up for review in three PRs plus a memory note, in merge order: allod/secrets#19 (the
pendinginventory state), allod/tools#195 (theallod secretnamespace behind thesecrettag), allod/nexus#50 (the tag on the host build and theservice: noneregistry groups; closes this issue), then allod/memory's "age.md: a credential lands in two halves". After tools merges, theallod-toolspin in nexus and deploy has to move before the host binary carries the command.Follow-ups filed from the design comment: allod/tools#196 for an agent-side
allod secret declarethat writes the three entries in canonical shape, and allod/tools#197 for moving value rotation out ofrotate-tokenintoallod secret rotate, where the registry rename is also decided.Not done here and left to the operator: retiring the private
save-encrypted-credentialskill by hand once the nexus PR lands, and a full live landing, since no agent machine holds an age identity. The command's realnix evalandagepaths were exercised against a clone of the secrets template up to the point where the identity is needed.Cross-family review pass run after the merges, as
delegate-implementationasks; it had been skipped during implementation. Runner: pi driving gpt-5.6-sol and gpt-5.6-terra at thinking high, because codex refused both with a reconnect error. Two reviewers on the tools diff, one each on secrets and nexus, every finding verified against the source before acting.Results: allod/tools#195 had seven real findings (a guessed
masterdefault branch that could push a landing tomain, no rollback on a failed add or commit, a silent restore failure, two flip-parsing gaps, a path with a space, and the untagged build broken on the BSD family), fixed in the tools PR "allod secret: answer the cross-family review". allod/nexus#50 had three (the--forgejo-tokenselector could resolve anonegroup with placeholder fields, and two Forgejo-only messages fornonegroups), fixed in allod/nexus#51. allod/secrets#19 had one real finding, that nothing exercises thependingarm in the template, filed as allod/secrets#20; its other finding, that an untracked ciphertext is invisible to${self}, is the ordinary flake-source rule.