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.exeatC:\Users\konar\.local\bin\sops.exe(v3.9.1, same binary as Git Bash/usr/bin/sops)- Without
--config, SOPS searches for.sops.yamlstarting 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.sopsfiles.
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 rawThe 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"Related
docs/playbooks/sops-windows-crlf.md— write-side issues (CRLF/BOM)C:\Users\konar\.age\p24-infra-keys.txt— developer age keysecrets/administration.env.sops— containsSUPABASE_ACCESS_TOKENfor Management API (developer-only; excluded fromsecrets-sync.yml). Workers on bms-4 useROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKENinsecrets/role-secret-manager.env.sops. It is not inmonitoring.env.sops(moved out 2026-07-05, 2620)