Repository navigation
writ: symlink deployment leaves SSH private keys 0644, which SSH refuses #382
Description
Symlink deployment cannot give a private key the permissions SSH requires. Git working trees preserve only the executable bit, so every non-executable file in a writ-managed clone sits at 0644 — and a deployed symlink exposes exactly those target permissions. SSH refuses a 0644 private key outright.
Observed, 2026-08-12
On the dogfooded machine (writ-managed since the 2026-08-09 cutover), ~/.ssh/id_rsa and ~/.ssh/id_ed25519 are writ symlinks into the personal-repo clone. Both targets were 0644. Every git push/git pull over SSH failed:
Permissions 0644 for '~/.ssh/id_rsa' are too open.
This private key will be ignored.
[email protected]: Permission denied (publickey).
The failure surfaced hours after it was created: pushes worked all day, consistent with ssh-agent serving the keys from memory, and the file path only became load-bearing when the agent no longer held them. So the broken state can lie dormant across a working session and appear "suddenly."
Immediate remediation applied by hand: chmod 600 on both targets (through the symlinks). This holds only until something refreshes the clone's working tree — a checkout, a reset, or a re-clone recreates the files under the default umask.
Why this is structural, not a one-off
- Git records file modes as only 100644 or 100755. A clone can never faithfully hold 0600.
- Symlink deployment intentionally has no copy step, so there is no point at which writ currently applies a mode.
- Therefore any SSH private key (or comparable secret) deployed by symlink is world-group-readable by construction, and SSH's own policy makes it unusable the moment the agent drops it.
The SOPS path already acknowledges this class of problem: writ's .sops-suffix handling produces a decrypted copy, and the scenario suite asserts the mode on decrypted secrets (sops_integration_test.go — "secret mode" assertions). The gap is files that are sensitive but not SOPS-encrypted, which today deploy as bare symlinks with no mode contract.
Shapes for a real fix (for the plan to weigh)
- Mode metadata on deployment entries — a declared mode per file (or per rule), applied by deploy and re-asserted by
writ upgradeandwrit status; symlinked files get the mode applied to the target. - Copy-with-mode for declared-sensitive files — sensitive entries deploy as copies with an enforced mode rather than symlinks, accepting the staleness trade-off symlinks exist to avoid.
- Fold into the
writ secretfamily — treat unencrypted private keys in a layer as a lint/refusal case, steering them to the SOPS path whose decrypted-copy machinery already owns modes.
writ status should flag a deployed-secret mode violation in any of these designs; today it reports healthy while SSH is unusable.
Found while running the repository's commit script on the dogfooding machine; the failed pull left a git close-branch half-completed (branch switched, not deleted), which the script run recovered from separately.
Activity
David-Noble-at-work commentedon Aug 12, 2026
Prior art located:
git-protect-encrypted-files, in the personal repo atHome/common/.local/bin/git-protect-encrypted-filesand writ-deployed to~/.local/bin.It is exactly the remediation this issue needs, in manual form: enumerate encrypted files via
git crypt status -e, thenchmod go-rwx(Unix) oricacls /inheritance:r /grant:r(Windows), with--dry-runand--verbose. The personal repo's.gitattributesdeclares/Home/*/.ssh/* filter=git-crypt, so both SSH keys from this incident are inside its coverage — running it from the clone repairs them.Two implications for the fix shapes above:
The gap is the trigger, not the tool. Nothing invokes the script after a checkout, clone, or writ refresh, which is why the 2026-08-09 cutover's fresh clone sat at 0644 until the agent dropped the keys. Fix shape 1 (writ applying modes at deploy/upgrade) is this script mechanized into the pipeline.
The SOPS migration strands it. The script enumerates through git-crypt's ledger only; files migrating to
.sopsleave that ledger, so its coverage shrinks to nothing as the migration completes. Whatever writ builds must cover both the SOPS decrypted-copy path (which already asserts modes) and declared-sensitive plaintext files, or the protection regresses precisely as the migration succeeds.David-Noble-at-work commentedon Aug 12, 2026
Severity datum: running
git-protect-encrypted-fileson the clone protected 76 encrypted files, not just the two SSH keys that surfaced the incident. The whole git-crypt secret surface —.pem,.env,.Personal-secrets, all of/Home/*/.ssh/*— had been group/other-readable since the 2026-08-09 cutover minted the clone. The manual sweep is done; the trigger gap this issue tracks is what allows it to regress on the next clone refresh.David-Noble-at-work commentedon Aug 21, 2026
Recurrence and ruling (2026-08-20). The writ sync at 17:51 today reset both key targets to 0644 again
and SSH refused them mid-workflow ("bad permissions" →
git fetchdead). Interim band-aid re-applied:chmod 600on the two targets in the writ clone — it regresses on every sync.Ruled: the SOPS migration is the fix; no separate writ mode story is needed. Confirmed in code: once the
keys are SOPS-encrypted, their pipeline moves from
file.link(symlink shows checkout's 0644 through) toencryption.decrypt+file.copy, andDecryptSopsFileboth defaults0o600and refuses any mode withgroup/other bits (
enforceSecretFloor, pkg/op/provider/encryption/provider.go) — deploying decryptedplaintext wider than owner-only is structurally impossible on both encrypted pipelines (plain and +render).
Already pinned by
cmd/writ/writ/deploy/sops_integration_test.govia the portable-fact pair (unix modebits / Windows DACL) on all three CI platforms.
Chartered: a front-to-back scenario — writ deploys an encrypted file, the target is asserted owner-only
in the platform's own terms — joins the judgment-scenario roster and lands with the personal-repo key
migration. This bug closes when the keys go encrypted, not by patching the link pipeline.