Files
agent-orchestrator/secrets.py
T
notplantsandClaude 6c56c1953e refactor(secrets): store lives at /secrets, not /srv/secrets
/srv is the projects tree — agents grep, find and `ls -R` it constantly, so a store
under it turns up in ordinary searches and risks being read (or pasted) by accident.
/secrets sits outside that blast radius: nothing routinely walks it, and it is still
0700 loops, still not a repo, still ciphertext at rest.

Override with AO_SECRETS_STORE if a host puts it elsewhere.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 16:54:03 +00:00

115 lines
4.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""The one place secrets live on an orchestrator host: a sops+age encrypted store.
WHY: credentials used to sit in plaintext all over the box — passwords baked into git
remote URLs (`https://user:pass@host/...`, which `git remote -v` happily prints), API keys
in .env files, a private key at mode 0644. Anything in a repo is one `git add -A` away from
a push. So: ONE encrypted file, OUTSIDE every git tree, and a helper every project uses.
store: /secrets/store.yaml sops+age ciphertext, mode 0600
age key: ~/.config/sops/age/keys.txt the ONLY plaintext secret on disk, 0600
outside git by construction — /secrets is not a repo and has no remote.
USAGE (library):
from secrets import get, get_group
cookie = get("tangled.cookie")
env = get_group("cc_ci_testenv") # dict, e.g. to build an env
USAGE (CLI):
python3 engine/secrets.py list # group/key names only, never values
python3 engine/secrets.py get tangled.cookie # value to stdout (careful in logs)
python3 engine/secrets.py materialize <name> # write a runtime file a consumer needs
MATERIALIZED FILES: some consumers read a fixed path and cannot be taught otherwise (a
systemd EnvironmentFile, an ssh IdentityFile, `nix`'s authKeyFile). Those files still exist
on disk at 0600, but the STORE IS THE SOURCE OF TRUTH — `materialize` rewrites them from it.
Never edit a materialized file by hand; edit the store (`sops /secrets/store.yaml`) and
re-materialize, or the two silently drift.
ADDING A SECRET: sops /secrets/store.yaml (opens decrypted in $EDITOR, re-encrypts on save)
"""
import json, os, subprocess, sys, pathlib
STORE = os.environ.get("AO_SECRETS_STORE", "/secrets/store.yaml")
AGE_KEY = os.environ.get("SOPS_AGE_KEY_FILE", os.path.expanduser("~/.config/sops/age/keys.txt"))
# name -> (path, mode). Files a consumer reads from a fixed location; see MATERIALIZED FILES.
MATERIALIZE = {
"tangled-session": ("engine/.tangled-session", 0o600), # relative to the project dir
"cc-ci-testenv": ("/srv/cc-ci/.testenv", 0o600),
}
def _load():
"""Decrypt the store. Fails loudly: a silent empty dict would look like 'no secrets'."""
if not pathlib.Path(STORE).exists():
sys.exit(f"no secret store at {STORE} — see engine/README.md (Secrets)")
env = {**os.environ, "SOPS_AGE_KEY_FILE": AGE_KEY}
r = subprocess.run(["sops", "-d", "--output-type", "json", STORE],
capture_output=True, text=True, env=env)
if r.returncode != 0:
sys.exit(f"cannot decrypt {STORE} (age key at {AGE_KEY}?): {r.stderr.strip()[:300]}")
return json.loads(r.stdout)
def get(dotted, default=None):
"""get('group.key') -> value. Missing key returns default (None) rather than raising."""
cur = _load()
for part in dotted.split("."):
if not isinstance(cur, dict) or part not in cur:
return default
cur = cur[part]
return cur
def get_group(name):
"""get_group('cc_ci_testenv') -> dict of that group (empty dict if absent)."""
return _load().get(name, {})
def materialize(name, project_dir="."):
"""Write a runtime file from the store. Returns the path written."""
if name == "tangled-session":
cookie = get("tangled.cookie")
if not cookie:
sys.exit("store has no tangled.cookie")
p = pathlib.Path(project_dir) / MATERIALIZE[name][0]
body = f"TANGLED_COOKIE={cookie}\n"
elif name == "cc-ci-testenv":
p = pathlib.Path(MATERIALIZE[name][0])
body = "".join(f"{k}={v}\n" for k, v in get_group("cc_ci_testenv").items())
else:
sys.exit(f"unknown materialize target {name!r} — known: {', '.join(MATERIALIZE)}")
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(body)
p.chmod(MATERIALIZE[name][1])
return str(p)
def main():
argv = sys.argv[1:]
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return
cmd = argv[0]
if cmd == "list":
for g, v in _load().items():
print(f"{g}: {', '.join(v) if isinstance(v, dict) else '<value>'}")
elif cmd == "get":
if len(argv) < 2:
sys.exit("usage: secrets.py get <group.key>")
v = get(argv[1])
if v is None:
sys.exit(f"no such key: {argv[1]}")
print(v if isinstance(v, str) else json.dumps(v))
elif cmd == "materialize":
if len(argv) < 2:
sys.exit(f"usage: secrets.py materialize <{'|'.join(MATERIALIZE)}>")
print("wrote", materialize(argv[1], argv[2] if len(argv) > 2 else "."))
else:
sys.exit(f"unknown command {cmd!r} — list | get | materialize")
if __name__ == "__main__":
main()