Skip to content

Latest commit

 

History

History
590 lines (375 loc) · 24.7 KB

File metadata and controls

590 lines (375 loc) · 24.7 KB

CHANGELOG

v0.12.0 (2026-03-14)

Features

  • cli: Add --raw and --base64 flags for clean SSH key import/export (#33, 10a7ed8)

Summary

SSH private keys and certificates stored in Ansible Vault suffer from whitespace corruption during YAML multiline formatting. This PR adds clean import/export modes to prevent these issues.

  • get --raw: Outputs value without Type: headers or field labels, strips trailing whitespace per line, ensures single trailing newline. Ideal for vaultctl get key --field privateKey --raw > key.pem - get --base64: Outputs value as a single base64-encoded line, suitable for environments that cannot handle multiline values - set --base64: Accepts an inline base64-encoded value, decodes before storing - set --base64-file: Reads base64 from a file or stdin (-), decodes before storing - set --file: Now applies whitespace cleanup (trailing space removal) on import - clean_multiline_value(): New helper that strips trailing whitespace per line and ensures exactly one trailing newline

Problem

When SSH keys are stored in YAML via ansible-vault, the multiline formatting introduces trailing spaces on lines. Extracting these keys with vaultctl get ... --json | jq -r produces keys that SSH rejects. There was no way to get a clean, whitespace-safe export or to import base64-encoded values.

Changed Files

| File | Change | |------|--------| | src/vaultctl/cli.py | Added --raw and --base64 flags to get, --base64 and --base64-file options to set, mutual exclusivity validation, _output_raw() and _output_base64_encoded() helpers | | src/vaultctl/yaml_util.py | Added clean_multiline_value() helper | | tests/test_cli.py | 17 new integration tests covering all new flags and edge cases | | tests/test_yaml_util.py | 7 unit tests for clean_multiline_value() | | tests/conftest.py | Added ssh_key fixture entry with trailing whitespace for testing |

Design Decisions

  1. clean_multiline_value in yaml_util.py — It is a value formatting utility closely related to YAML handling, keeping it here avoids a new module for one function 2. Mutual exclusivity of --json, --raw, --base64 — Validated at runtime with a clear error message rather than Click's cls=MutuallyExclusiveOption to keep it simple 3. --file now cleans whitespace on import — Prevents storing corrupted values at the source. This is a minor behavioral change but strictly an improvement 4. --base64-file - for stdin — Follows Unix convention, enables piping: cat key.pem | base64 | vaultctl set key --base64-file -

Test Plan

  • get --raw on plain strings outputs clean value - [x] get --raw on multiline values strips trailing whitespace - [x] get --raw --field extracts single field without headers - [x] get --raw on structured entries outputs YAML without Type: header - [x] get --base64 produces valid single-line base64 - [x] get --base64 on multiline values cleans before encoding - [x] get --base64 --field works on individual fields - [x] --json, --raw, --base64 are mutually exclusive - [x] set --base64 decodes and stores correctly - [x] set --base64 rejects invalid input - [x] set --base64-file reads from file - [x] set --base64-file - reads from stdin - [x] set --file cleans trailing whitespace - [x] Multiple input sources rejected - [x] clean_multiline_value unit tests (7 cases) - [x] All 319 tests pass, coverage 88%

Co-authored-by: Fred Thiele 8555720+f3rdy@users.noreply.github.com

v0.11.1 (2026-03-14)

Bug Fixes

  • cli: Format nested values as YAML and add --json flag to get (#32, 899ab2d)

Problem

vaultctl get on structured entries (dicts, lists) outputs Python repr() format — single quotes, no indentation, not parseable by jq or other tools. Example:


domains: [{'name': 'docker build hosts', 'credentials': [{'type': 'x509ClientCert', ...

This makes credentialStore entries with 50+ nested credentials completely unreadable.

Solution

1. Human-readable output: YAML formatting

Added _format_value() helper (cli.py:35-46) that formats nested values: - Strings: returned as-is (no change to existing behavior) - Dicts/Lists: formatted as YAML via yaml.dump(default_flow_style=False) - Other types: converted via str()

The get command now calls _format_value(value[f]) instead of directly printing value[f], so nested structures render as readable YAML with proper indentation.

2. Machine-readable output: --json flag

New --json flag on the get command outputs the value as JSON:

bash vaultctl get vault_jenkins_credentials --json | jq '.global.credentials | length'

Works with --field too:

bash vaultctl get db_creds --field username --json

Uses json.dumps(indent=2, ensure_ascii=False) for readable JSON that pipes cleanly to jq.

Files changed

  • src/vaultctl/cli.py: - Added _format_value() helper (lines 35-46) - Added --json / output_json option to get command - Changed dict field output from value[f] to _format_value(value[f]) - JSON output path for both full value and --field access

Test plan - [ ] vaultctl get <dict-key> shows readable YAML (not Python repr) - [ ] `vaultctl

get --json | jq .parses correctly - [ ]vaultctl get unchanged (plain string output) - [ ]vaultctl get --field username` unchanged - [ ] All 298 existing tests pass

Co-authored-by: Fred Thiele 8555720+f3rdy@users.noreply.github.com

v0.11.0 (2026-03-14)

Features

  • cli: Add shell completion for bash, zsh, and fish (#31, 7d860d8)

Summary

  • New vaultctl completion <shell> command (bash, zsh, fish) - Uses Click's shell_completion API
    • Works without .vaultctl.yml config

Install

  completion fish > ~/.config/fish/completions/vaultctl.fish # fish ```

## Test plan - [ ] `vaultctl completion bash` outputs valid bash completion - [ ] `vaultctl
  completion zsh` outputs valid zsh completion - [ ] `vaultctl completion fish` outputs valid fish
  completion - [ ] Tab completion works after eval

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.10.0 (2026-03-14)

### Features

- **search**: Add --context flag to show parent object of matches
  ([#30](https://github.com/cdds-ab/vaultctl/pull/30),
  [`47a015e`](https://github.com/cdds-ab/vaultctl/commit/47a015ee7924c640581cad6356ccb417d1f9fc58))

## Summary

- Add `--context / -c` flag to `vaultctl search` that shows the parent dict of each matched field -
  Sibling fields are redacted by default (`****`), matched field shows first 4 chars + `...` -
  Combine with `--show-match` to display all field values in cleartext - Multiple matches in the
  same parent object are grouped into a single block

Closes #20

## Test plan

- [x] Unit tests for `search_values(include_context=True)` covering nested dicts, lists, top-level
  strings, multiple matches - [x] CLI integration tests for `--context`, `--context --show-match`,
  and top-level string fallback - [x] All 298 tests pass, 88% coverage - [x] mypy strict, ruff,
  bandit clean

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.9.0 (2026-03-14)

### Features

- **cli**: Add search command and list --filter ([#29](https://github.com/cdds-ab/vaultctl/pull/29),
  [`007f505`](https://github.com/cdds-ab/vaultctl/commit/007f50557b165d55d0e21ca59f5d0ee8b6777065))

## Summary

- **`vaultctl list --filter/-f PATTERN`**: Regex filter on key names, descriptions, and consumers
  from vault-keys.yml metadata. No additional decryption beyond what `list` already does. -
  **`vaultctl search PATTERN`**: New subcommand that decrypts the vault and recursively searches all
  values (strings in nested dicts/lists). Output shows only key names and dot-path locations — never
  values unless `--show-match` is explicitly used. - `--keys-only / -k`: Search only key names and
  metadata (no vault decryption needed) - `--show-match`: Display matched values (with security
  warning) - Exit code 0 if matches found, 1 if not (scripting-friendly)

### Security considerations - Search pattern is never logged or included in error output - Values
  are never shown without explicit `--show-match` flag - `--show-match` displays a yellow WARNING to
  stderr - Recursive search is depth-limited (max 20 levels) - All search logic is in a
  pure-function module (`search.py`) — no side effects

### Architecture - New module `src/vaultctl/search.py` with `search_values()` and `filter_keys()` —
  pure functions, fully unit-testable - CLI wiring in `cli.py` follows existing command patterns -
  100% test coverage on search.py, 87% overall

Closes #TBD

## Test plan

- [x] Unit tests for `search_values()` — flat values, nested dicts, nested lists, depth limit,
  include_values toggle - [x] Unit tests for `filter_keys()` — key name, description, consumer
  matching, regex, case insensitivity - [x] Integration tests for `vaultctl list --filter` — name
  match, description match, regex, no match, invalid regex - [x] Integration tests for `vaultctl
  search` — value found, not found, nested, show-match, keys-only, invalid regex - [x] All 272 tests
  pass, 87.45% coverage - [x] ruff, mypy --strict, bandit all clean

---------

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.8.2 (2026-03-14)

### Bug Fixes

- **detect**: Support lists as top-level vault values in recursive detection
  ([#28](https://github.com/cdds-ab/vaultctl/pull/28),
  [`9bc77b4`](https://github.com/cdds-ab/vaultctl/commit/9bc77b435d6d6e16b223c88079bf68bb98689fd7))

## Summary

- `_collect_nested_credential_types()` now handles list values directly (not only lists inside
  dicts) - `detect_type_heuristic()` checks `isinstance(value, (dict, list))` for credential store
  detection - Fixes detection for vault entries that are credential lists at the top level - 5 new
  tests for list-based credential structures

## Test plan - [ ] `uv run pytest` — 237 tests green - [ ] `vaultctl detect-types` on vaults with
  list-based credential entries

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>

### Documentation

- Add troubleshooting section to README ([#27](https://github.com/cdds-ab/vaultctl/pull/27),
  [`1384c43`](https://github.com/cdds-ab/vaultctl/commit/1384c43c23680e5b1baf725de7e70e9e64956904))

## Summary

Adds troubleshooting section covering the most common issues: - Decryption failures from
  missing/misconfigured password source - Config file not found - `init` overwriting password config
  on re-run - `self-update` on pip/uv installs

## Test plan - [ ] README renders correctly on GitHub

---------

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.8.1 (2026-03-14)

### Bug Fixes

- **security**: Add recursion limits, redaction runtime guard, and security docs
  ([#26](https://github.com/cdds-ab/vaultctl/pull/26),
  [`9bcf6e2`](https://github.com/cdds-ab/vaultctl/commit/9bcf6e2f6bdb3cf270a8a47d3986be0cf9908abc))

## Summary

- **F-04**: Recursion depth limit (max 50) in `_collect_nested_credential_types()` and
  `redact_value()` - **A-01**: Runtime redaction guard in `build_payload()` using
  `contains_unredacted()` — aborts AI detection if redaction fails - **F-05**: Trust-boundary
  comments on `shell=True` in `password.py` and `ai_detect.py` - **docs/SECURITY.md**: Comprehensive
  security architecture documentation covering data flow, triple-layer AI protection, trust
  boundaries, and verification steps - 7 new tests for recursion limits and redaction guard

Based on findings from cybersecurity audit of #24.

## Test plan - [ ] `uv run pytest` — all 233+ tests green - [ ] Review `docs/SECURITY.md` for
  completeness - [ ] `vaultctl detect-types --show-payload` still works correctly

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.8.0 (2026-03-14)

### Features

- **detect**: Recursive type detection for nested credential structures
  ([#25](https://github.com/cdds-ab/vaultctl/pull/25),
  [`aafbfed`](https://github.com/cdds-ab/vaultctl/commit/aafbfedc365744727631018e2654e649f679af5f))

## Summary - Adds recursive scanning of nested dict/list structures for credential type fields -
  Detects Jenkins JCasC-style credential stores - New credentialStore type with sub-type summary -
  10 new tests

Closes #24

## Test plan - [ ] uv run pytest — all tests green - [ ] vaultctl detect-types on real Jenkins JCasC
  vault shows nested types - [ ] Existing detection behavior unchanged

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.7.2 (2026-03-13)

### Bug Fixes

- **self-update**: Improve module docstring for clarity
  ([`7832596`](https://github.com/cdds-ab/vaultctl/commit/7832596de1a4139d60021b28d450f6b7902ca226))


## v0.7.1 (2026-03-13)

### Bug Fixes

- **ci**: Drop macos-amd64 binary build (unreliable macos-13 runner)
  ([`59bf347`](https://github.com/cdds-ab/vaultctl/commit/59bf347d9897868c3194bad6ba3ea384bd077919))


## v0.7.0 (2026-03-13)

### Features

- **cli**: Standalone binary with self-update and checksum verification
  ([#23](https://github.com/cdds-ab/vaultctl/pull/23),
  [`e49710c`](https://github.com/cdds-ab/vaultctl/commit/e49710cd2d270530694ad1dab79b8f6a0d3b8fd1))

## Summary

- Add `vaultctl self-update` command that downloads the latest release from GitHub - SHA256 checksum
  verification before replacing the binary (checksums.sha256 asset) - Release workflow builds
  standalone PyInstaller binaries for linux-amd64, macos-amd64, macos-arm64 - Graceful fallback when
  no checksums available (older releases) - Temp file cleanup on checksum mismatch or download
  failure

Closes #23

## Test plan

- [x] 16 unit tests for self-update module (platform detection, checksum verification, update flow)
  - [x] Full test suite passes (190 tests, 85% coverage) - [x] Bandit security scan clean - [ ]
  Verify PyInstaller binary build in CI - [ ] Verify checksums.sha256 uploaded to release

---------

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.6.0 (2026-03-13)

### Features

- **ai**: Add AI-assisted type detection with GDPR consent
  ([#22](https://github.com/cdds-ab/vaultctl/pull/22),
  [`da4a4af`](https://github.com/cdds-ab/vaultctl/commit/da4a4af685a9f791e682ee024b1b6a53d37c37ee))

## Summary Phase 2 of #19: AI-assisted vault entry type detection with security and GDPR compliance.

- **`ai_detect.py`**: AI client module with: - Mandatory redaction (all data passes through
  `redact_vault_data()`) - Exception firewall (no secrets in error messages) - TLS enforcement
  (HTTPS required for remote, HTTP only for localhost/Ollama) - Untrusted response parsing (JSON
  string literals only, no eval) - Phase 1 / AI result merging (local heuristics take priority) -
  **`config.py`**: `AIConfig` dataclass with endpoint, model, api_key_cmd, consent state - **CLI
  flags**: `--ai`, `--show-payload`, `--yes` (skip consent for CI) - **GDPR consent flow**:
  Interactive disclosure of what data is sent, to where, with opt-in - **Graceful fallback**: AI
  failure falls back to Phase 1 local heuristics

### Security measures (from security review): - API key resolved via command (never stored in
  config, never logged) - No vault secrets in any error message or exception - Payload hash for
  audit trail - Data minimization: only key names, field names, Phase 1 hints sent

Closes #19

## Test plan - [x] 23 unit tests for ai_detect.py (payload building, endpoint validation, API key,
  response parsing, result merging) - [x] 3 CLI integration tests (show-payload, ai-no-config
  fallback, consent prompt) - [x] No secrets in `--show-payload` output verified - [x] All 174 tests
  pass - [x] mypy strict + ruff clean

---------

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.5.0 (2026-03-12)

### Features

- **cli**: Add detect-types command ([#21](https://github.com/cdds-ab/vaultctl/pull/21),
  [`a873a28`](https://github.com/cdds-ab/vaultctl/commit/a873a283a07679e11615768977bb8996169f9f8e))

## Summary - New CLI command `vaultctl detect-types` with heuristic type detection - `--apply`
  writes detected types to vault entries and keys metadata - `--show-redacted` displays safe
  redacted vault structure for auditing - `--json` and `--confidence` for machine-readable and
  filtered output - Test fixture extended with `untyped_creds` entry for detection testing

Part 2 of #19

## Test plan - [x] 5 new integration tests (dry-run, JSON, confidence filter, show-redacted, apply)
  - [x] `--show-redacted` verified: no secrets in output - [x] `--apply` verified: detected type
  persisted and visible in `get` - [x] All 148 tests pass - [x] mypy strict + ruff clean

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.4.0 (2026-03-12)

### Features

- **detect**: Add heuristic type detection and vault data redaction
  ([#20](https://github.com/cdds-ab/vaultctl/pull/20),
  [`a160ed9`](https://github.com/cdds-ab/vaultctl/commit/a160ed958ff957bc87bd6f64cd101bc60cd8a754))

## Summary - **`redact.py`**: Deterministic redaction — replaces all secret values with
  `***REDACTED***`, preserves key names, dict structure, and `type` field values. Includes
  `contains_unredacted()` verification helper for auditing. - **`detect.py`**: Heuristic type
  detection engine with three priority levels: 1. Dict field structure (e.g. `username`+`password`
  `usernamePassword`) — high confidence 2. Value patterns (PEM headers, ssh-* prefixes) — high
  confidence 3. Key name patterns (e.g. `*_password`, `*_cert`) — medium confidence - Skips
  `_previous` backup keys and entries with explicit `type` field - **74 new tests** covering
  completeness, edge cases, priority ordering

Part 1 of #19 (core modules, no CLI integration yet)

## Test plan - [x] 36 redaction tests (value types, nesting, parametrized completeness) - [x] 38
  detection tests (field patterns, value patterns, key names, priorities) - [x] `mypy --strict`
  clean - [x] `ruff check` clean

---------

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.3.0 (2026-03-12)

### Features

- **cli**: Display structured vault entry types ([#18](https://github.com/cdds-ab/vaultctl/pull/18),
  [`059b9d9`](https://github.com/cdds-ab/vaultctl/commit/059b9d99d34c706acf6ab132d40d768600e111e8))

## Summary - `get`: Shows type + fields for structured entries (dicts), add `--field` flag for
  direct field access - `list`: Shows `[usernamePassword]` type tag for non-secretText entries -
  `describe`: Shows `Type:` line when `entry_type` is set in metadata - Test fixtures extended with
  structured `db_creds` entry - 7 new integration tests

Closes #14

## Test plan - [x] `test_get_structured_entry` — dict entry shows type + fields - [x]
  `test_get_structured_field``--field username` returns single value - [x]
  `test_get_structured_field_missing` — missing field exits 1 - [x] `test_get_field_on_plain_string`
`--field` on string exits 1 - [x] `test_list_shows_type_tag``[usernamePassword]` shown,
  `[secretText]` hidden - [x] `test_describe_structured_entry` — Type line in describe output - [x]
  All 51 tests pass, mypy strict clean, ruff clean

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>


## v0.2.0 (2026-03-11)

### Documentation

- Add PR-based workflow documentation ([#13](https://github.com/cdds-ab/vaultctl/pull/13),
  [`3fdb1b1`](https://github.com/cdds-ab/vaultctl/commit/3fdb1b15e29e871af180826062efaab4d4bdb0c7))

## Summary - Add PR-based workflow documentation to CLAUDE.md - Document branch naming conventions
  (feature/, fix/) - Document PR requirements (Closes #N, CI green, squash merge) - Document branch
  protection rules

## Test plan - [x] CLAUDE.md updated with workflow section - [x] Branch protection configured on
  GitHub - [x] Squash merge as only merge strategy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

### Features

- **keys**: Add entry_type field to KeyInfo dataclass
  ([#16](https://github.com/cdds-ab/vaultctl/pull/16),
  [`53263f0`](https://github.com/cdds-ab/vaultctl/commit/53263f0a35b4b992f02df740fd70bd699fb6e283))

## Summary - Add `entry_type` field to `KeyInfo` dataclass, populated from `type` metadata in
  vault-keys.yml - Enables tracking structured entry types (e.g. `usernamePassword`, `sshKey`) in
  key metadata - Step 2 of #14 (structured vault data types)

## Test plan - [x] 3 new tests: type present, type default (empty), missing key - [x] All 15
  `test_keys.py` tests pass - [x] `mypy --strict` clean - [x] `ruff check` clean

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>

- **types**: Add vault entry type detection module
  ([#15](https://github.com/cdds-ab/vaultctl/pull/15),
  [`4203c18`](https://github.com/cdds-ab/vaultctl/commit/4203c185bb4212055fa343da7bdde56b95b07716))

## Summary - Add `src/vaultctl/types.py` with utilities for detecting and accessing structured vault
  entry types (e.g. `usernamePassword`, `sshKey`) - Add `tests/test_types.py` with 13 tests covering
  all type detection and field access functions - Step 1 of #14 (structured vault data types)

Closes #14

## Test plan - [x] `uv run pytest tests/test_types.py` — 13 tests pass - [x] `uv run mypy --strict
  src/vaultctl/types.py` — clean - [x] `uv run ruff check src/vaultctl/types.py` — clean

Co-authored-by: Fred Thiele <8555720+f3rdy@users.noreply.github.com>

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>


## v0.1.2 (2026-03-08)

### Documentation

- **password**: Document env var empty-string fallthrough semantics
  ([`6e56fd9`](https://github.com/cdds-ab/vaultctl/commit/6e56fd9473485bcb5ecce01de3cad62dbc55bb22))

Add code comment, README section, and explicit test for the behavior where VAULT_PASS="" is treated
  as unset and falls through to next source.

Closes #12

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

### Refactoring

- **cli**: Switch all user-facing messages from German to English
  ([`912212e`](https://github.com/cdds-ab/vaultctl/commit/912212e28591f59879805926c6228ffaeb36efa8))

Translate ~30 German CLI messages to English and update all test assertions accordingly. No
  functional changes.

Closes #6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

- **keys**: Introduce Literal type for ExpiryWarning.status
  ([`bb4d9b9`](https://github.com/cdds-ab/vaultctl/commit/bb4d9b95cddef94a74cef1cc23a5fb3a50385f85))

Replace bare `str` with `ExpiryStatus = Literal["expired", "warning", "ok"]` to catch typos at
  type-check time.

Closes #5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>


## v0.1.1 (2026-03-08)

### Bug Fixes

- **cli**: Improve error message when no config found
  ([`6f62776`](https://github.com/cdds-ab/vaultctl/commit/6f627765de3d01ee874a7b09f61a693c26d460b5))

Closes #1

- **types**: Enforce mypy strict compliance across all modules
  ([`450846b`](https://github.com/cdds-ab/vaultctl/commit/450846b105ccfc7e221762e8bebf01e23bd22680))

Add missing type annotations (dict[str, Any], -> None, etc.) to all public and private functions.
  Remove --ignore-missing-imports from pre-commit mypy args since pyproject.toml overrides handle
  it.

Closes #3 Closes #10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

- **vault**: Use secure tempfile permissions (0600) for sensitive data
  ([`efe4ead`](https://github.com/cdds-ab/vaultctl/commit/efe4eade0c7fad77b843d16f8fbd77390a1042d1))

Temporary files containing decrypted vault data and passwords are now created with mkstemp +
  explicit fchmod(0600) via a _secure_tempfile context manager, preventing exposure on shared
  systems.

Closes #9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>


## v0.1.0 (2026-03-08)

### Features

- Initial vaultctl CLI implementation
  ([`124ba4f`](https://github.com/cdds-ab/vaultctl/commit/124ba4f99df04d0020ade93dfff66a80cd037434))

Generalized Ansible Vault management CLI with: - Commands: init, list, get, set, delete, describe,
  restore, edit, check - YAML config (.vaultctl.yml) with upward search - Password resolution chain
  (env, file, cmd) - Key metadata with expiry tracking (vault-keys.yml) - CI/CD pipeline (GitHub
  Actions), semantic-release, pre-commit hooks - 46 tests, 80% coverage