feat: edit existing secrets in-place for sops-nix and agenix - #658
feat: edit existing secrets in-place for sops-nix and agenix#658Scott McMaster (scottmcmaster) wants to merge 2 commits into
Conversation
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
📋 PR Overview
🔬 Coverage
|
🎨 Storybook previewUpdated for 5842fc3 🧭 Story changesCompared to ✏️ Changed stories (8)
|
There was a problem hiding this comment.
Pull request overview
This PR wires up the previously stubbed "edit secret value" flow for both the sops-nix and agenix backends. On the backend it adds an edit_secret command that decrypts the existing encrypted file, replaces the requested value in place (preserving the declaration, file location, backend, and recipients), re-encrypts, runs a dry darwin-rebuild check, and commits — with best-effort rollback on failure. On the frontend it reuses AddSecretView in an "edit" mode that hides the add-only controls, and replaces the "not implemented" edit button with a real one. It also extracts the YAML helpers (replace_yaml_path, remove_yaml_path, validate_yaml_syntax) into a new shared yaml_utils module.
Changes:
- New Rust
edit_secretbackend (SOPS + agenix), including conservative recovery of a SOPS repo source file from its evaluated/nix/storepath, plus theeditSecretorpc route andEditSecretResultshared type. - Frontend edit flow:
AddSecretViewgains an optionalsecretprop /buildEditRequest,SecretDetailViewexposesonEdit, andSecretsManagementroutes the neweditview toeditSecret. - Refactor: YAML utilities moved to
yaml_utils.rswith expanded unit tests.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/native/src-tauri/src/secrets/secrets_management.rs | Adds edit_secret/edit_sops_secret/edit_age_secret, SOPS store-path source recovery, and tests |
| apps/native/src-tauri/src/yaml_utils.rs | New module hosting YAML path replace/remove/validate helpers + tests |
| apps/native/src-tauri/src/evolve/file_ops.rs | Moves validate_yaml_syntax (and its tests) into yaml_utils |
| apps/native/src-tauri/src/main.rs | Declares the new yaml_utils top-level module |
| apps/native/src-tauri/src/orpc/secrets.rs | Adds editSecret input type, handler, and route |
| apps/native/src-tauri/src/shared_types/secrets_management.rs | Adds EditSecretResult |
| apps/native/src/ipc/orpc-bindings.ts | Generated EditSecretInput/EditSecretResult + editSecret client |
| apps/native/src/components/widget/secrets/add-secret-view.tsx | Edit mode support and buildEditRequest |
| apps/native/src/components/widget/secrets/add-secret-view.test.ts | Tests for buildEditRequest |
| apps/native/src/components/widget/secrets/secret-detail-view.tsx | Replaces stub edit button with real onEdit; removes canEdit |
| apps/native/src/components/widget/secrets/secrets-management.tsx | Routes edit view and calls editSecret |
| apps/native/src/components/widget/secrets/types.ts | Adds edit view kind and edit apply origin |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
I addressed the two gaps:
|
f5d297c to
b76a140
Compare
c78dc70 to
6bd3a30
Compare
b76a140 to
5b2306b
Compare
|
darkmatteragent please review this PR |
d8ec606 to
65adf7e
Compare
0ad03b4 to
12ae9b2
Compare
darkmatteragent
left a comment
There was a problem hiding this comment.
Review — REQUEST CHANGES
65adf7e0ccf5 · 3 findings
Request changes: the new edit flow reuses the add-flow slug validator on the existing secret's ID, so editing any secret whose nix attrname contains an underscore — the common nix convention — always fails with a misleading naming error.
- The delete flows skip that validator, so delete succeeds where edit fails, confirming the reuse is an oversight, not policy.
- Secondary: the relative-path branch of resolve_sops_source_file returns a canonicalized path its caller strips against a non-canonical base, breaking edits when the config dir contains a symlink; and edit_secret's doc overclaims recipient preservation for SOPS.
Findings
Caution
blocker · correctness — Edit rejects existing secrets whose IDs are not lowercase hyphen slugs
apps/native/src-tauri/src/secrets/secrets_management.rs:140
Both edit paths gate on the add-flow validator: line 140 (validate_new_secret(secret_id, value)?; in edit_age_secret) and line 196 (edit_sops_secret). validate_new_secret requires IDs to contain only a-z, 0-9, and '-'. But in the edit flow the ID is not chosen by nixmac — it is the evaluated nix attrname, taken verbatim by load_sops_secrets/load_agenix_secrets (secret(id, id, ...)). Nix
Important
minor · correctness — resolve_sops_source_file returns a canonicalized path only in the relative branch
apps/native/src-tauri/src/secrets/secrets_management.rs:258
Line 258 returns resolve_existing_path_in_dir(base, evaluated_file), which canonicalizes (file_ops.rs returns full_path_canonical), while the sibling branches deliberately preserve the non-canonical base: line 267 return Ok(base.join(relative)), and the store-match branch returns raw walkdir paths under base. The caller, edit_sops_secret line 210, feeds the result to `repo_relative_path_st
Tip
nit · documentation — edit_secret doc claims recipients never change, but the SOPS path re-encrypts with current .sops.yaml rules
apps/native/src-tauri/src/secrets/secrets_management.rs:118
Lines 117–118 document "Replace an existing secret's value without changing its declaration, location, backend, or recipients." That holds for agenix (agenix_edit_recipients, line 179, refuses to change the recipient set), but edit_sops_secret re-encrypts through encrypt_sops_yaml (line 215), which applies the repository's current .sops.yaml creation rules — so SOPS recipients can be added
| secret_id: &str, | ||
| value: &str, | ||
| ) -> anyhow::Result<EditSecretResult> { | ||
| validate_new_secret(secret_id, value)?; |
There was a problem hiding this comment.
[blocker] Edit rejects existing secrets whose IDs are not lowercase hyphen slugs
Both edit paths gate on the add-flow validator: line 140 (validate_new_secret(secret_id, value)?; in edit_age_secret) and line 196 (edit_sops_secret). validate_new_secret requires IDs to contain only a-z, 0-9, and '-'. But in the edit flow the ID is not chosen by nixmac — it is the evaluated nix attrname, taken verbatim by load_sops_secrets/load_agenix_secrets (secret(id, id, ...)). Nix attrnames conventionally contain underscores (the repo's own fixtures and snapshots use wifi_password, cachix_signing_key), and the delete flows (delete_sops_secret/delete_age_secret) do NOT call this validator, so deletion works for such secrets while edit always fails with "Secret names must be lowercase slugs containing only a-z, 0-9, and '-'" — an error about a field the edit UI does not even show (add-secret-view.tsx renders the name input only when !editing). The charset check exists to constrain IDs nixmac creates in the add flow; applying it to pre-existing declarations breaks the new feature for a large class of real secrets. Fix: for edits, validate only that the value is non-empty (e.g. …
There was a problem hiding this comment.
Confirmed at the current head: both edit entry points still validate the existing ID with the new-secret rule (secrets_management.rs:143 and :199 → validate_new_secret, :1022-1034, which rejects anything outside [a-z0-9-]). IDs come straight from the nix attrnames (load_sops_secrets:1483-1498), and sops-nix's own docs use names like github_token and myservice/my_subdir/my_secret — so editing those always fails with the naming error. The delete flows (:336, :524-538) don't validate the ID at all, so validating only the value in edit looks like the right shape.
| } | ||
|
|
||
| /// Replace an existing secret's value without changing its declaration, | ||
| /// location, backend, or recipients. |
There was a problem hiding this comment.
[nit] edit_secret doc claims recipients never change, but the SOPS path re-encrypts with current .sops.yaml rules
Lines 117–118 document "Replace an existing secret's value without changing its declaration, location, backend, or recipients." That holds for agenix (agenix_edit_recipients, line 179, refuses to change the recipient set), but edit_sops_secret re-encrypts through encrypt_sops_yaml (line 215), which applies the repository's current .sops.yaml creation rules — so SOPS recipients can be added or dropped by an edit. The PR description and the SOPS edit UI ("The updated value uses the recipients registered in the repository's .sops.yaml") state this is intentional, so the behavior is fine; only the doc comment overpromises a security-relevant property. Reword it to scope the recipient guarantee to agenix and state the SOPS behavior.
Caution blocker · correctness — Edit rejects existing secrets whose IDs are not lowercase hyphen slugs Both edit paths gate on the add-flow validator: line 140 ( Warning major · correctness — SOPS edit assumes YAML format for every sops-nix secret, corrupting json/ini/dotenv sources edit_sops_secret unconditionally runs the YAML pipeline: line 211 Tip nit · documentation — edit_secret doc claims recipients never change, but the SOPS path re-encrypts with current .sops.yaml rules Lines 117–118 document "Replace an existing secret's value without changing its declaration, location, backend, or recipients." That holds for agenix ( reviewed: 65adf7e — 2026-09-01T08:06Z verdict: request_changes findings: 3
Run details
|
||||||||||||||||||||||||||||
darkmatteragent
left a comment
There was a problem hiding this comment.
Review — REQUEST CHANGES
65adf7e0ccf5 · 3 findings
Request changes: the new edit flow reuses the add-flow slug validator on the existing secret's ID, so editing any secret whose nix attrname contains an underscore — the common nix convention — always fails with a misleading naming error.
- SOPS edits also assume YAML unconditionally while sops-nix supports json/ini/dotenv/binary formats and
key = ""whole-file secrets; non-YAML sources are silently re-encrypted in the wrong format and committed, a break the dry-build gate cannot catch. - The delete flows skip the slug validator, confirming its reuse in edit is an oversight, not policy.
Findings
Caution
blocker · correctness — Edit rejects existing secrets whose IDs are not lowercase hyphen slugs
apps/native/src-tauri/src/secrets/secrets_management.rs:140
Both edit paths gate on the add-flow validator: line 140 (validate_new_secret(secret_id, value)?; in edit_age_secret) and line 196 (edit_sops_secret). validate_new_secret requires IDs to contain only a-z, 0-9, and '-'. But in the edit flow the ID is not chosen by nixmac — it is the evaluated nix attrname, taken verbatim by load_sops_secrets/load_agenix_secrets (secret(id, id, ...)). Nix
Warning
major · correctness — SOPS edit assumes YAML format for every sops-nix secret, corrupting json/ini/dotenv sources
apps/native/src-tauri/src/secrets/secrets_management.rs:215
edit_sops_secret unconditionally runs the YAML pipeline: line 211 decrypt_sops_file (hardcodes --output-type yaml), line 212 replace_sops_value (serde_yaml parse/serialize), and line 215 encrypt_sops_yaml (hardcodes --input-type yaml --output-type yaml). But the producer, load_sops_secrets, evaluates only file = toString secret.sopsFile; key = secret.key and never reads format, whi
Tip
nit · documentation — edit_secret doc claims recipients never change, but the SOPS path re-encrypts with current .sops.yaml rules
apps/native/src-tauri/src/secrets/secrets_management.rs:118
Lines 117–118 document "Replace an existing secret's value without changing its declaration, location, backend, or recipients." That holds for agenix (agenix_edit_recipients, line 179, refuses to change the recipient set), but edit_sops_secret re-encrypts through encrypt_sops_yaml (line 215), which applies the repository's current .sops.yaml creation rules — so SOPS recipients can be added
| secret_id: &str, | ||
| value: &str, | ||
| ) -> anyhow::Result<EditSecretResult> { | ||
| validate_new_secret(secret_id, value)?; |
There was a problem hiding this comment.
[blocker] Edit rejects existing secrets whose IDs are not lowercase hyphen slugs
Both edit paths gate on the add-flow validator: line 140 (validate_new_secret(secret_id, value)?; in edit_age_secret) and line 196 (edit_sops_secret). validate_new_secret requires IDs to contain only a-z, 0-9, and '-'. But in the edit flow the ID is not chosen by nixmac — it is the evaluated nix attrname, taken verbatim by load_sops_secrets/load_agenix_secrets (secret(id, id, ...)). Nix attrnames conventionally contain underscores (the repo's own fixtures and snapshots use wifi_password, cachix_signing_key), and the delete flows (delete_sops_secret/delete_age_secret) do NOT call this validator, so deletion works for such secrets while edit always fails with "Secret names must be lowercase slugs containing only a-z, 0-9, and '-'" — an error about a field the edit UI does not even show (add-secret-view.tsx renders the name input only when !editing). The charset check exists to constrain IDs nixmac creates in the add flow; applying it to pre-existing declarations breaks the new feature for a large class of real secrets. Fix: for edits, validate only that the value is non-empty (e.g. …
| } | ||
|
|
||
| /// Replace an existing secret's value without changing its declaration, | ||
| /// location, backend, or recipients. |
There was a problem hiding this comment.
[nit] edit_secret doc claims recipients never change, but the SOPS path re-encrypts with current .sops.yaml rules
Lines 117–118 document "Replace an existing secret's value without changing its declaration, location, backend, or recipients." That holds for agenix (agenix_edit_recipients, line 179, refuses to change the recipient set), but edit_sops_secret re-encrypts through encrypt_sops_yaml (line 215), which applies the repository's current .sops.yaml creation rules — so SOPS recipients can be added or dropped by an edit. The PR description and the SOPS edit UI ("The updated value uses the recipients registered in the repository's .sops.yaml") state this is intentional, so the behavior is fine; only the doc comment overpromises a security-relevant property. Reword it to scope the recipient guarantee to agenix and state the SOPS behavior.
12ae9b2 to
d9c860f
Compare
65adf7e to
fc4f15f
Compare
There was a problem hiding this comment.
Caution
Both edit_age_secret and edit_sops_secret call validate_new_secret(secret_id, value)?, which rejects any ID not matching [a-z0-9-]+.
apps/native/src-tauri/src/secrets/secrets_management.rs:140
Caution
edit_sops_secret unconditionally runs the YAML pipeline: decrypt_sops_file (hardcoded --output-type yaml), replace_sops_value (serde_yaml parse/serialize), and encrypt_sops_yaml (hardcoded `...
apps/native/src-tauri/src/secrets/secrets_management.rs:215
Warning
The edit_secret doc comment states "without changing its declaration, location, backend, or recipients," but edit_sops_secret re-encrypts through encrypt_sops_yaml which applies the repository's...
apps/native/src-tauri/src/secrets/secrets_management.rs:118
3 finding(s) posted as inline comments.
| config_dir: &str, | ||
| secret_id: &str, | ||
| value: &str, | ||
| ) -> anyhow::Result<EditSecretResult> { |
There was a problem hiding this comment.
Caution
Both edit_age_secret and edit_sops_secret call validate_new_secret(secret_id, value)?, which rejects any ID not matching [a-z0-9-]+. In the edit flow secret_id is the evaluated nix attrname loaded verbatim from the host configuration — underscores are idiomatic in Nix attribute names — so any secret declared as my_secret or api_key fails with a misleading naming-format error. The delete flows do not call this validator, confirming its use here is an oversight rather than policy.
| let encrypted_rel = repo_relative_path_string(base, &encrypted_path)?; | ||
| let plaintext = decrypt_sops_file(host_attr, config_dir, &encrypted_path)?; | ||
| let updated = replace_sops_value(&plaintext, sops_key, value)?; | ||
|
|
||
| let operation = (|| -> anyhow::Result<EditSecretResult> { |
There was a problem hiding this comment.
Caution
edit_sops_secret unconditionally runs the YAML pipeline: decrypt_sops_file (hardcoded --output-type yaml), replace_sops_value (serde_yaml parse/serialize), and encrypt_sops_yaml (hardcoded --input-type yaml --output-type yaml). sops-nix supports json, ini, dotenv, and binary formats; editing a non-YAML secret silently re-encrypts it in YAML format. The dry-build gate cannot detect this corruption because Nix evaluates only the Nix declarations, not the encrypted file format.
There was a problem hiding this comment.
Confirmed at the current head: decrypt hardcodes --output-type yaml (secrets_management.rs:1059-1069) and re-encrypt hardcodes --input-type yaml --output-type yaml (:1089-1102), while the eval reads only file and key, never format (:1483-1486). The dry-build gate can't catch the corruption — it's nix build --dry-run (rebuild/darwin.rs:109-115), evaluation only, so a json/ini/dotenv sopsFile re-encrypted as YAML passes the check and gets committed; the break only surfaces on the next real rebuild.
|
|
||
| /// Replace an existing secret's value without changing its declaration, |
There was a problem hiding this comment.
Warning
The edit_secret doc comment states "without changing its declaration, location, backend, or recipients," but edit_sops_secret re-encrypts through encrypt_sops_yaml which applies the repository's current .sops.yaml creation rules — so SOPS recipients can change. The PR description itself acknowledges this ("For SOPS, we use what's currently in the repo's .sops.yaml"), making the function-level contract claim actively misleading to future contributors reasoning about the security guarantee.
There was a problem hiding this comment.
Confirmed: edit_sops_secret decrypts the whole document (secrets_management.rs:214) and encrypt_sops_yaml (:1089-1128) re-encrypts it from scratch under the current .sops.yaml creation rules — the file's existing data key and recipient set are discarded, not just the edited value's. In a repro, a one-value edit in a one-secret file rewrote the value line, the age enc payload (a fresh data key), lastmodified, and the MAC — 8 of the file's 16 lines. So the blast radius is every secret in a shared file, which the recipient text in the edit view doesn't currently convey.
d9c860f to
90b8230
Compare
fc4f15f to
16dcb26
Compare
|
Prelint reached the review limit for this pull request (5 reviews of each kind). New pushes do not start a review.
|
16dcb26 to
8ade269
Compare
90b8230 to
5ef9bbe
Compare
8ade269 to
656a01e
Compare
| .file_name() | ||
| .and_then(|name| name.to_str()) | ||
| .ok_or_else(|| anyhow!("Evaluated SOPS file has no valid filename"))?; | ||
| let source_name = nix_store_source_name(store_name) |
There was a problem hiding this comment.
Editing a SOPS secret declared with a plain path literal — sopsFile = ../../secrets/secrets.yaml;, the style in sops-nix's own docs — always fails. builtins.getFlake copies the checkout to /nix/store/<hash>-source/, so the evaluated sopsFile is a nested path under that copy, and nix_store_source_name only parses flat single-file paths (<32-char-hash>-<name> as the basename). The basename here is just secrets.yaml, so the edit is refused with a misleading error. Only declarations that evaluate to a flat single-file store path (like builtins.path { ... }, which nixmac's own add flow writes) or to a path inside the checkout survive.
Repro (template repo with the declaration above):
$ nix eval --impure --json --expr 'let cfg = (builtins.getFlake (toString ./.)).darwinConfigurations."Alexs-MacBook-Air".config; in builtins.mapAttrs (_: s: { file = toString s.sopsFile; key = s.key; }) cfg.sops.secrets'
{"demo-token":{"file":"/nix/store/s9f2bc1mhizhcfzhv9qdj9jkclg82nh9-source/secrets/secrets.yaml","key":"demo-token"}}
2026-09-02T15:40:16.836866Z ERROR nixmac::orpc::helpers: orpc error command="secrets.editSecret" error=Could not derive a source filename from Nix store path
The nested case looks like the easy one: when the store path has components after the store copy's root (the first component under /nix/store/), that subpath is exactly the config-dir-relative path, since the eval runs getFlake (toString ./.) from the config dir — would resolving base.join(subpath) and keeping the existing byte-equality check (so it still fails closed on mismatch) work here?
| backend: secret.backend, | ||
| title: "Encrypt & commit", | ||
| subtitle: `Edit secret · ${secret.id}`, | ||
| files: [{ path: secret.file, note: "· encrypted update", mark: "~" }], |
There was a problem hiding this comment.
The Encrypt & commit sheet is filled from secret.file — the evaluated sopsFile — as both the file chip and diffFile. For a store-path declaration that's /nix/store/..., not the repository file the backend actually resolves, modifies, and commits (it computes the repo-relative path internally and even returns it as EditSecretResult.encryptedFile, which the UI never reads). So the one confirmation step before a commit names a file that isn't being touched.
Observed with a builtins.path-declared secret:
sheet: ~ /nix/store/7759y7r012ywly82x8np0fgc29dkd89f-api-token.yaml · encrypted update
commit: secrets/api-token.yaml | 16 ++++++++--------
The backend only resolves the real path at submit time — would a pre-flight resolve (reusing resolve_sops_source_file) so the sheet can show the repo-relative path work here?
| } | ||
|
|
||
| /// Replace an existing scalar at a slash-delimited SOPS key path. | ||
| fn replace_sops_value(plaintext: &str, sops_key: &str, value: &str) -> anyhow::Result<String> { |
There was a problem hiding this comment.
The edit pipeline decrypts the whole document and round-trips it through serde_yaml::Value, which has no representation for comments — so every hand-written comment in the secrets file is silently dropped from the committed re-encrypted file. sops itself preserves comments across encrypt/decrypt (they're encrypted alongside the values), so annotations kept in these files are legitimate and survive normal sops editing.
Before/after one edit through the app:
$ sops --decrypt secrets/api-token.yaml # before
# rotate quarterly (comment should survive edits)
api-token: swordfish-original
$ sops --decrypt secrets/api-token.yaml # after
api-token: test123-x2
Would sops set <file> '["<key>"]' '<json-value>' work here instead of the decrypt/replace/re-encrypt pipeline? On a test file it changed only the target line (plus lastmodified/mac), keeping comments, sibling keys' ciphertext, and the existing data key — which would also keep the file's current recipients instead of re-keying it from .sops.yaml. sops_extract_path already converts the slash-delimited key into exactly this index syntax. One wrinkle: sops set takes the plaintext value as an argv parameter (visible in ps), unlike today's stdin pipe.
| secret_id: &str, | ||
| value: &str, | ||
| ) -> anyhow::Result<EditSecretResult> { | ||
| validate_new_secret(secret_id, value)?; |
There was a problem hiding this comment.
Confirmed at the current head: both edit entry points still validate the existing ID with the new-secret rule (secrets_management.rs:143 and :199 → validate_new_secret, :1022-1034, which rejects anything outside [a-z0-9-]). IDs come straight from the nix attrnames (load_sops_secrets:1483-1498), and sops-nix's own docs use names like github_token and myservice/my_subdir/my_secret — so editing those always fails with the naming error. The delete flows (:336, :524-538) don't validate the ID at all, so validating only the value in edit looks like the right shape.
| let encrypted_rel = repo_relative_path_string(base, &encrypted_path)?; | ||
| let plaintext = decrypt_sops_file(host_attr, config_dir, &encrypted_path)?; | ||
| let updated = replace_sops_value(&plaintext, sops_key, value)?; | ||
|
|
||
| let operation = (|| -> anyhow::Result<EditSecretResult> { |
There was a problem hiding this comment.
Confirmed at the current head: decrypt hardcodes --output-type yaml (secrets_management.rs:1059-1069) and re-encrypt hardcodes --input-type yaml --output-type yaml (:1089-1102), while the eval reads only file and key, never format (:1483-1486). The dry-build gate can't catch the corruption — it's nix build --dry-run (rebuild/darwin.rs:109-115), evaluation only, so a json/ini/dotenv sopsFile re-encrypted as YAML passes the check and gets committed; the break only surfaces on the next real rebuild.
|
|
||
| /// Replace an existing secret's value without changing its declaration, |
There was a problem hiding this comment.
Confirmed: edit_sops_secret decrypts the whole document (secrets_management.rs:214) and encrypt_sops_yaml (:1089-1128) re-encrypts it from scratch under the current .sops.yaml creation rules — the file's existing data key and recipient set are discarded, not just the edited value's. In a repro, a one-value edit in a one-secret file rewrote the value line, the age enc payload (a fresh data key), lastmodified, and the MAC — 8 of the file's 16 lines. So the blast radius is every secret in a shared file, which the recipient text in the edit view doesn't currently convey.
85fd686 to
3189f9e
Compare
68c8e28 to
5842fc3
Compare






Summary
Hook up the edit functionality for secrets for both SOPS and agenix.
ATTENTION
a. For age, the updated value keeps the same recipients.
b. For SOPS, we use what's currently in the repo's .sops.yaml.
Also did some refactoring of the YAML-related utility methods, and enhanced some of the unit tests.
Screenshots (note that I hid all of the extra controls from the "add" flow since they were distracting and not directly relevant to the "edit" flow but per prelint comments recipients are included):
Test Plan
New unit tests in Rust and TypeScript, manually tested the SOPS and age paths in the UI.
Docs