Skip to content

writ: symlink deployment leaves SSH private keys 0644, which SSH refuses #382

Open

Description

@David-Noble-at-work

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)

  1. Mode metadata on deployment entries — a declared mode per file (or per rule), applied by deploy and re-asserted by writ upgrade and writ status; symlinked files get the mode applied to the target.
  2. 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.
  3. Fold into the writ secret family — 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.


Epic: #430 · Feature: #405

Activity

  1. David-Noble-at-work commented on Aug 12, 2026

    @David-Noble-at-work
    ContributorAuthor

    Prior art located: git-protect-encrypted-files, in the personal repo at Home/common/.local/bin/git-protect-encrypted-files and writ-deployed to ~/.local/bin.

    It is exactly the remediation this issue needs, in manual form: enumerate encrypted files via git crypt status -e, then chmod go-rwx (Unix) or icacls /inheritance:r /grant:r (Windows), with --dry-run and --verbose. The personal repo's .gitattributes declares /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:

    1. 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.

    2. The SOPS migration strands it. The script enumerates through git-crypt's ledger only; files migrating to .sops leave 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.

  2. David-Noble-at-work commented on Aug 12, 2026

    @David-Noble-at-work
    ContributorAuthor

    Severity datum: running git-protect-encrypted-files on 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.

  3. added
    taskImplementation work item
    bugSomething isn't working
    Severity:CriticalData loss, corruption, or a security failure
    Priority:P0Now -- ahead of feature work
    and removed
    taskImplementation work item
    on Aug 17, 2026
  4. David-Noble-at-work commented on Aug 21, 2026

    @David-Noble-at-work
    ContributorAuthor

    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 fetch dead). Interim band-aid re-applied:
    chmod 600 on 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) to
    encryption.decrypt+file.copy, and DecryptSopsFile both defaults 0o600 and refuses any mode with
    group/other bits (enforceSecretFloor, pkg/op/provider/encryption/provider.go) — deploying decrypted
    plaintext wider than owner-only is structurally impossible on both encrypted pipelines (plain and +render).
    Already pinned by cmd/writ/writ/deploy/sops_integration_test.go via the portable-fact pair (unix mode
    bits / 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.

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

Metadata

Metadata

Labels

Epic:WindowsCampaignThe Windows campaign epicPriority:P0Now -- ahead of feature workSeverity:CriticalData loss, corruption, or a security failurebugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions