SOPS decrypt on Windows — PowerShell

Problem

Running sops --decrypt secrets/X.env.sops in PowerShell (or even Bash on this Windows machine without explicit flags) fails with:

Error unmarshalling input json: invalid character 'C' looking for beginning of value

SOPS defaults to JSON parsing when it cannot locate .sops.yaml via the working directory, or when the file extension alone is ambiguous. The dotenv format is not auto-detected on Windows.

Root cause

  • sops.exe at C:\Users\konar\.local\bin\sops.exe (v3.9.1, same binary as Git Bash /usr/bin/sops)
  • Without --config, SOPS searches for .sops.yaml starting from the process CWD, not the file’s directory. PowerShell CWD may differ from the repo root.
  • Without --input-type, SOPS falls back to JSON for .env.sops files.

Fix — always use explicit flags

$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
& "C:\Users\konar\.local\bin\sops.exe" `
  --config "C:\code_2026\p24-infra\.sops.yaml" `
  --input-type dotenv `
  --output-type dotenv `
  --decrypt "C:\code_2026\p24-infra\secrets\monitoring.env.sops"

Extract a single key safely (value never printed):

$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$raw = & "C:\Users\konar\.local\bin\sops.exe" `
  --config "C:\code_2026\p24-infra\.sops.yaml" `
  --input-type dotenv --output-type dotenv `
  --decrypt "C:\code_2026\p24-infra\secrets\monitoring.env.sops" 2>&1
$env:MY_KEY = ($raw | Select-String "^MY_KEY=").ToString() -replace "^MY_KEY=",""
Remove-Variable raw
# use $env:MY_KEY in a command, never print it
$env:MY_KEY = ""

Gotcha: flag order — --output MUST come before the file (incident #2040)

SOPS 3.x silently ignores any flag placed after the positional file argument (it warns, then treats the flag as an ignored extra positional). With the output flag dropped, the decrypt falls back to its default stdout sink — and the PowerShell tool captures that into the chat transcript. This is exactly how the entire n8n-bms4.env.sops leaked on 2026-06-29 (#2040).

# ❌ LEAKS — --output is AFTER the file, so SOPS ignores it and prints plaintext to stdout
& sops.exe --decrypt "...\secrets\n8n-bms4.env.sops" --output "C:\tmp\out.env"
 
# ✅ SAFE — --output BEFORE the positional file (writes to disk, nothing on stdout)
& sops.exe --decrypt --output "C:\tmp\out.env" "...\secrets\n8n-bms4.env.sops"
 
# ✅ SAFER — never write plaintext to disk; capture one key into a variable, never print it
$raw = & sops.exe --input-type dotenv --output-type dotenv --decrypt "...\secrets\file.env.sops" 2>&1
$env:MY_KEY = ($raw | Select-String "^MY_KEY=").ToString() -replace "^MY_KEY=",""
Remove-Variable raw

The safety hooks (.claude/hooks/pre-bash-safety.sh and pre-bash-safety-windows.ps1) now block any sops decrypt of secrets/*.env.sops unless the plaintext is sent to a safe sink (pipe to a filter, > file redirect, --output before the file, or exec-env) — so this mistake is caught before it runs. If a value still leaks, follow docs/playbooks/static-api-key-incident-rotation.md.

Gotcha: the PowerShell tool does NOT persist variables across separate invocations (near-miss #4813)

Each call to the PowerShell tool used by Claude Code agents is a fresh process — only the working directory carries over; $env:VAR and regular $var assignments from one tool call are gone in the next. This is easy to forget because docs/secrets-sops-age.md’s own “Add a new secret” example already shows the correct pattern (decrypt → append → encrypt as one combined script), but it is very tempting to split that into “readable” separate steps (generate secret, then decrypt, then merge+encrypt) — each looking fine in isolation, each silently operating on empty/default variables in reality.

What happened (2026-08-03, issue #4813): the secret and the decrypted plaintext were captured into $env:GMAIL_SESSION_MANAGER_KEY_TMP and $plain in one tool call, then referenced from a separate tool call two steps later. Both were empty in that second call. sops --encrypt does not fail on near-empty input — it happily encrypted a file containing almost nothing, overwriting secrets/n8n-bms4.env.sops in the working tree and silently discarding every other secret already in it (Redis password, other session-manager keys, N8N API key, etc.). No error was raised by any step. The only reason it didn’t reach git commit/push was a canary decrypt run immediately after, in yet another separate call, which happened to also omit $env:SOPS_AGE_KEY_FILE and therefore failed loudly for an unrelated reason (wrong default key path) — that unrelated failure is what triggered a manual check of file sizes, which is what actually caught the real problem. This was luck, not process — a canary that failed to load age identities from the wrong path could just as easily have been “fixed” by re-exporting the key and re-running the (still-broken) encrypt, still with empty content.

Fix — always do generate → decrypt → append → encrypt → canary as ONE PowerShell tool call, exactly as shown in docs/secrets-sops-age.md’s example, never split across multiple invocations:

cd C:\code_2026\p24-infra   # or your worktree
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
 
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
$bytes = New-Object byte[] 32
$rng.GetBytes($bytes)
$secretVal = [Convert]::ToBase64String($bytes) -replace '[+/=]',''
 
$plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\n8n-bms4.env.sops
if ($LASTEXITCODE -ne 0) { throw "decrypt failed" }
 
$newContent = ($plain -join "`n") + "`nNEW_KEY=$secretVal`n"
$tmpPath = "$PWD\secrets\n8n-bms4-edit.env.sops"
[System.IO.File]::WriteAllText($tmpPath, $newContent, [System.Text.UTF8Encoding]::new($false))
$enc = sops --encrypt --input-type dotenv --output-type dotenv $tmpPath
if ($LASTEXITCODE -ne 0) { throw "encrypt failed" }
[System.IO.File]::WriteAllText("$PWD\secrets\n8n-bms4.env.sops", (($enc -join "`n") + "`n"), [System.Text.UTF8Encoding]::new($false))
Remove-Item $tmpPath -Force
 
# Canary — in the SAME call, same $env:SOPS_AGE_KEY_FILE still in scope
$verify = sops --decrypt --input-type dotenv --output-type dotenv secrets\n8n-bms4.env.sops
if ($LASTEXITCODE -ne 0) { throw "SOPS CORRUPT -- do not commit" }

Before every commit that touches a .env.sops file, independent of the above: run git diff --stat secrets/<file>.env.sops and sanity-check the insertion/deletion counts are in the same ballpark (SOPS re-encrypts every line on any edit, so a full-file diff is normal — but a file that shrank from ~27 KB to ~3 KB is not). If anything modified a SOPS file is still an uncommitted working-tree change (never git added), git checkout -- <file> recovers instantly — this is exactly what saved #4813.

Supabase Management API from Windows

Python urllib fails with SSL: CERTIFICATE_VERIFY_FAILED on Windows — use PowerShell Invoke-RestMethod instead. For SQL with special characters ($$, newlines), build the JSON payload with Python (no SSL needed) and POST the file via PowerShell -InFile:

# 1. Python encodes SQL → json file (no secrets in file, only SQL)
python3 -c "
import json
with open('query.json','w') as f:
    json.dump({'query': open('migration.sql').read()}, f)
"
 
# 2. PowerShell posts with token in header
$headers = @{ "Authorization" = "Bearer $env:SUPA_TOKEN" }
Invoke-RestMethod -Uri $url -Method POST -Headers $headers `
  -ContentType "application/json" -InFile "query.json"
Remove-Item "query.json"
  • docs/playbooks/sops-windows-crlf.md — write-side issues (CRLF/BOM)
  • C:\Users\konar\.age\p24-infra-keys.txt — developer age key
  • secrets/administration.env.sops — contains SUPABASE_ACCESS_TOKEN for Management API (developer-only; excluded from secrets-sync.yml). Workers on bms-4 use ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN in secrets/role-secret-manager.env.sops. It is not in monitoring.env.sops (moved out 2026-07-05, 2620)