diff --git a/CLAUDE.md b/CLAUDE.md index 371bff8..cb60119 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,16 @@ src/vaultctl/ - `_previous` backup keys are excluded; field names with non-identifier characters get quoted +**8. Schema-aware `set` (#40 phase 3):** +- After computing the new vault data, `set` validates it against + `.vaultctl/vault.cue` if both the baseline and `cue` exist +- `_previous` backup keys are stripped from the validation input — they aren't + in the schema by design, and pre-existing backups must not look like drift + for unrelated changes +- Default behaviour is interactive: prompt to extend, then prompt to proceed + with drift. `--extend-schema` / `--no-extend-schema` short-circuit both + prompts; `--force` alone implies `--no-extend-schema` + ### CLI Commands | Command | Description | diff --git a/README.md b/README.md index 4be31cd..b2fdf25 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,27 @@ vaultctl schema sync --apply # rewrite the baseline to match the vault `schema sync` re-derives the schema from the current vault content and compares it to `.vaultctl/vault.cue`. Without `--apply` it prints a unified diff and exits non-zero; with `--apply` it rewrites the baseline. `vault.constraints.cue` is never touched — CUE merges it back in at validation time. +**Schema-aware `set`:** + +When a baseline exists, `vaultctl set` checks the new vault content against it before encrypting. If a value introduces structure the schema doesn't cover (e.g. a new key), `set` shows the diff and asks whether to extend the baseline: + +```bash +vaultctl set new_token "abc123" +# → Schema does not cover this change: +# --- .vaultctl/vault.cue (current) +# +++ .vaultctl/vault.cue (inferred) +# ... +# + new_token: string +# Extend .vaultctl/vault.cue with this change? [y/N]: +``` + +Flags for non-interactive use: +- `--extend-schema` — extend silently. +- `--no-extend-schema` — leave the baseline alone, accept drift (`vaultctl validate` will flag it later). +- `--force` without either flag — same as `--no-extend-schema` (safer non-interactive default). + +Without a baseline or without the `cue` binary, the check is skipped entirely. + **Without `cue` installed:** schema checks are skipped with a warning; the cross-consistency check still runs. ## Type Detection diff --git a/src/vaultctl/cli.py b/src/vaultctl/cli.py index 5b8809d..2bf2f27 100644 --- a/src/vaultctl/cli.py +++ b/src/vaultctl/cli.py @@ -478,6 +478,14 @@ def _output_base64_encoded(value: Any) -> None: @click.option("--backup/--no-backup", default=True, help="Save previous value as _previous.") @click.option("--expires", default=None, help="Expiry date (YYYY-MM-DD) for vault-keys.yml.") @click.option("--force", is_flag=True, default=False, help="Skip confirmation prompts.") +@click.option( + "--extend-schema/--no-extend-schema", + "extend_schema", + default=None, + help="If the new value introduces structure not in .vaultctl/vault.cue, " + "either extend it (--extend-schema) or skip the prompt and proceed with drift " + "(--no-extend-schema). Default: prompt interactively.", +) @pass_ctx def set( vctx: VaultContext, @@ -490,6 +498,7 @@ def set( backup: bool, expires: str | None, force: bool, + extend_schema: bool | None, ) -> None: """Set a vault key.""" value = _resolve_set_value(value, use_prompt, from_file, from_base64, from_base64_file, key) @@ -514,6 +523,8 @@ def set( data[key] = value + _apply_schema_aware_check(vctx, data, extend_schema=extend_schema, force=force) + try: encrypt_vault(data, vctx.config.vault_file, vctx.password) except VaultError as exc: @@ -753,6 +764,55 @@ def _print_detection_json(results: list[DetectionResult]) -> None: click.echo(json.dumps(items, indent=2)) +def _apply_schema_aware_check( + vctx: VaultContext, + new_data: dict[str, Any], + extend_schema: bool | None, + force: bool, +) -> None: + """Validate `new_data` against the project schema baseline and react to drift. + + Quietly skips the check when: + - the cue binary isn't available (validation isn't possible); + - no `.vaultctl/vault.cue` exists (no baseline to compare against). + + Otherwise, if the new vault content fails validation against the baseline: + - prompt to extend the schema (default), or + - extend silently if `--extend-schema` was passed, + - or proceed with drift if `--no-extend-schema` (or `--force`) was passed. + + Aborts the calling command with sys.exit(1) if the user declines both + options interactively. The caller has not yet written the vault, so abort + leaves it unchanged. + """ + baseline = vctx.config.config_dir / ".vaultctl" / "vault.cue" + if not (cue_available() and baseline.is_file()): + return + + # The schema deliberately excludes `_previous` backup keys (see + # infer_vault_schema). Strip them before validation so the schema check + # only reacts to the change being set, not to pre-existing backups. + check_data = {k: v for k, v in new_data.items() if not k.endswith("_previous")} + if not validate_vault_data(check_data, override=baseline): + return + + click.echo("Schema does not cover this change:", err=True) + _, current, fresh = compute_schema_drift(check_data, baseline) + click.echo(render_schema_diff(current, fresh, str(baseline)), err=True) + + decision = extend_schema + if decision is None: + # If --force is set with no explicit schema flag, pick the safer + # non-interactive default: leave drift; user notices via `validate`. + decision = False if force else click.confirm("Extend .vaultctl/vault.cue with this change?", default=False) + + if decision: + baseline.write_text(fresh, encoding="utf-8") + click.echo(f"Updated {baseline}.") + elif not force: + click.confirm("Proceed without updating schema?", default=False, abort=True) + + def _resolve_set_value( value: str | None, use_prompt: bool, diff --git a/tests/test_cli.py b/tests/test_cli.py index 308cd75..c393414 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -855,3 +855,90 @@ def test_schema_sync_apply_updates_baseline(runner, cli_env, tmp_path): follow_up = runner.invoke(main, ["schema", "sync"]) assert follow_up.exit_code == 0, follow_up.output assert "No drift" in follow_up.output + + +# --- schema-aware set (#40 phase 3) --- + + +def test_set_skips_schema_check_when_no_baseline(runner, cli_env): + """Without a baseline, set behaves exactly as before.""" + result = runner.invoke(main, ["set", "brand_new_key", "v", "--force", "--no-backup"]) + assert result.exit_code == 0, result.output + assert "Schema does not cover" not in result.output + + +def test_set_silent_when_key_already_in_schema(runner, cli_env): + """Modifying an existing key keeps the schema covered.""" + runner.invoke(main, ["schema", "infer"]) + result = runner.invoke(main, ["set", "test_key", "new_string_value", "--force", "--no-backup"]) + assert result.exit_code == 0, result.output + assert "Schema does not cover" not in result.output + + +def test_set_extend_schema_flag_writes_baseline(runner, cli_env): + """--extend-schema auto-extends the baseline without prompting.""" + runner.invoke(main, ["schema", "infer"]) + result = runner.invoke( + main, + ["set", "fresh_key", "fresh_value", "--force", "--no-backup", "--extend-schema"], + ) + assert result.exit_code == 0, result.output + assert "Updated" in result.output + + # The new key should now be part of the schema, so a sync reports no drift. + sync = runner.invoke(main, ["schema", "sync"]) + assert sync.exit_code == 0, sync.output + assert "No drift" in sync.output + + +def test_set_no_extend_schema_proceeds_with_drift(runner, cli_env): + """--no-extend-schema skips the prompt and accepts the resulting drift.""" + runner.invoke(main, ["schema", "infer"]) + result = runner.invoke( + main, + ["set", "another_fresh_key", "v", "--force", "--no-backup", "--no-extend-schema"], + ) + assert result.exit_code == 0, result.output + assert "Updated" not in result.output # baseline NOT updated + + # Drift should be reported by sync. + sync = runner.invoke(main, ["schema", "sync"]) + assert sync.exit_code == 1 + assert "another_fresh_key" in sync.output + + +def test_set_force_without_flag_defaults_to_no_extend(runner, cli_env): + """--force alone (no schema flag) leaves drift instead of prompting.""" + runner.invoke(main, ["schema", "infer"]) + result = runner.invoke(main, ["set", "force_only_key", "v", "--force", "--no-backup"]) + assert result.exit_code == 0, result.output + # Schema notice still printed (so the user can see it in CI logs) + assert "Schema does not cover" in result.output + # But baseline not updated + assert "Updated" not in result.output + + +def test_set_interactive_accept_extend(runner, cli_env): + """Interactive: user types 'y' to extend schema.""" + runner.invoke(main, ["schema", "infer"]) + result = runner.invoke( + main, + ["set", "interactive_key", "v", "--no-backup"], + input="y\n", # answer "yes" to "Extend .vaultctl/vault.cue with this change?" + ) + assert result.exit_code == 0, result.output + assert "Updated" in result.output + + +def test_set_interactive_decline_extend_decline_proceed_aborts(runner, cli_env): + """Interactive: decline both prompts → abort, vault unchanged.""" + runner.invoke(main, ["schema", "infer"]) + result = runner.invoke( + main, + ["set", "abort_key", "v", "--no-backup"], + input="n\nn\n", # decline extend, decline proceed + ) + assert result.exit_code != 0 + # Vault should not contain the key + after = runner.invoke(main, ["get", "abort_key"]) + assert after.exit_code == 1 # not found