Skip to content

feat: add SOPS secret add/delete with darwin-rebuild verification and commit - #655

Open
Scott McMaster (scottmcmaster) wants to merge 4 commits into
08-11-scott-agenix-secrets-rofrom
08-11-scott-add-del-sops-secrets
Open

feat: add SOPS secret add/delete with darwin-rebuild verification and commit#655
Scott McMaster (scottmcmaster) wants to merge 4 commits into
08-11-scott-agenix-secrets-rofrom
08-11-scott-add-del-sops-secrets

Conversation

@scottmcmaster

@scottmcmaster Scott McMaster (scottmcmaster) commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Hooks up the original storybook mocks end-to-end for SOP secret add and delete operations. (Age will be forthcoming.)

Includes some fileops and nix editor refactorings and enhancements for reusable/recurring patterns.

Screenshots:

Screenshot 2026-08-11 at 3 08 33 PM Screenshot 2026-08-11 at 2 58 26 PM image image Screenshot 2026-08-11 at 3 00 39 PM image

Test Plan

New unit tests as appropriate, plus manual testing e2e.

  • No test plan needed

Docs

  • Docs updated (companion PR in darkmatter/nixmac-web: #___)
  • No docs update needed

Scott McMaster (scottmcmaster) commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

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.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@scottmcmaster Scott McMaster (scottmcmaster) changed the title scott-add-del-sops-secrets feat: add SOPS secret add/delete with darwin-rebuild verification and commit Aug 11, 2026
@scottmcmaster
Scott McMaster (scottmcmaster) marked this pull request as ready for review August 11, 2026 07:12
@darkmatter

darkmatter Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

🎨 Storybook preview

Open Storybook preview

Updated for db16ba5


⚠️ Detected UI changes (5)

These stories' HTML snapshots changed. I've added screenshots + links to the changed stories below. Review them carefully then accept the changes to regenerate baselines and include them in this PR:

Flows/Evolve › Playground

Flows/Evolve › Playground

Flows/Evolve › 1. Begin (idle)

Flows/Evolve › 1. Begin (idle)

Flows/Evolve › 2. Evolving (progress)

Flows/Evolve › 2. Evolving (progress)

Flows/Evolve › Evolving With Error Event

Flows/Evolve › Evolving With Error Event

Flows/Evolve › 3. Review (changes generated)

Flows/Evolve › 3. Review (changes generated)


Accept UI changes

  • Click here to accept these changes

Alternatively, you can run bun run test:update-snapshots locally to re-generate the baselines and then push the changes to this PR.

What does this do?

The screenshots above show UI changes detected by the Storybook
snapshot tests run on this PR. Each image is the rendered output of
a Storybook story from the code in this PR branch; the snapshot
test compared it against the committed baseline in
__snapshots__/ and flagged the difference.

Checking the box tells the darkmatter[bot] to regenerate the
baselines from this PR's current code and commit them directly to
this branch. The new baselines become the source of truth for
future runs — only accept after confirming the visual changes are
intentional.

Comparison baseline: the committed __snapshots__/ files on this
PR branch (carried forward from develop). Accept updates them in
place on this branch.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor
Warnings
⚠️

No Linear issue ID found in this PR's title, description, or branch name (expected something like ENG-123). Add one so this work is traceable in Linear, or add #no-linear to the PR description to acknowledge it's intentionally untracked.

⚠️ ❗ Big PR (1504 lines changed). Consider splitting it into smaller, focused changes.
Messages
📖 No docs update needed — acknowledged.

📋 PR Overview

Lines changed 1504 (+1272 / -232)
Files 1 added, 16 modified, 0 deleted
Draft / WIP no
Has Test Plan yes
Linear issue no
No Test Plan Needed no
New UI components no
New Storybook stories no
New Rust modules no
New TS source files no
New tests yes (1)
package.json touched no
Cargo.toml touched no
Infra / CI touched no

🔬 Coverage

Report Lines Statements Functions Branches
apps/native/coverage/coverage-summary.json 36.2% 35.5% 31.1% 31.2%

Generated by 🚫 dangerJS against db16ba5

@prelint

prelint Bot commented Aug 11, 2026

Copy link
Copy Markdown

Ship with changes SOPS secret add and delete are now live end-to-end operations

Product decisions in this change

Agree 1. The Add secret button is now visible and connected to a live backend, promoting SOPS secret management from a hidden stub to a first-class user-facing feature.

The flow is end-to-end: the form validates the slug, the backend encrypts the plaintext via SOPS, declares the secret in the Nix module, runs a dry build to verify correctness, and commits exactly the two affected files. The SOPS-only focus is appropriate for a first release. The review sheet gives users a diff preview before committing, which sets expectations correctly.

Agree 2. The backend selector is replaced by a static label showing SOPS only, with a note that agenix support will be added later.

A toggle with one working option and one that silently errors is worse than no toggle. The static label removes a false affordance and sets accurate expectations. The concern is that 'added later' carries no date or milestone link, so the promise is open-ended.

Agree 3. Per-recipient selection is removed from the add form; which identities receive access is now determined entirely by the .sops.yaml creation rule.

The previous checkboxes implied users could choose recipients per secret. SOPS actually uses the .sops.yaml creation rule regardless of what was checked. The read-only list with a clarifying note is more accurate. Users who want to change recipients must edit .sops.yaml directly, which is the correct place for that decision.

Agree 4. If add or delete fails at any step, the system automatically restores the modified files to their pre-operation state without requiring user action.

The clean-repo prerequisite ensures these files had no unrelated pending changes at entry, so restoring them cannot discard other user work. A user who sees an error can fix the root cause and retry without needing to inspect git state. This makes the feature safe to explore.

Agree with concerns 5. The add review sheet shows "sops secrets module" as the declaration file entry rather than the actual resolved file path.

The backend resolves the true declaration file path at operation time, after the user confirms. The path is genuinely unknown when the review sheet is built. The label correctly describes what will change. The concern is the inconsistency: the encrypted file shows its real path (secrets/secrets.yaml) while the declaration file shows a generic label. Showing the expected standard path (modules/darwin/sops-secrets.nix) with a qualifying note would give users a more useful preview at no extra backend cost.

Agree with concerns 6. The plaintext secret value is stored in React component state between the user opening the review sheet and the confirmed API call.

The value must be held somewhere between form submission and API call; React state is the conventional place. The concern is duration. The review sheet has no timeout, so the secret stays in component state for however long the user leaves the sheet open. A short idle timeout (30 to 60 seconds) that clears the pending secret and returns to the form would reduce the exposure window without affecting normal use.

Disagree 7. The delete button is active for agenix secrets where a local key is configured, and the backend rejects the operation only after the user confirms the destructive dialog.

The add form removes the agenix backend option entirely and shows a note that agenix support is coming later. The delete surface does not match that pattern. A user with an agenix secret opens the detail view and sees an active Delete button. They read a dialog describing a permanent destructive operation, confirm it, wait, and then see an error saying the feature is not implemented. That sequence teaches users not to trust the confirmation dialog. The delete button should be disabled for agenix secrets, matching the intent the add form already communicates.

Agree with concerns 8. During the delete flow, the UI shows only a "Deleting…" spinner with no phase label while the darwin-rebuild check runs.

A darwin-rebuild check can take 30 to 60 seconds on a slow machine. The add flow labels the phase 'Encrypting with SOPS · running darwin-rebuild check'. Delete shows only a spinning indicator inside the button. A user who sees no change for 45 seconds cannot distinguish a running build check from a frozen application. A single status line near the dialog footer showing the current phase would close this gap.

Agree with concerns 9. The clean repository prerequisite is checked at confirm time, after the user has engaged with and confirmed the deletion dialog.

The error displays inline near the action button, which preserves context and avoids a navigation surprise. The cost is that a user with uncommitted changes invests time in reading and confirming a destructive dialog only to learn a prerequisite was never met. Checking when the delete dialog opens, or showing a warning on the detail view when the repo is dirty, would let users fix the problem before engaging with the confirmation.

Agree with concerns 10. The delete confirmation dialog names no specific files that will change; it reads only 'This removes the encrypted value and Nix declaration, verifies the configuration, and commits the change.'

The add review sheet names both files explicitly. The delete dialog gives users the same level of detail as a generic destructive confirm. Adding the two file paths (secrets/secrets.yaml and the declaration module path) to the dialog description costs minimal design work and gives users the same transparency they get from the add flow.

Agree with concerns 11. The original secret name is not preserved in the Nix declaration; only the normalized lowercase slug is stored.

The slug is required for SOPS YAML keys and Nix identifiers. The form shows the slug preview as the user types, so the transformation is visible before submission. A Nix comment above the declaration entry could preserve the original intent at near-zero cost. This matters most when team members review git diffs months later and find a slug that no longer matches their mental model of the secret.

Agree with concerns 12. All SOPS secrets share one encrypted file at the fixed path secrets/secrets.yaml, and the add and delete views give no warning to users whose repository uses a different layout.

The fixed path matches the shipped template and covers the majority of new users. A user with a custom encrypted-file path will see the operation fail with no explanation of the layout mismatch. A one-time check at view load that detects whether secrets/secrets.yaml is absent could surface a clear warning before the user attempts an operation.

Agree with concerns 13. SOPS encryption target recipients are not reported in the add result, so users cannot verify which identities received access after the operation completes.

For the standard single-rule template, the .sops.yaml creation rule matches exactly and recipients are correct. If a user has multiple path-based creation rules, SOPS may match the wrong rule and encrypt to unexpected recipients without any error. Reporting the matched creation rule name or the recipient count in the done state would let users detect mismatches without inspecting the encrypted file manually.

Agree with concerns 14. Edit value and Rotate & re-key are hidden entirely when not yet implemented, while the Delete button is visible and active for the same secret.

Hiding unimplemented actions keeps the surface clean. The concern is asymmetry: Delete is visible and works, but users who expect to update or rotate a value see nothing suggesting those operations are planned. A disabled state with a tooltip would communicate intent without adding noise.

Option What it gives users What it costs Effort to change later
Hidden (current) Clean surface No signal of planned features Low
Disabled with tooltip Intent communicated Slightly busier surface Low

Agree with concerns 15. When multiple Nix files contain 'sops.secrets', the fallback search fails with an error that does not name the conflicting files.

The error reads 'Found multiple Nix modules containing sops.secrets; expected modules/darwin/sops-secrets.nix'. It tells the user what was expected but not which files caused the conflict. A user with a home-manager options file that also references sops.secrets cannot self-diagnose without searching the repository manually. The backend already collects the candidate list; reporting those paths in the error costs one line.

Since the last review

  • Still open: Delete button is active for agenix secrets; backend rejects only after the user confirms the dialog (secret-detail-view.tsx: disabled={capability === "unavailable" || isDeleting} still leaves the button active for agenix secrets; secrets_management.rs returns the error string only after confirmation.)
  • Still open: Delete uses a plain confirmation dialog with no indication that a darwin-rebuild check will run (AlertDialogDescription in secret-detail-view.tsx describes the operation but adds no phase label or note about a running build check.)
  • Still open: Delete shows only a spinner in the button with no phase label during the darwin-rebuild verification step (secret-detail-view.tsx: the button label transitions to 'Deleting…' with a Loader2 spinner; no status line outside the button reflects the running build phase.)
  • Still open: Clean repository prerequisite fires at confirm time, not when the detail view or delete dialog opens (secrets_management.rs: status.clean_head check is inside delete_sops_secret, which runs after the user confirms the dialog.)
  • Still open: Delete confirmation dialog names no specific files that will change (AlertDialogDescription: 'This removes the encrypted value and Nix declaration, verifies the configuration, and commits the change.' — no file paths are named.)
  • Still open: Secret name is normalized to a lowercase slug with no record of the original name in the configuration (secrets_management.rs: declare_sops_secret calls apply_semantic_edit with sopsFile and key attrs only; no Nix comment preserving the original name is added.)
  • Still open: All SOPS secrets share one fixed encrypted file with no warning for users with a non-standard layout (secrets_management.rs: MANAGED_SOPS_FILE is hardcoded to 'secrets/secrets.yaml'; no user-facing warning is emitted when this file is absent.)
  • Still open: SOPS encryption target recipients are not reported in the add result (shared_types/secrets_management.rs: AddSecretResult fields are secret_id, encrypted_file, declaration_file, runtime_path, and commit_hash — no recipients or matched creation rule field.)
  • Addressed in code: Recipients section header implies these specific identities will receive access, in tension with the clarifying note (add-secret-view.tsx: checkboxes removed entirely; clarifying note 'Encryption uses the recipients in the matching .sops.yaml creation rule.' added directly below the 'Recipients — who can decrypt' heading.)
  • Still open: Edit value and Rotate & re-key are hidden instead of shown in a disabled state (secret-detail-view.tsx: canEdit = false and canRotate = false still render these buttons only when the flags are true, so they remain invisible.)
  • Still open: Multiple sops.secrets files error does not name the conflicting files (secrets_management.rs: error reads 'Found multiple Nix modules containing sops.secrets; expected modules/darwin/sops-secrets.nix' — candidate paths are collected but not included in the message.)
  • Still open: No committed milestone for agenix delete support; active button affects real sessions indefinitely (secrets_management.rs returns 'Deleting agenix secrets is not yet implemented'; no date, milestone, or UI indicator is added in this PR.)
  • Still open: Vault model may expose only global recipients, not per-file creation rule recipients (add-secret-view.tsx still derives displayed recipients from vault.recipients.filter((r) => r.inUse); no per-rule filtering is added.)
  • Still open: Error message for multiple sops.secrets files does not list the conflicting file paths (secrets_management.rs: the multi-candidate error formats only the expected standard path string, not the list of found candidate paths.)

Open questions

  • Does the team have a committed milestone for agenix add and delete support? The 'Agenix support will be added later' note and the backend error for delete both carry no date. If the timeline is open-ended, the active delete button on agenix secrets affects real sessions until that milestone ships.

  • Does the vault model expose per-file .sops.yaml creation rule recipients, or only the global list of all vault identities? If it exposes only the global list, the recipients section in the add form may show identities that SOPS will not use for the secrets/secrets.yaml creation rule.

  • When a user with two Nix modules containing 'sops.secrets' tries to add or delete a secret, does the error message name the conflicting files? The backend collects the candidates but the current message does not list them.

Recommendation

Ship with changes
The core SOPS add and delete flows are end-to-end: they encrypt, declare, verify, commit, and restore on failure. One change blocks shipment: the delete button is active for agenix secrets and the backend rejects the operation only after the user completes a destructive confirmation. Disabling the delete button when the secret backend is agenix (matching the pattern the add form already uses) removes the only disagree verdict. The remaining agree-with-concerns items are real gaps but they do not break the primary SOPS use case.

@prelint prelint Bot 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

The Delete button is rendered unconditionally for all secrets regardless of secret.decryptionCapability, but delete_sops_secret on the backend calls decrypt_sops_file internally — it must decryp...

apps/native/src/components/widget/secrets/secret-detail-view.tsx:248

1 finding(s) posted as inline comments.

Comment thread apps/native/src/components/widget/secrets/secret-detail-view.tsx

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR wires the previously mocked SOPS "add secret" and "delete secret" flows to real Rust backend implementations, replacing frontend simulations. On the backend it encrypts/decrypts the shared secrets/secrets.yaml via sops, declares/removes the secret in the nix-darwin module through the semantic Nix AST editor, verifies with darwin-rebuild dry-run, and commits only the two managed files. It also extracts reusable path helpers and a multi-file commit helper, and refactors ensure_secret to share them.

Changes:

  • New secrets::secrets_management::add_secret/delete_secret (SOPS only) with clean-repo precondition, dry-build verification, post-edit vault re-verification, and best-effort rollback; exposed via new secrets.addSecret/secrets.deleteSecret oRPC procedures and shared AddSecretResult/DeleteSecretResult types.
  • Reusable helpers: git::commit_files (selective multi-file commit), file_ops path helpers (repo_relative_path, repo_relative_path_string, relative_path_between), and nix_file_editor::remove_attrpath; ensure_secret refactored onto the shared relative-path helper.
  • Frontend: un-hides the Add-secret button, backend-owns the add flow with error surfacing in the apply sheet, adds a delete confirmation dialog in the detail view, and simplifies the add form to SOPS-only.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
apps/native/src-tauri/src/secrets/secrets_management.rs Core SOPS add/delete: encrypt/decrypt over stdin, YAML edit, module declaration edit, build verify, commit, rollback, plus tests.
apps/native/src-tauri/src/orpc/secrets.rs New addSecret/deleteSecret procedures and shared Git-state refresh after mutation.
apps/native/src-tauri/src/shared_types/secrets_management.rs Adds AddSecretResult/DeleteSecretResult specta types.
apps/native/src-tauri/src/git/exec.rs / git/mod.rs Generalizes commit_file into commit_files for selective multi-file commits; exports it; adds a test.
apps/native/src-tauri/src/evolve/file_ops.rs Adds repo-relative and lexical relative-path helpers with tests.
apps/native/src-tauri/src/evolve/nix_file_editor.rs Adds remove_attrpath for structural attrpath removal with tests.
apps/native/src-tauri/src/evolve/ensure_secret.rs / evolve/mod.rs Reuses shared path helper; exports GitignoreChecker.
apps/native/src/ipc/orpc-bindings.ts Generated TS bindings for the new inputs/results and procedures.
apps/native/src/components/widget/secrets/*.tsx Wires add flow, delete confirmation dialog, apply-sheet error display; SOPS-only add form.

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

Comment thread apps/native/src-tauri/src/secrets/secrets_management.rs Outdated
@czxtm
cooper (czxtm) requested review from darkmatteragent and removed request for darkmatteragent August 31, 2026 16:13

@darkmatteragent darkmatteragent 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.

Review — REQUEST CHANGES

ac9ad5228e23 · 5 findings

Implements SOPS secret add/delete end-to-end with verification and scoped commits, but the feature breaks on nested-config layouts and on nixmac's own per-secret files — request changes.

  • Git calls receive config-relative paths, so nested configs stage and restore the wrong paths, leaving half-applied edits.
  • Delete hardcodes secrets/secrets.yaml while nixmac's own ensure_secret flow creates secrets/.yaml, so those secrets can never be deleted.
  • Mutation-triggered vault refresh unmounts the UI mid-operation, losing result/error feedback; full-file re-encryption also re-keys sibling recipients unverified.

Findings

Caution

blocker · correctness — Git paths passed as config-relative but joined against repo workdir — nested configs commit and roll back the wrong paths
apps/native/src-tauri/src/secrets/secrets_management.rs:135

Gutter 135 passes &[MANAGED_SOPS_FILE, &declaration_rel] to commit_files(config_dir, ...) (same at gutters 287-291), and gutters 147-148 / 306-308 pass the same strings to restore_file(config_dir, ...). Both paths are relative to config_dir (gutters 94/243 set base = Path::new(config_dir) for every file_ops call), but commit_files (git/exec.rs:196-226) runs `git2::Repository::discover(

Caution

blocker · correctness — Delete hardcodes secrets/secrets.yaml; nixmac's own ensure_secret secrets (secrets/.yaml) deterministically fail to delete
apps/native/src-tauri/src/secrets/secrets_management.rs:95

Gutters 84-92 load the vault entry but gutter 95 discards secret.file and resolves the constant MANAGED_SOPS_FILE ("secrets/secrets.yaml", gutter 33) for every SOPS row — and secret-detail-view.tsx gutters 105-108 dispatch every vault row's Delete button here. This collides with the app's own producers: execute_ensure_secret creates secrets/{name}.yaml per secret (evolve/ensure_secret.rs:9

Warning

major · correctness — Mutation-triggered vault refresh unmounts the secrets UI mid-flight; add/delete result and error feedback are lost
apps/native/src-tauri/src/orpc/secrets.rs:102

Gutters 76 and 93 call refresh_state_after_mutation before the handler returns, and gutter 102 runs crate::git::query::status_and_cache(config_dir, &ctx.app) synchronously. status_and_cache writes the GitState cell, and git_state::update_status calls secrets_vault::refresh_if_active on every change (git_state.rs:114-115) — after a commit it always has. secrets_vault::refresh immediatel

Warning

major · security — Full-file re-encryption re-keys all sibling secrets to current .sops.yaml rules and is never verified decryptable before commit
apps/native/src-tauri/src/secrets/secrets_management.rs:261

Gutter 258 builds plaintext from updated_sops_plaintext, which decrypts the ENTIRE managed file (every sibling secret), and gutter 261 encrypt_sops_yaml(config_dir, MANAGED_SOPS_FILE, plaintext.as_bytes())? re-encrypts all of it with a fresh sops --encrypt (gutters 406-419 pass no metadata; decrypt_sops_file's --output-type yaml strips the sops: metadata block). Recipients therefore

Important

minor · correctness — remove_attrpath splits on '.' before unquoting, making dot-containing secret ids undeletable
apps/native/src-tauri/src/evolve/nix_file_editor.rs:916

Line 916 splits the raw attrpath on '.' (attrpath.split('.')) before line 917's normalize_attrpath_for_match strips quotes. For sops.secrets."my.token" — a legal nix quoted attr and a secret name nixmac itself permits (evolve/ensure_secret.rs:143 allows '.' in secret names) that the vault lists with id my.token — the target becomes ["sops","secrets","my","token"]. But `collect_attrpath_ass

Comment thread apps/native/src-tauri/src/orpc/secrets.rs
@darkmatteragent

darkmatteragent commented Aug 31, 2026

Copy link
Copy Markdown
Reviewer · darkmatter agent VERDICT
REQUEST CHANGES
Wires SOPS add/delete end-to-end with verification, scoped commits, and rollback — but two data-loss-class bugs must be fixed first: a stale, un-keyed vault cache that can commit a secret's plaintext to the wrong repo, and an attrpath normalizer that can silently delete sibling secret declarations. - The route's `lastVault` cache ignores host/config identity, so an add begun for repo A submits against repo B after a settings change. - `remove_attrpath` strips quotes/whitespace on both sides, so deleting one secret can match and remove a differently-quoted sibling and commit it. - The review sheet also previews a shared secrets.yaml update while the backend writes per-secret files, and both flows fail on common non-nixmac repo layouts.

Caution

blocker · correctness — lastVault cache is not keyed to host/config source: a pending add can commit plaintext to the wrong repository
apps/native/src/components/widget/secrets/secrets-management.tsx:104

Gutters 101-104: const lastVault = useRef<SecretsVault | null>(null); if (state?.vault) lastVault.current = state.vault; const vault = state?.vault ?? (state?.loading ? lastVault.current : null); — the cache is a plain ref with no source identity, and SecretsVaultState carries none (state/secrets_vault.rs refresh_now writes only activated/loading/error/vault). SecretsManagementRoute is rende

Caution

blocker · data-loss — remove_attrpath's quote/whitespace normalization collides distinct keys and silently deletes sibling declarations
apps/native/src-tauri/src/evolve/nix_file_editor.rs:970

Target segments are normalized at gutters 915-919 (.map(normalize_attrpath_for_match) on attrpath.split('.')) and source keys at gutter 970 (normalize_attrpath_for_match(&attr.syntax().text().to_string())), and normalize_attrpath_for_match strips all whitespace and quotes. Two valid coexisting declarations sops.secrets."foo bar" = …; and sops.secrets.foobar = …; both normalize to `foob

Warning

major · correctness — Review sheet depicts a shared secrets.yaml update, but the backend creates a new per-secret file
apps/native/src/components/widget/secrets/add-secret-view.tsx:18

buildAddRequest still renders the pre-patch shared-file story: gutter 18 { path: "secrets/secrets.yaml", note: "· encrypted update", mark: "~" }, gutter 21 diffFile: "secrets/secrets.yaml", gutter 25 { kind: "added", text: + ${slug}: ENC[AES256_GCM,data:••••••,type:str] }, and the form preview gutter 53 const encryptTarget = secrets/secrets.yaml › ${slug}``. The backend this PR wires u

Warning

major · correctness — Delete only works for nixmac-created per-file secrets; shared-sopsFile declarations fail with a cryptic error
apps/native/src-tauri/src/secrets/secrets_management.rs:93

Gutter 93 let encrypted_file = managed_sops_file(secret_id); hardcodes secrets/{id}.yaml for every delete, and gutter 94 resolve_existing_path_in_dir(base, &encrypted_file) fails when that file doesn't exist. Vault entries come from file = toString secret.sopsFile (load_sops_secrets), so secrets using the standard sops-nix pattern sopsFile = ./secrets.yaml — a shared file with no `secret

Warning

major · correctness — Add cannot merge into grouped/nested sops.secrets declarations — build check always fails and rolls back
apps/native/src-tauri/src/secrets/secrets_management.rs:450

declare_sops_secret always emits SetAttrs with path: format!("sops.secrets.\"{secret_id}\"") (gutter 450). set_attrs only merges when the FULL target path already exists; otherwise it inserts a fresh flat sops.secrets."<id>" = { … }; at top level. For a module whose secrets are grouped — sops.secrets = { existing = { … }; }; (discovered by find_sops_declaration_file because it contains "sops

reviewed: ac9ad52 — 2026-08-31T23:24Z verdict: request_changes findings: 5
reviewed: 6d33048 — 2026-09-01T07:32Z verdict: request_changes findings: 1
reviewed: 65a2f45 — 2026-09-02T13:04Z verdict: request_changes findings: 5

Reviewer avatar Reviewer
darkmatter agent
Run details
Metadata Value
Agent darkmatter/agent/reviewer
Session pr:darkmatter/nixmac#655
Kind pr
Runtime platform-runtime
Configured model litellm/glm-5.2-fp8
Target darkmatter/nixmac#655
Revision 65a2f4562997141fe43e88a014fcfe0a7fbbe754
Verdict request_changes
Reviewed at 2026-09-02T13:04Z
Logs Open Braintrust session

@darkmatteragent darkmatteragent 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.

Review — REQUEST CHANGES

6d330489eaf4 · 1 finding

End-to-end SOPS secret add/delete with clean-tree guards, darwin-rebuild verification, targeted commits, and fail-closed rollback — well built overall. One blocker: the new delete flow is keyed by secret id alone, but ids are only unique per backend, so a backend collision silently deletes the wrong secret.

Fix by carrying (backend, id) through the selection chain.

Findings

Caution

blocker · data-loss — Delete is keyed by id only; a backend id collision deletes the wrong (SOPS) secret
apps/native/src/components/widget/secrets/secret-detail-view.tsx:106

The new destructive call await client.secrets.deleteSecret({ secretId: secret.id, backend: secret.backend }) (lines 105-108) trusts an entry resolved upstream by id alone. In secrets-management.tsx, selectedSecret = vault.entries.find((s) => s.id === view.secretId) and VaultView's onOpenSecret(secret.id) carry only secret.id, but load_secrets_vault (secrets_management.rs:738-739) concate

Comment thread apps/native/src/components/widget/secrets/secret-detail-view.tsx
@prelint

prelint Bot commented Sep 1, 2026

Copy link
Copy Markdown

Prelint reached the review limit for this pull request (5 reviews of each kind). New pushes do not start a review.

  • To review the latest push, comment @prelint. The review still spends the quota.
  • To change the limit, open review configuration.

@darkmatteragent darkmatteragent 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.

Review — REQUEST CHANGES

65a2f4562997 · 5 findings

Wires SOPS add/delete end-to-end with verification, scoped commits, and rollback — but two data-loss-class bugs must be fixed first: a stale, un-keyed vault cache that can commit a secret's plaintext to the wrong repo, and an attrpath normalizer that can silently delete sibling secret declarations.

  • The route's lastVault cache ignores host/config identity, so an add begun for repo A submits against repo B after a settings change.
  • remove_attrpath strips quotes/whitespace on both sides, so deleting one secret can match and remove a differently-quoted sibling and commit it.
  • The review sheet also previews a shared secrets.yaml update while the backend writes per-secret files, and both flows fail on common non-nixmac repo layouts.

Findings

Caution

blocker · correctness — lastVault cache is not keyed to host/config source: a pending add can commit plaintext to the wrong repository
apps/native/src/components/widget/secrets/secrets-management.tsx:104

Gutters 101-104: const lastVault = useRef<SecretsVault | null>(null); if (state?.vault) lastVault.current = state.vault; const vault = state?.vault ?? (state?.loading ? lastVault.current : null); — the cache is a plain ref with no source identity, and SecretsVaultState carries none (state/secrets_vault.rs refresh_now writes only activated/loading/error/vault). SecretsManagementRoute is rende

Caution

blocker · data-loss — remove_attrpath's quote/whitespace normalization collides distinct keys and silently deletes sibling declarations
apps/native/src-tauri/src/evolve/nix_file_editor.rs:970

Target segments are normalized at gutters 915-919 (.map(normalize_attrpath_for_match) on attrpath.split('.')) and source keys at gutter 970 (normalize_attrpath_for_match(&attr.syntax().text().to_string())), and normalize_attrpath_for_match strips all whitespace and quotes. Two valid coexisting declarations sops.secrets."foo bar" = …; and sops.secrets.foobar = …; both normalize to `foob

Warning

major · correctness — Review sheet depicts a shared secrets.yaml update, but the backend creates a new per-secret file
apps/native/src/components/widget/secrets/add-secret-view.tsx:18

buildAddRequest still renders the pre-patch shared-file story: gutter 18 { path: "secrets/secrets.yaml", note: "· encrypted update", mark: "~" }, gutter 21 diffFile: "secrets/secrets.yaml", gutter 25 { kind: "added", text: + ${slug}: ENC[AES256_GCM,data:••••••,type:str] }, and the form preview gutter 53 const encryptTarget = secrets/secrets.yaml › ${slug}``. The backend this PR wires u

Warning

major · correctness — Delete only works for nixmac-created per-file secrets; shared-sopsFile declarations fail with a cryptic error
apps/native/src-tauri/src/secrets/secrets_management.rs:93

Gutter 93 let encrypted_file = managed_sops_file(secret_id); hardcodes secrets/{id}.yaml for every delete, and gutter 94 resolve_existing_path_in_dir(base, &encrypted_file) fails when that file doesn't exist. Vault entries come from file = toString secret.sopsFile (load_sops_secrets), so secrets using the standard sops-nix pattern sopsFile = ./secrets.yaml — a shared file with no `secret

Warning

major · correctness — Add cannot merge into grouped/nested sops.secrets declarations — build check always fails and rolls back
apps/native/src-tauri/src/secrets/secrets_management.rs:450

declare_sops_secret always emits SetAttrs with path: format!("sops.secrets.\"{secret_id}\"") (gutter 450). set_attrs only merges when the FULL target path already exists; otherwise it inserts a fresh flat sops.secrets."<id>" = { … }; at top level. For a module whose secrets are grouped — sops.secrets = { existing = { … }; }; (discovered by find_sops_declaration_file because it contains "sops

subtitle: `New secret · ${slug}`,
files: [{ path: "secrets/secrets.yaml", note: "· updated", mark: "~" }],
files: [
{ path: "secrets/secrets.yaml", note: "· encrypted update", mark: "~" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[major] Review sheet depicts a shared secrets.yaml update, but the backend creates a new per-secret file

buildAddRequest still renders the pre-patch shared-file story: gutter 18 { path: "secrets/secrets.yaml", note: "· encrypted update", mark: "~" }, gutter 21 diffFile: "secrets/secrets.yaml", gutter 25 { kind: "added", text: + ${slug}: ENC[AES256_GCM,data:••••••,type:str] }, and the form preview gutter 53 const encryptTarget = secrets/secrets.yaml › ${slug}``. The backend this PR wires up does something else: secrets_management.rs gutters 36-38 managed_sops_file returns `secrets/{secret_id}.yaml`, and add_sops_secret encrypts/commits that NEW file (gutter 242). The ApplySheet is the consent step for a config-repo commit; the user approves an "encrypted update to secrets/secrets.yaml" while a brand-new file is created and committed. Fix: build chips/diff/preview from the managed layout — `secrets/${slug}.yaml` with mark "+", and a declaration diff instead of a fabricated ENC line.

/// `sops.secrets.foo = { ... };`, nested attrsets, and a mixture of both. Empty parent
/// attrsets are intentionally preserved. An absent attrpath is reported as an error so
/// callers performing destructive operations do not silently succeed.
pub(crate) fn remove_attrpath(content: &str, attrpath: &str) -> Result<String> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

remove_attrpath splits the attrpath on ., so sops.secrets."my.secret" never matches — and the agent flow allows . in names (ensure_secret.rs:136-153). Delete of a dotted-name secret then always fails, after the encrypted file was already removed, relying on rollback.

.ok_or_else(|| anyhow!("Secret declaration '{secret_id}' does not exist"))?;

let base = Path::new(config_dir);
let encrypted_file = managed_sops_file(secret_id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Delete still targets by convention, not by declaration. The entry found in the vault just above is only existence-checked and discarded; the path is rebuilt as secrets/<id>.yaml. A secret whose sopsFile basename differs from its declaration name, lives outside secrets/, or sits in a shared file fails after the confirm dialog — and if an unrelated secrets/<id>.yaml happens to exist, that file is deleted and committed instead. Reproduced on a fresh template repo with demo = { sopsFile = ../../secrets/demo-renamed.yaml; } plus an unreferenced stray secrets/demo.yaml: delete reports success, and

$ git show --stat HEAD   # "secrets: delete demo (sops)"
 modules/darwin/sops-secrets.nix |  3 ---
 secrets/demo.yaml               | 16 ----------------

— the stray is gone, secrets/demo-renamed.yaml is left orphaned in the repo. Would resolving the path from the declaration's actual sopsFile work here?

variant="ghost"
size="sm"
className="text-destructive"
disabled={capability === "unavailable" || isDeleting}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Delete no longer needs decryption (it just removes the file), but this gate still disables it for capability === "unavailable" — exactly the case where delete matters most: a secret nothing local can decrypt anymore. Same gate on the confirm button at line 318. Can it be dropped?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Went over the new round — the refresh gate + last-vault UI, the backend-qualified ids, and the nixmacignore discovery all check out nicely. Three residuals as inline comments. The first is the one I'd still block on: delete resolves the file by naming convention rather than from the declaration, and I could reproduce it deleting and committing an unrelated file while reporting success. The other two (dotted names can't be deleted; the delete button still requires decryption capability it no longer needs) are smaller and fail closed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I like the direction this is taking!

But sadly, besides the comments inline, after spending a bunch of time trying the feature I haven't managed to get it to fully work. Some of my struggles where due to my own incorrect expectations, but some of it are, I reckon, bugs.

Problems getting it to work

The first set of problems was that I started trying it on a configuration that did not have SOPS set up in the flake at all. I did have a SOPS_AGE_KEY_FILE env variable, that was being correctly shown in "keys and recipients". The "add secret" button was available, so I tried it, filled in the data, and I was greeted with this error:

Image

I went ahead and added the sops.yaml file as instructed.

Then I was greeted with this error:

Image

So I committed separately.

Clearly that was not the only file needed though, since adding the secret would then cause the build to fail, because it was missing the SOPS flake input and the import of the module.

But then it was getting more annoying, because somehow sometimes (I think when the commit failed on the nix build or eval stages), Nixmac would leave the new secret and edited configuration there, and not revert it, which I would have to undo manually before trying again.

At some point I even got a full screen error:

Image

Finally, after I have resolved all the SOPS setup errors, I am still unable to add a secret:

Image

Once again, the changes are left on disk even after I press "Cancel", so if I try again it just shows the "Uncommited changes" error.

The upside of this is that I could verify the changes manually and commit myself, which worked, showing that this error is somehow an implementation issue.

Adding a second secret via the UI failed again with the same cryptic commit SOPS secret error.

So there are a few takeways from this journey:

  1. I think nixmac should allow you to setup SOPS, so you can do the whole workflow from the UI. There could be a few ways to do this in the UI, which we could discuss elsewhere. Perhaps you had it already planned for a later step, which would make sense to scope down this PR, sorry if this was the case.

  2. If not, the messages indicating what's missing could be improved, but no need really to spend much time on this if (1) is gonna be addressed.

  3. The requirement to have a clean working dir was a bit annoying, specially when the cause was the feature not cleaning up after error. But perhaps things could be improved:
    a. Maybe the requirement could be relaxed? The feature could stage/commit only the hunks it edits, so if there are no conflicts in that area, it could still commit and leave the working dir dirty with the other changes.
    b. The message could be shown as soon as you press the "add secret" button, or even gray it out and show some kind of message elsewhere that says "manual uncommited changes, editing secrets requires a clean working dir" or alike.
    c. The error could tell you what's dirty and have "review and commit" button to bring some kind of popup to solve the issue directly.

  4. The last error that I got stuck with looks genuinely like a bug. Maybe triggered by the fact that my flake is in a subdir nix/os? The produced edits where correct and built properly, it just failed to commit the changes.

Usability improvements

Besides this, there are some small usability issues:

  1. It seems like auto-correct is enabled in the "Name" and "Value" inputs in the "Add secret" form. This would cause surprising things like auto-capitalize the word when you leave the input, specially suprising on the Value, where you can't see change by default! I'd just disable auto-correct there.

  2. Pressing TAB when the Name input is focused moves the focus to the "hidden" button. It should move it to the "Value" input IMHO.

  3. Editing reformatted my whole configuration. I'm not sure I vibe with this...

let base = Path::new(config_dir);
if !base.join(".sops.yaml").is_file() && !base.join("sops.yaml").is_file() {
anyhow::bail!("No .sops.yaml or sops.yaml was found in the repository root");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I guess this is where it tried to detect if sops is there.

Writing .sops.yaml by hand isn't enough, two more preconditions follow:

  • find_sops_declaration_file (:411) needs a .nix file that already contains sops.secrets, else Could not find a Nix module containing sops.secrets. Nothing creates this one: declare_sops_secret goes through apply_semantic_edit, which requires an existing file (Copilot's point at :530).
  • the local age key has to match a creation rule, or encryption fails.

Comment on lines +179 to +183
if !status.clean_head {
anyhow::bail!(
"The repository has uncommitted changes. Commit or stash them before adding a secret so nixmac can roll back safely if verification fails."
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

See general comment for suggestions on how to make this error less frustrating for the user :)

Comment on lines +315 to +327
child
.stdin
.take()
.ok_or_else(|| anyhow!("open sops stdin"))?
.write_all(plaintext)
.context("send plaintext to sops")?;
let output = child.wait_with_output().context("wait for sops encrypt")?;
if !output.status.success() {
anyhow::bail!(
"sops encryption failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

sops's real error is hidden by a broken pipe.

When sops exits early (no matching creation rule, the common case) the write fails with EPIPE and returns first. The user sees send plaintext to sops: Broken pipe, and the sops encryption failed: … branch below never runs. Ignoring the write error, or reading stderr before propagating, would surface what sops actually said.

.filter_map(|entry| {
std::fs::read_to_string(entry.path())
.ok()
.filter(|text| text.contains("sops.secrets"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Module search misses the nested form. This doesn't match sops = { secrets = { … }; };, the form remove_attrpath handles and its own test uses. Add and delete both fail with "Could not find a Nix module containing sops.secrets".

let encrypted_file = managed_sops_file(secret_id);
let encrypted_path = resolve_existing_path_in_dir(base, &encrypted_file)
.with_context(|| format!("resolve {encrypted_file}"))?;
let declaration_file = find_sops_declaration_file(base)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The module isn't checked for the secret being deleted.

find_sops_declaration_file returns the standard module whenever it exists, without confirming it declares secret_id. Deleting a secret declared elsewhere fails at remove_attrpath with "attrpath does not exist" — after the encrypted file is already gone. Add has the mirror problem: it writes into a module that may not be where the user's other secrets live.

if (applyInFlight.current) return;
setApplyPhase("building");
setApplyError(null);
if (apply?.origin === "add" && pendingSecret) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not sure this is an actually reachable condition, but in theory add with no pending secret reports success.

When origin === "add" but pendingSecret is null, runApply falls through to the simulation timer, reaches done, and shows "Committed to your config", but nothing was sent to the backend. Could the add branch key off origin alone and error when the payload is missing?

const runtimePath = (backend === "agenix" ? "/run/agenix/" : "/run/secrets/") + slug;
const encryptTarget = `secrets/secrets.yaml › ${slug}`;
const runtimePath = `/run/secrets/${slug}`;
const invalid = !name.trim() || !value.trim();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case: validation checks the name, not the slug. The request sends slugifySecretName(name). A punctuation-only name slugifies to empty, passes the form, and only fails in the backend's validate_new_secret once the apply sheet is open. Can also be solved by sluggifying punctuation to -.

/// This is deliberately a no-op when the observable is not managed, which
/// keeps lower-level config helpers usable in isolated tests and early startup.
pub fn refresh<R: Runtime + 'static>(app: &AppHandle<R>) {
let mut gate = REFRESH_GATE.lock().unwrap();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The refresh gate holds its lock across refresh_now. (nit)

The guard stays alive while refresh_now does a synchronous write_sync() and event emit. std::sync::Mutex isn't reentrant, so anything re-entering refresh or begin_mutation from that emit deadlocks. A panic under the lock also poisons it, making the .unwrap()s at :38, :53 and :92 panic from then on.

I haven't given this bit that much though but I don't love raw/global locks in Rust they are normally an smell and I wonder if there is an alternative design where synchronization happens by wrapping the appropriate data.

// Fall back to the direct invalidation used before this state was
// synchronized here.
log::warn!("[{operation}] Failed to refresh Git state: {error}");
secrets_vault::refresh(&ctx.app);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The documented fallback never runs. (nit)

This is called while _refresh_guard is alive, so refresh just re-sets an already-true refresh_pending. The fallback the comment describes can't happen — the refresh always comes from Drop. Worth dropping the guard first, or dropping the comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants