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:
parent
09b0aad342
commit
9d4a49d49d
2 changed files with 25 additions and 2 deletions
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue