Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions src/vaultctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,14 @@ def _output_base64_encoded(value: Any) -> None:
@click.option("--backup/--no-backup", default=True, help="Save previous value as <key>_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,
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading