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 fc4f15f 🧭 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
5b2306b to
f117d3d
Compare
20630c0 to
94b35c5
Compare
| const agenixEditUnavailable = | ||
| secret.backend === "agenix" && | ||
| (!secret.publicRecipientsResolved || secret.publicRecipients.length === 0); | ||
| // SOPS deletion must decrypt and rewrite its shared YAML document. Agenix | ||
| // deletion removes an opaque per-secret file and does not need a local key. | ||
| const canDelete = secret.backend === "agenix" || capability !== "unavailable"; |
There was a problem hiding this comment.
⚠️ Edge Case: SOPS edit offered when decryption is unavailable
The "Edit value" button is shown whenever !agenixEditUnavailable, which is only ever false for agenix secrets. For a SOPS secret it is always rendered, even when capability === "unavailable". But edit_sops_secret must decrypt_sops_file before re-encrypting, so a SOPS secret with no local identity produces a guaranteed decrypt failure after the user submits. This is the same condition that already gates Delete (canDelete = backend === "agenix" || capability !== "unavailable"), so editing should be gated identically. Compute a canEdit that also requires SOPS decryptability and use it for the button.
Gate the SOPS edit button on decryption capability, mirroring canDelete.:
const agenixEditUnavailable =
secret.backend === "agenix" &&
(!secret.publicRecipientsResolved || secret.publicRecipients.length === 0);
// SOPS edit decrypts and rewrites the shared YAML, so it needs a local key
// just like deletion does.
const canEdit =
secret.backend === "agenix" ? !agenixEditUnavailable : capability !== "unavailable";
...
{canEdit && (
<Button variant="outline" size="sm" onClick={onEdit}>
- Apply fix
Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎
Code Review
|
| Auto-apply | Compact |
|
|
Was this helpful? React with 👍 / 👎 | Gitar
94b35c5 to
d2105c9
Compare
f117d3d to
652ca35
Compare
d2105c9 to
d8ec606
Compare
652ca35 to
0ad03b4
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. …
| } | ||
|
|
||
| /// 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.
|
|
||
| /// 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.






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