fix: decrypt SOPS through explicit custody into private output
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
tegwick 2026-10-12 12:08:35 +02:00
parent 4cf26a9bc8
commit 681c5b0ea1
6 changed files with 499 additions and 27 deletions

View file

@ -7,7 +7,8 @@
#
# To edit an encrypted file: sops secrets/<file>
# To encrypt a new file: sops --encrypt --in-place secrets/<file>
# To decrypt to stdout (inspect): sops -d secrets/<file>
# Attended private recovery: make sops-decrypt FILE=secrets/<file>.yaml OUTPUT=<private-directory>/<new-file>
# Run through the custody unlock helper; never decrypt into a transcript.
# To add a recipient: update .sops.yaml + sops --rotate --in-place secrets/<file>
creation_rules:

View file

@ -50,19 +50,8 @@ hooks-test: ## Test that the pre-commit hook blocks plaintext secrets
@rm -rf sso-mfa/bootstrap/secrets/_hooktest
# ── SOPS / age ────────────────────────────────────────────────────────────────
sops-setup: ## Legacy dev-local setup: copy age key to SOPS default path
@mkdir -p ~/.config/sops/age
@if [[ -f ~/.config/age/key.txt ]]; then \
cp -n ~/.config/age/key.txt ~/.config/sops/age/keys.txt || true; \
chmod 600 ~/.config/sops/age/keys.txt; \
echo "✔ SOPS key ready at ~/.config/sops/age/keys.txt"; \
elif [[ -f ~/.config/sops/age/keys.txt ]]; then \
echo "✔ SOPS key already present at ~/.config/sops/age/keys.txt"; \
else \
echo "ERROR: age key not found at ~/.config/age/key.txt"; \
echo " Generate one with: age-keygen -o ~/.config/age/key.txt"; \
exit 1; \
fi
sops-setup: ## Validate the registered public age recipient; never install a private key
@python3 sso-mfa/bootstrap/sops-private.py setup
sops-edit: ## Edit an encrypted file with SOPS: make sops-edit FILE=secrets/foo.yaml
@[[ -n "$(FILE)" ]] || (echo "Usage: make sops-edit FILE=secrets/path/to/file.yaml"; exit 1)
@ -73,9 +62,10 @@ sops-encrypt: ## Encrypt a file in place: make sops-encrypt FILE=secrets/foo.yam
sops --encrypt --in-place $(FILE)
@echo "✔ Encrypted $(FILE)"
sops-decrypt: ## Decrypt a file to stdout (never writes plaintext to disk): make sops-decrypt FILE=secrets/foo.yaml
@[[ -n "$(FILE)" ]] || (echo "Usage: make sops-decrypt FILE=secrets/path/to/file.yaml"; exit 1)
sops -d $(FILE)
sops-decrypt: export NK_SOPS_FILE = $(FILE)
sops-decrypt: export NK_SOPS_OUTPUT = $(OUTPUT)
sops-decrypt: ## Decrypt age-only JSON/YAML to new private OUTPUT outside Git; explicit custody required
@python3 sso-mfa/bootstrap/sops-private.py decrypt
sops-rotate: ## Rotate SOPS recipients after updating .sops.yaml: make sops-rotate FILE=secrets/foo.yaml
@[[ -n "$(FILE)" ]] || (echo "Usage: make sops-rotate FILE=secrets/path/to/file.yaml"; exit 1)
@ -123,18 +113,16 @@ creds-init: sops-setup hooks ## One-time setup: verify prerequisites, configure
@command -v kubectl >/dev/null 2>&1 \
&& echo "✔ kubectl found" \
|| echo "WARN: kubectl not found (required for creds-apply/verify)"
@test -f ~/.config/sops/age/keys.txt \
|| (echo "ERROR: age key not found — run 'make sops-setup' after placing your key at ~/.config/age/key.txt"; exit 1)
@echo ""
@echo "✔ creds-init complete. Next: make creds-generate"
creds-generate: ## Generate all service secrets and print KeePassXC entry guide
creds-generate: ## Generate private service files for public-recipient encryption
@command -v openssl >/dev/null 2>&1 || (echo "ERROR: openssl not found"; exit 1)
cd sso-mfa/bootstrap && bash gen-secrets.sh
@sed -i "s|^generated_at: .*|generated_at: $$(date -Iseconds)|" sso-mfa/bootstrap/creds-state.yaml
@echo ""
@echo "State: generated_at updated in sso-mfa/bootstrap/creds-state.yaml"
@echo "Next : enter values into KeePassXC, then set keepass_confirmed: true"
@echo "Next : encrypt to the registered public recipient; never copy values into transcripts."
@echo " Run 'make creds-bundle' to create an encrypted offsite backup."
creds-bundle: ## Age-encrypt the ops bundle for offsite storage

View file

@ -174,8 +174,53 @@ its trap while a foreground child runs; this is not a process-group supervisor,
SIGKILL cleanup guarantee or a runtime authority boundary. Temporary key cleanup
on command return does not certify that an arbitrary child retained no copies.
Remaining concrete caller defects stay in NK-WP-0049-T02: Make `sops-setup` /
`creds-init` still install or require permanent HOME keys; Make `sops-decrypt`
still emits plaintext; legacy rotation and privacyIDEA bootstrap helpers contain
secret-bearing output. Do not use those legacy paths as the recovery procedure
Remaining concrete caller defects stay in NK-WP-0049-T02: legacy rotation and
privacyIDEA bootstrap helpers contain secret-bearing output; direct editor,
rotate and delegated apply/init paths still need their own custody/output audit. Do not use those legacy paths as the recovery procedure
or treat these narrower helper repairs as closure of the whole custody surface.
## Make recovery entry points — 2026-10-12
`make sops-setup` now validates only `keys/age.pub`. It never copies or generates
a private key; `creds-init` likewise has no permanent HOME key requirement.
Existing permanent files are not deleted or represented as remediated copies.
`make sops-decrypt FILE=... OUTPUT=...` requires the custody helper's explicit
`NETKINGDOM_CUSTODY_AGE_KEY_FILE`. The owned private regular key file must match
the registered public recipient. Output must be a new file in an owned private
parent outside Git worktrees (including linked-worktree markers and bare repos).
No destination file/symlink is overwritten. Partial tool output is removed on
failure; successful publication is atomic and mode 0600. The operator removes
successful plaintext after the approved operation. This does not promise cleanup
after SIGKILL, hostile same-UID interference or arbitrary child copies.
For the repository's age custody profile, the wrapper accepts SOPS JSON/YAML,
and rejects non-age recipients, age plugins, key groups, unknown metadata and
duplicate keys before decryption.
Other formats/backends require their own reviewed adapter; they are not silently
sent through a different credential source. YAML input requires PyYAML.
SOPS runs with an isolated HOME/XDG configuration and a minimal environment,
using an inherited descriptor for the explicit key. This prevents SOPS from
combining it with HOME/SSH keys, inline keys, key commands, proxies or remote key
services. Raw parser/tool diagnostics and plaintext stdout never enter the
terminal transcript. This wrapper assumes trusted installed `age`, `age-keygen`
and `sops` binaries; it is not an OS sandbox or task-authorization grant.
Use from the operator's approved custody terminal, with a private directory
created outside all repositories (for example using `mktemp -d`):
```bash
# OUTPUT's parent must exist and be owned/private; select a new filename.
make sops-custody-run COMMAND='make sops-decrypt FILE=secrets/example.yaml OUTPUT=/private/recovery/new-example.yaml'
```
The command string carries paths only. Never put a credential in FILE, OUTPUT,
COMMAND, shell history or Make variables. Tests exercise real SOPS 3.13.3 with
disposable keys; no production key or encrypted secret was accessed. The test
binary checksum was checked against the official release checksum; no detached
signature verification or installed-toolchain promotion is claimed.
Source basis: [SOPS age identity loading](https://github.com/getsops/sops/blob/v3.13.3/age/keysource.go)
loads explicit and ambient identities together, so setting `SOPS_AGE_KEY_FILE`
alone is insufficient to exclude ambient credentials.

View file

@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Age-only SOPS recovery to a new private file; no plaintext process output."""
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import shutil
import signal
import stat
import subprocess
import sys
import tempfile
import uuid
ROOT = Path(__file__).resolve().parents[2]
RECIPIENT = re.compile(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}\Z')
class Refused(Exception):
pass
def public_recipient():
lines = (ROOT / 'keys/age.pub').read_text().splitlines()
values = [line.strip() for line in lines if line.strip() and not line.startswith('#')]
if len(values) != 1 or not RECIPIENT.fullmatch(values[0]):
raise Refused('registered public recipient is invalid')
result = subprocess.run(['age', '-r', values[0]], input=b'', stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=10)
if result.returncode:
raise Refused('registered public recipient is invalid')
return values[0]
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise Refused('duplicate encrypted document key')
result[key] = value
return result
def age_document(source, recipient):
# Inspect ciphertext metadata before giving a credential to any subprocess.
# This repository's admitted SOPS profile is age-only, not arbitrary cloud
# KMS, key services, SSH identities or executable age plugins.
with source.open("rb") as encrypted:
raw = encrypted.read(8 * 1024 * 1024 + 1)
if len(raw) > 8 * 1024 * 1024:
raise Refused('encrypted input is too large')
if source.suffix == '.json':
data = json.loads(raw, object_pairs_hook=unique_object)
kind = 'json'
elif source.suffix in {'.yaml', '.yml'}:
import yaml
class UniqueLoader(yaml.SafeLoader):
def construct_mapping(self, node, deep=False):
self.flatten_mapping(node)
return unique_object((self.construct_object(key, deep=deep),
self.construct_object(value, deep=deep))
for key, value in node.value)
try:
data = yaml.load(raw, Loader=UniqueLoader)
except yaml.YAMLError:
raise Refused('invalid encrypted YAML input') from None
kind = 'yaml'
else:
raise Refused('age recovery supports SOPS JSON/YAML input')
metadata = data.get('sops') if isinstance(data, dict) else None
if not isinstance(metadata, dict):
raise Refused('SOPS metadata is missing')
known = {'age', 'kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp',
'key_groups', 'shamir_threshold', 'lastmodified', 'mac', 'version',
'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex',
'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex',
'mac_only_encrypted'}
if set(metadata) - known or metadata.get('key_groups'):
raise Refused('unsupported SOPS custody profile')
if any(metadata.get(key) for key in ('kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp')):
raise Refused('recovery requires age-only metadata')
recipients = metadata.get('age')
if not isinstance(recipients, list) or not recipients:
raise Refused('age recipient metadata is missing')
if any(not isinstance(row, dict) or not RECIPIENT.fullmatch(str(row.get('recipient', '')))
for row in recipients):
raise Refused('unsupported age recipient')
if recipient not in [row['recipient'] for row in recipients]:
raise Refused('document does not include the registered recipient')
return raw, kind
def private_key_fd():
name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '')
if not name:
raise Refused('explicit temporary custody key required')
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW)
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_size > 16384:
os.close(fd)
raise Refused('custody key must be an owned private regular file')
return fd
def decrypt(recipient):
source_name = os.environ.get('NK_SOPS_FILE', '')
output_name = os.environ.get('NK_SOPS_OUTPUT', '')
if not source_name or not output_name:
raise Refused('supply FILE and OUTPUT; plaintext stdout is disabled')
source = Path(source_name).resolve(strict=True)
output = Path(output_name).absolute()
parent = output.parent.resolve(strict=True)
if any((p / '.git').is_file() or (p / '.git/HEAD').is_file()
or ((p / 'HEAD').is_file() and (p / 'objects').is_dir() and (p / 'refs').is_dir())
for p in [parent, *parent.parents]):
raise Refused('plaintext output must be outside Git worktrees')
raw, kind = age_document(source, recipient)
binary = shutil.which('sops')
if binary is None:
raise Refused('sops executable is unavailable')
directory = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
temporary = '.netkingdom-sops-' + uuid.uuid4().hex
keyfd = None
try:
info = os.fstat(directory)
if info.st_uid != os.getuid() or info.st_mode & 0o077:
raise Refused('OUTPUT requires an owned private parent directory')
try:
os.stat(output.name, dir_fd=directory, follow_symlinks=False)
except FileNotFoundError:
pass
else:
raise Refused('OUTPUT already exists')
keyfd = private_key_fd()
derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
pass_fds=(keyfd,), timeout=10)
if derived.returncode or derived.stdout.strip() != recipient.encode():
raise Refused('custody key does not match the registered recipient')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600, dir_fd=directory)
with os.fdopen(fd, 'wb') as plain, tempfile.TemporaryDirectory(prefix='netkingdom-sops-home-') as isolated:
# HOME/XDG isolation matters: SOPS loads ambient keys in addition to
# an explicit key file. No inline keys, SSH keys, key commands,
# proxies or remote keyservice configuration are inherited.
env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': isolated,
'XDG_CONFIG_HOME': isolated, 'GNUPGHOME': isolated,
'SOPS_AGE_KEY_FILE': f'/proc/self/fd/{keyfd}',
'AWS_EC2_METADATA_DISABLED': 'true'}
result = subprocess.run([binary, '--decrypt', '--input-type', kind,
'--output-type', kind, '/dev/stdin'],
input=raw, stdout=plain, stderr=subprocess.DEVNULL,
env=env, cwd=isolated, pass_fds=(keyfd,), timeout=30)
if result.returncode:
raise Refused('SOPS decryption failed; no plaintext published')
plain.flush()
os.fsync(plain.fileno())
# Atomic no-overwrite publication within the private directory.
os.link(temporary, output.name, src_dir_fd=directory, dst_dir_fd=directory,
follow_symlinks=False)
os.unlink(temporary, dir_fd=directory)
os.fsync(directory)
finally:
if keyfd is not None:
os.close(keyfd)
try:
os.unlink(temporary, dir_fd=directory)
except FileNotFoundError:
pass
os.close(directory)
print('Decrypted to the requested private file. Remove it after the approved operation.')
def interrupted(_signum, _frame):
raise Refused('operation interrupted')
def main():
for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT):
signal.signal(sig, interrupted)
try:
if len(sys.argv) != 2 or sys.argv[1] not in {'setup', 'decrypt'}:
raise Refused('unsupported custody operation')
recipient = public_recipient()
if sys.argv[1] == 'setup':
print('Registered public recipient validated. No private key was read, copied or generated.')
else:
decrypt(recipient)
return 0
except Refused as exc:
print('ERROR: ' + str(exc), file=sys.stderr)
except (OSError, ValueError, TypeError, ImportError, subprocess.SubprocessError):
# Parser, filesystem and tool errors can include user data. Never echo.
print('ERROR: custody operation failed; input and tool diagnostics suppressed.', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())

227
tests/test_sops_private.py Normal file
View file

@ -0,0 +1,227 @@
"""Use real SOPS with disposable age custody; no repository secret inputs."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[1]
SOPS = shutil.which('sops')
pytestmark = pytest.mark.skipif(SOPS is None, reason='SOPS required for real crypto regression')
@pytest.fixture
def case(tmp_path):
repo = tmp_path / 'repo'; (repo / 'sso-mfa/bootstrap').mkdir(parents=True)
shutil.copyfile(ROOT / 'Makefile', repo / 'Makefile')
shutil.copyfile(ROOT / 'sso-mfa/bootstrap/sops-private.py', repo / 'sso-mfa/bootstrap/sops-private.py')
(repo / 'keys').mkdir()
key = tmp_path / 'disposable.key'
subprocess.run(['age-keygen', '-o', str(key)], capture_output=True, check=True)
public = subprocess.check_output(['age-keygen', '-y', str(key)], text=True).strip()
(repo / 'keys/age.pub').write_text(public + '\n')
home = tmp_path / 'home'; home.mkdir(mode=0o700)
env = {'HOME': str(home), 'PATH': os.environ['PATH'], 'NETKINGDOM_CUSTODY_AGE_KEY_FILE': str(key)}
clear = tmp_path / 'input.json'; clear.write_text(json.dumps({'credential': 'synthetic-secret-payload'}))
cipher = repo / 'cipher.json'
encrypted = subprocess.run([SOPS, '--encrypt', '--age', public, str(clear)], cwd=tmp_path,
env=env, capture_output=True, check=True)
cipher.write_bytes(encrypted.stdout)
output_dir = tmp_path / 'private-output'; output_dir.mkdir(mode=0o700)
return repo, key, env, cipher, output_dir / 'restored.json'
def make(case, target='sops-decrypt', *, env=None, source=None, output=None):
repo, _, default_env, cipher, destination = case
return subprocess.run(['make', '-s', target, 'FILE=' + str(source or cipher),
'OUTPUT=' + str(output or destination)],
env=env or default_env, cwd=repo, capture_output=True, text=True, timeout=15)
def ambient_key(case):
_, key, env, _, _ = case
path = Path(env['HOME']) / '.config/sops/age/keys.txt'
path.parent.mkdir(parents=True); path.write_bytes(key.read_bytes()); path.chmod(0o600)
def test_setup_never_installs_private_key(case):
_, key, env, _, _ = case
legacy = Path(env['HOME']) / '.config/age/key.txt'
legacy.parent.mkdir(parents=True); legacy.write_bytes(key.read_bytes())
result = make(case, 'sops-setup')
assert result.returncode == 0
assert not (Path(env['HOME']) / '.config/sops').exists()
def test_decrypt_real_sops_to_private_output_without_transcript(case):
ambient_key(case) # Old target succeeds through ambient discovery and leaks.
result = make(case)
assert result.returncode == 0
assert 'synthetic-secret-payload' not in result.stdout + result.stderr
assert json.loads(case[4].read_text()) == {'credential': 'synthetic-secret-payload'}
assert case[4].stat().st_mode & 0o077 == 0
assert not list(case[4].parent.glob('.netkingdom-sops-*'))
def test_wrong_explicit_key_never_falls_back_to_home_or_inline_keys(case, tmp_path):
ambient_key(case)
wrong = tmp_path / 'wrong.key'
subprocess.run(['age-keygen', '-o', str(wrong)], capture_output=True, check=True)
env = {**case[2], 'NETKINGDOM_CUSTODY_AGE_KEY_FILE': str(wrong), 'SOPS_AGE_KEY': case[1].read_text()}
result = make(case, env=env)
assert result.returncode != 0
assert 'synthetic-secret-payload' not in result.stdout + result.stderr
assert not case[4].exists()
def test_absent_explicit_custody_refused_even_if_home_has_key(case):
ambient_key(case)
env = dict(case[2]); del env['NETKINGDOM_CUSTODY_AGE_KEY_FILE']
assert make(case, env=env).returncode != 0
assert not case[4].exists()
@pytest.mark.parametrize('kind', ['file', 'symlink'])
def test_existing_output_is_not_replaced(case, kind):
ambient_key(case)
if kind == 'file': case[4].write_bytes(b'original-output')
else: case[4].symlink_to(case[4].parent / 'absent')
assert make(case).returncode != 0
if kind == 'file': assert case[4].read_bytes() == b'original-output'
else: assert case[4].is_symlink()
def test_git_worktree_output_is_refused(case):
ambient_key(case)
subprocess.run(['git', 'init', str(case[0])], env=case[2], capture_output=True, check=True)
assert make(case, output=case[0] / 'plaintext.json').returncode != 0
assert not (case[0] / 'plaintext.json').exists()
def test_shared_output_directory_is_refused(case):
ambient_key(case)
case[4].parent.chmod(0o755)
assert make(case).returncode != 0
assert not case[4].exists()
def noisy_tool(case, tmp_path):
bin_dir = tmp_path / 'bin'; bin_dir.mkdir()
tool = bin_dir / 'sops'
tool.write_text('#!/bin/sh\necho synthetic-tool-secret\necho synthetic-error-secret >&2\nexit 23\n')
tool.chmod(0o755)
return {**case[2], 'PATH': str(bin_dir) + ':' + case[2]['PATH']}
def test_child_output_and_errors_are_not_exposed_on_failure(case, tmp_path):
result = make(case, env=noisy_tool(case, tmp_path))
assert result.returncode != 0
assert 'synthetic-tool-secret' not in result.stdout + result.stderr
assert 'synthetic-error-secret' not in result.stdout + result.stderr
assert not case[4].exists()
assert not list(case[4].parent.glob('.netkingdom-sops-*'))
def test_non_age_metadata_refuses_before_tool(case, tmp_path):
data = json.loads(case[3].read_text()); data['sops']['kms'] = [{'arn': 'synthetic'}]
case[3].write_text(json.dumps(data))
result = make(case, env=noisy_tool(case, tmp_path))
assert result.returncode != 0
assert 'age-only metadata' in result.stderr
assert 'synthetic-tool-secret' not in result.stdout
def test_filename_is_not_shell_code(case):
ambient_key(case)
odd = case[0] / 'cipher; touch SHELL_EXECUTED; #.json'
shutil.copyfile(case[3], odd)
result = make(case, source=odd)
assert result.returncode == 0
assert not (case[0] / 'SHELL_EXECUTED').exists()
assert case[4].exists()
def test_yaml_roundtrip(case, tmp_path):
public = (case[0] / 'keys/age.pub').read_text().strip()
clear = tmp_path / 'input.yaml'; clear.write_text('credential: synthetic-yaml-secret\n')
cipher = case[0] / 'cipher.yaml'
result = subprocess.run([SOPS, '--encrypt', '--age', public, str(clear)], cwd=tmp_path,
env=case[2], capture_output=True, check=True)
cipher.write_bytes(result.stdout)
result = make(case, source=cipher)
assert result.returncode == 0
assert 'synthetic-yaml-secret' not in result.stdout + result.stderr
assert 'synthetic-yaml-secret' in case[4].read_text()
def test_parser_error_never_echoes_input(case):
case[3].write_text('synthetic-malformed-secret')
result = make(case)
assert result.returncode != 0
assert 'synthetic-malformed-secret' not in result.stdout + result.stderr
assert not case[4].exists()
def test_shared_custody_key_is_refused(case):
case[1].chmod(0o644)
result = make(case)
assert result.returncode != 0
assert not case[4].exists()
def test_creds_init_has_no_permanent_key_requirement(case):
subprocess.run(['git', 'init', str(case[0])], env=case[2], capture_output=True, check=True)
hooks = case[0] / '.githooks'; hooks.mkdir()
(hooks / 'pre-commit').write_text('#!/bin/sh\nexit 0\n')
result = make(case, 'creds-init')
assert result.returncode == 0
assert not (Path(case[2]['HOME']) / '.config/sops').exists()
def test_real_sops_does_not_execute_ambient_key_commands(case, tmp_path):
marker = tmp_path / 'ambient-command-executed'
command = tmp_path / 'ambient-key-command'
command.write_text('#!/bin/sh\ntouch ' + str(marker) + '\nexit 1\n')
command.chmod(0o755)
env = {**case[2], 'SOPS_AGE_KEY_CMD': str(command), 'SOPS_AGE_SSH_PRIVATE_KEY_CMD': str(command)}
result = make(case, env=env)
assert result.returncode == 0
assert not marker.exists()
def test_yaml_parser_error_is_sanitized(case):
bad = case[0] / 'broken.yaml'
bad.write_text('secret: [synthetic-malformed-yaml-secret\n')
result = make(case, source=bad)
assert result.returncode != 0
assert 'synthetic-malformed-yaml-secret' not in result.stdout + result.stderr
assert 'Traceback' not in result.stderr
@pytest.mark.parametrize('kind', ['json', 'yaml'])
def test_duplicate_metadata_refused_before_sops(case, kind):
source = case[0] / ('duplicate.' + kind)
source.write_text('{"sops": {}, "sops": {}}' if kind == 'json' else 'sops: {}\nsops: {}\n')
result = make(case, source=source)
assert result.returncode != 0
assert 'duplicate' in result.stderr
@pytest.mark.parametrize('kind', ['bare', 'linked'])
def test_bare_and_linked_git_outputs_are_refused(case, kind):
location = case[4].parent / 'git-output'
if kind == 'bare':
subprocess.run(['git', 'init', '--bare', str(location)], env=case[2], capture_output=True, check=True)
else:
subprocess.run(['git', 'init', str(case[0])], env=case[2], capture_output=True, check=True)
subprocess.run(['git', '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid',
'commit', '--allow-empty', '-m', 'fixture'], cwd=case[0], env=case[2],
capture_output=True, check=True)
subprocess.run(['git', 'worktree', 'add', '--detach', str(location)], cwd=case[0], env=case[2],
capture_output=True, check=True)
location.chmod(0o700)
result = make(case, output=location / 'plaintext.json')
assert result.returncode != 0
assert not (location / 'plaintext.json').exists()

View file

@ -74,8 +74,6 @@ signal deferral and arbitrary-child copy retention are explicitly not solved by
this helper. Evidence: Custodian `custody-entrypoints.json`.
Concrete uncovered callers remain (T02 stays progress):
- `Makefile` sops-setup/creds-init copy or require permanent HOME private keys.
- `Makefile` sops-decrypt sends plaintext to stdout.
- `sso-mfa/bootstrap/creds-rotate.sh` logs newly generated values and embeds a
password in a printed SQL command. Its source can be repaired without executing
founder-deferred rotations; no such rotation has been run.
@ -83,6 +81,18 @@ Concrete uncovered callers remain (T02 stays progress):
- Delegated create/apply helpers still need full tracing/error/Secret-annotation
output review; transport capture and native effect evidence remain mandatory.
2026-10-12 Make caller repair: sops-setup now validates only the public recipient;
creds-init no longer requires or installs a permanent private key. sops-decrypt
uses the new age-only SOPS recovery adapter: explicit matching private custody
file, isolated HOME/XDG/environment, pinned file descriptor, sanitized diagnostics,
private no-overwrite output outside Git, atomic publication and failure cleanup.
FILE/OUTPUT are passed as environment data instead of interpolated shell code.
The old targets failed all 11 initial real-SOPS regressions. Additional format,
parser, environment-command and credential-mode tests cover the final adapter.
Existing HOME copies were not deleted; native recovery and the remaining direct
editor/rotate/delegated paths are unverified. Evidence is Custodian
`sops-make-entrypoints.json`; T02 remains progress, not source-surface closure.
## Verify owner-approved recovery and current native callers
```task