feat(vault): CHANGEME placeholder convention + check-vault flags them

Streamline the recurring secret-entry friction: the agent stubs a needed secret as
vault.<service>.<key>=CHANGEME with a what/how-to-obtain comment, wires the code,
and commits; the operator fills it via make edit-vault (real value never hits chat).
check-vault now lists outstanding CHANGEME placeholders so none are forgotten.
Convention documented in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-14 15:40:37 +02:00
parent 09b0aad342
commit 9d4a49d49d
2 changed files with 25 additions and 2 deletions

View file

@ -84,7 +84,15 @@ Full design rationale: `docs/decisions/`
`encrypt FILE=<path>` still exist for scripted/non-interactive edits.) `encrypt FILE=<path>` still exist for scripted/non-interactive edits.)
- `make check-vault` validates the vault decrypts, is valid YAML, keeps secrets under the - `make check-vault` validates the vault decrypts, is valid YAML, keeps secrets under the
nested `vault:` map, and has no empty leaves — printing a structure view with values nested `vault:` map, and has no empty leaves — printing a structure view with values
masked. Needs `rbw` unlocked. masked. Needs `rbw` unlocked. It also **flags any leaf still set to `CHANGEME`** (see
next bullet).
- **Stubbing a secret the operator must supply** (don't ping-pong over chat): when a new
secret is needed, the agent itself adds the vault entry with the sentinel value
**`CHANGEME`** plus a comment stating *what it is and how to obtain it*, wires the code
to `{{ vault.<service>.<key> }}`, and commits that. Then prompt the operator to run
`make edit-vault`, replace the `CHANGEME`(s) with the real value(s) — which never touch
the conversation — and re-encrypt. `make check-vault` lists any outstanding `CHANGEME`
placeholders so nothing is forgotten. The agent never handles the real secret.
--- ---

View file

@ -9,7 +9,9 @@ YAML, and checks:
- it decrypts to valid YAML that is a mapping; - it decrypts to valid YAML that is a mapping;
- top-level keys are within the allowed set (``vault`` + the ``vault__confirm`` - top-level keys are within the allowed set (``vault`` + the ``vault__confirm``
canary) secrets belong under the nested ``vault:`` map (CLAUDE.md); canary) secrets belong under the nested ``vault:`` map (CLAUDE.md);
- ``vault.<service>.<key>`` leaves are all non-empty strings. - ``vault.<service>.<key>`` leaves are all non-empty strings;
- reports leaves still set to the ``CHANGEME`` sentinel a secret the agent stubbed
that the operator must fill via ``make edit-vault``.
Prints a REDACTED view (comments + key tree, values masked) so a human can eyeball Prints a REDACTED view (comments + key tree, values masked) so a human can eyeball
format and comments. Secret values are never printed. format and comments. Secret values are never printed.
@ -31,6 +33,7 @@ except ImportError:
DEFAULT = "inventories/production/group_vars/all/vault.yml" DEFAULT = "inventories/production/group_vars/all/vault.yml"
ALLOWED_TOPLEVEL = {"vault", "vault__confirm"} ALLOWED_TOPLEVEL = {"vault", "vault__confirm"}
SENTINEL = "CHANGEME" # placeholder the agent writes for a secret the operator must fill
def main() -> int: def main() -> int:
@ -57,6 +60,7 @@ def main() -> int:
sys.exit("check-vault: vault root is not a mapping") sys.exit("check-vault: vault root is not a mapping")
errors = [] errors = []
placeholders = []
extra = set(data) - ALLOWED_TOPLEVEL extra = set(data) - ALLOWED_TOPLEVEL
if extra: if extra:
errors.append( errors.append(
@ -74,6 +78,8 @@ def main() -> int:
for k, v in kv.items(): for k, v in kv.items():
if not isinstance(v, str) or not v.strip(): if not isinstance(v, str) or not v.strip():
errors.append(f"vault.{svc}.{k} is empty or not a string") errors.append(f"vault.{svc}.{k} is empty or not a string")
elif v.strip() == SENTINEL:
placeholders.append(f"vault.{svc}.{k}")
# Redacted structure (comments + masked values) for human review. # Redacted structure (comments + masked values) for human review.
print(f"# {path} — redacted structure (secret values masked)") print(f"# {path} — redacted structure (secret values masked)")
@ -88,11 +94,20 @@ def main() -> int:
if isinstance(data.get("vault"), dict): if isinstance(data.get("vault"), dict):
print("\n# services under vault: " + ", ".join(sorted(data["vault"]))) print("\n# services under vault: " + ", ".join(sorted(data["vault"])))
if placeholders:
print("\n# placeholders awaiting real values (see the comments above for how to obtain each):")
for ph in placeholders:
print(f"# - {ph}")
if errors: if errors:
print("\ncheck-vault: FAIL", file=sys.stderr) print("\ncheck-vault: FAIL", file=sys.stderr)
for e in errors: for e in errors:
print(f" - {e}", file=sys.stderr) print(f" - {e}", file=sys.stderr)
return 1 return 1
if placeholders:
print(f"\ncheck-vault: OK — {len(placeholders)} placeholder(s) still need real values "
f"(fill via `make edit-vault`): {', '.join(placeholders)}")
return 0
print("\ncheck-vault: OK") print("\ncheck-vault: OK")
return 0 return 0