tsafe_cli/cli.rs
1use clap::{Parser, Subcommand, ValueEnum};
2pub use clap_complete::Shell;
3
4const ROOT_LONG_ABOUT: &str = "Manage secrets in a local encrypted vault instead of scattering them across `.env` files, shell history, and ad-hoc runtime exports.\n\nThe core-only release family centers on local encrypted vault CRUD, `exec`/contracts, profiles, snapshots, audit, and `doctor`, plus the default-core Azure Key Vault pull, biometric/quick-unlock, and team workflows when they are compiled into this `tsafe` binary. Some named stack shapes also include the terminal UI and/or the `agent` workflow as explicit companion/runtime claims. Broader gated non-core lanes such as AWS, GCP, browser/nativehost, plugins, and other additive surfaces appear only when they are compiled into this binary and shipped by the chosen stack. Companion runtimes such as `tsafe-agent` are installed and released separately. Use `tsafe build-info` when you need the compiled truth for the running binary.";
5
6const ROOT_AFTER_HELP: &str = "Privacy and local evidence:\n tsafe does not phone home. It does not send product analytics,\n crash reports, update pings, secret names, or secret values to a\n vendor service.\n\n Audit logs are local per-profile JSONL receipts. CloudEvents and Splunk\n formats are explicit export shapes for those local receipts; they are\n not hidden telemetry.\n\n Network access occurs only through explicit networked features you invoke\n or configure, such as provider pull/push, HIBP checks, one-time sharing,\n or an operator-selected audit export pipeline.\n\nCompiled truth:\n tsafe build-info\n\nCompanion note:\n `tsafe-agent` is installed and released separately from the `tsafe` CLI binary.\n\nSee also:\n man tsafe\n tsafe explain\n tsafe <command> --help\n docs/index.md in the repository";
7
8const DOCTOR_LONG_ABOUT: &str = "Diagnose vault health: file presence, snapshots, env vars, secret expiry, and operator-facing health hints.\n\nPrints a colour-coded report. Use `--json` for machine-readable monitoring output.";
9
10const DOCTOR_AFTER_HELP: &str =
11 "Examples:\n tsafe doctor\n tsafe doctor --json\n tsafe --profile prod doctor";
12
13const AUDIT_AFTER_HELP: &str = "Examples:\n tsafe audit\n tsafe audit --limit 100\n tsafe audit --explain\n tsafe audit --explain --json\n tsafe audit --cell-id doom-cell-001\n tsafe audit-verify\n tsafe audit-verify --json";
14
15const ROTATE_DUE_AFTER_HELP: &str =
16 "Examples:\n tsafe rotate-due\n tsafe rotate-due --json\n tsafe rotate-due --fail # for scripts: non-zero if overdue";
17
18const BUILD_INFO_AFTER_HELP: &str = "Examples:\n tsafe build-info\n tsafe build-info --json";
19
20#[derive(Parser)]
21#[command(
22 name = "tsafe",
23 about = "tsafe — local encrypted secret runtime for vaults, exec/contracts, and operator workflows",
24 long_about = ROOT_LONG_ABOUT,
25 version,
26 arg_required_else_help = true,
27 after_help = ROOT_AFTER_HELP
28)]
29pub struct Cli {
30 /// Named vault / profile. Defaults to the persisted default (or 'default'). Override with TSAFE_PROFILE env var.
31 #[arg(short, long, global = true, env = "TSAFE_PROFILE")]
32 pub profile: Option<String>,
33
34 #[command(subcommand)]
35 pub command: Commands,
36}
37
38#[derive(Subcommand)]
39pub enum Commands {
40 /// Initialise a new encrypted vault for the current profile.
41 ///
42 /// Creates the vault file for this profile under the platform data directory. Prompts for a master password twice.
43 ///
44 /// On an interactive terminal, after the vault is created you may be offered "quick unlock":
45 /// storing the password in the OS credential store (Touch ID / Face ID / Windows Hello / device PIN
46 /// where the OS supports it). You can accept, defer, or skip; run `tsafe biometric enable` anytime.
47 ///
48 /// If `tsafe config set-backup-vault main` (or `default`) is set, the new vault's master password is
49 /// also stored under `profile-passwords/<profile>` in that vault when possible.
50 ///
51 #[command(after_help = "Examples:\n tsafe init\n tsafe --profile prod init")]
52 Init,
53
54 /// View or change global settings (config.json): password backup target, default profile, etc.
55 ///
56 /// Use `config set-backup-vault main` so every new vault's master password is also stored under
57 /// `profile-passwords/<profile>` in the `main` vault (requires that vault to exist and be unlockable when you create more profiles).
58 #[command(
59 after_help = "Examples:\n tsafe config show\n tsafe config set-backup-vault main\n tsafe config set-backup-vault default\n tsafe config set-backup-vault off\n tsafe config set-exec-mode hardened\n tsafe config set-exec-redact-output on\n tsafe config add-exec-extra-strip OPENAI_API_KEY"
60 )]
61 Config {
62 #[command(subcommand)]
63 action: ConfigAction,
64 },
65
66 /// Store or update a secret in the vault.
67 ///
68 /// If VALUE is omitted on a TTY, you are prompted with masked input (typically `*` per character).
69 /// Piped / non-interactive stdin reads a single line.
70 /// Keys may be namespaced with `.` or `-` (e.g. `github.com.token`, `db-prod.PASSWORD`).
71 ///
72 /// If the key already exists the command will prompt for confirmation (on a TTY)
73 /// or exit with an error (non-TTY). Pass --overwrite to skip the check.
74 ///
75 #[command(
76 after_help = "Examples:\n tsafe set DB_PASSWORD supersecret\n tsafe set github.com.token ghp_xxx --tag env=prod\n tsafe set API_KEY --overwrite # replace existing without prompt"
77 )]
78 Set {
79 /// Secret key (e.g. DB_PASSWORD, github.com.token).
80 key: String,
81 /// Secret value. Omit for a masked TTY prompt or a line from stdin when piped.
82 value: Option<String>,
83 /// Attach tags as KEY=VALUE pairs (repeatable).
84 #[arg(short, long = "tag", value_name = "KEY=VALUE")]
85 tags: Vec<String>,
86 /// Overwrite the key if it already exists — skips the confirmation prompt.
87 #[arg(long)]
88 overwrite: bool,
89 },
90
91 /// Retrieve a secret and print its plaintext value.
92 ///
93 /// Use --copy to copy to clipboard instead of printing; the clipboard is cleared after 30 s.
94 /// Use --version to retrieve a previous version (0=current, 1=previous, etc.).
95 ///
96 #[command(
97 after_help = "Examples:\n tsafe get DB_PASSWORD\n tsafe get API_KEY --copy\n tsafe get DB_PASSWORD --version 1"
98 )]
99 Get {
100 /// Secret key.
101 key: String,
102 /// Copy value to clipboard and clear after 30 seconds (does not print).
103 #[arg(short, long)]
104 copy: bool,
105 /// Retrieve a previous version (0=current, 1=previous, etc.).
106 #[arg(long)]
107 version: Option<usize>,
108 },
109
110 /// Permanently remove a secret from the vault.
111 ///
112 /// The deletion is recorded in the audit log and a snapshot is taken before removal.
113 ///
114 #[command(after_help = "Examples:\n tsafe delete OLD_TOKEN")]
115 Delete {
116 /// Secret key.
117 key: String,
118 },
119
120 /// List all secret key names stored in the vault.
121 ///
122 /// Use --tag to filter by attached metadata.
123 /// Use --ns to filter to a specific namespace (e.g. "cds-adf").
124 ///
125 #[command(
126 after_help = "Examples:\n tsafe list\n tsafe list --tag env=prod\n tsafe list --ns cds-adf"
127 )]
128 List {
129 /// Filter to secrets with this tag (KEY=VALUE). Repeatable.
130 #[arg(short, long = "tag", value_name = "KEY=VALUE")]
131 tags: Vec<String>,
132 /// Filter to keys in this namespace (stored as `<ns>/<KEY>`).
133 #[arg(long)]
134 ns: Option<String>,
135 },
136
137 /// Print secrets to stdout in the chosen format.
138 ///
139 /// Formats: env (default), dotenv, powershell, json, github-actions, yaml, docker-env.
140 /// Use --ns to export only keys from a namespace; the prefix is stripped
141 /// so the output contains plain KEY=VALUE (e.g. APP_PW not cds-adf/APP_PW).
142 ///
143 #[command(
144 after_help = "Examples:\n tsafe export\n tsafe export --format powershell > secrets.ps1\n tsafe export --format github-actions --tag env=ci\n tsafe export --ns cds-adf --format dotenv > .env\n tsafe export --format yaml > secrets.yaml\n tsafe export --format docker-env > .env"
145 )]
146 Export {
147 /// Output format.
148 #[arg(short, long, default_value = "env")]
149 format: ExportFormat,
150 /// Limit to specific keys (all keys if omitted).
151 keys: Vec<String>,
152 /// Filter to secrets with this tag (KEY=VALUE). Repeatable.
153 #[arg(short, long = "tag", value_name = "KEY=VALUE")]
154 tags: Vec<String>,
155 /// Filter to keys in this namespace; prefix is stripped in output.
156 #[arg(long)]
157 ns: Option<String>,
158 },
159
160 /// Execute a command with secrets injected into its environment.
161 ///
162 /// Secrets are injected as env vars; the child inherits all other env vars.
163 /// Ctrl-C is forwarded to the child and tsafe exits with the child's exit code.
164 /// Use --ns to inject only secrets from a namespace (prefix stripped from var names).
165 ///
166 /// Use --contract to load a named authority contract from the nearest .tsafe.yml manifest.
167 /// A contract declares profile, namespace, allowed secrets, required secrets, allowed targets,
168 /// and trust posture as a reusable, auditable policy. Explicit flags still override contract values.
169 #[command(
170 name = "exec",
171 after_help = "Examples:\n tsafe exec -- dotnet run\n tsafe exec -- docker-compose up\n tsafe exec --ns cds-adf -- python pipeline.py\n tsafe exec --dry-run\n tsafe exec --plan -- npm start\n tsafe exec --require API_KEY,DB_URL -- npm test\n tsafe exec --no-inherit -- node index.js\n tsafe exec --only PATH,HOME -- python script.py\n tsafe exec --minimal -- pytest\n tsafe exec --mode hardened -- npm test\n tsafe exec --keys OPENAI_API_KEY,DB_URL -- npm test\n tsafe exec --env MY_API_KEY=VAULT_API_KEY -- npm test\n tsafe exec --preset minimal -- npm test\n tsafe exec --timeout 30 -- npm test\n tsafe exec --redact-output -- npm test\n tsafe exec --contract deploy -- terraform apply\n tsafe exec --contract ci-tests --dry-run"
172 )]
173 Exec {
174 /// Load a named authority contract from the nearest .tsafe.yml (or .tsafe.json) manifest.
175 /// The contract sets the profile, namespace, allowed/required secrets, allowed targets, and
176 /// trust posture. Explicit flags (--ns, --keys, --mode, etc.) still override contract values.
177 #[arg(long, value_name = "NAME")]
178 contract: Option<String>,
179 /// Inject only secrets from this namespace; prefix is stripped from env var names.
180 #[arg(long)]
181 ns: Option<String>,
182 /// Inject only these vault keys (after `--ns` prefix stripping). Comma-separated or repeat flag.
183 /// Missing selected keys abort the run so narrower injection does not silently degrade.
184 #[arg(long, value_name = "KEY", value_delimiter = ',', action = clap::ArgAction::Append)]
185 keys: Vec<String>,
186 /// Trust preset for this run. `standard` keeps broad compatibility, `hardened` applies a stricter preset,
187 /// and `custom` uses your persisted exec trust settings. Explicit flags still override the preset.
188 #[arg(long)]
189 mode: Option<ExecModeSetting>,
190 /// Kill the child process after this many seconds and exit non-zero. Default: no timeout.
191 #[arg(long, value_name = "SECONDS")]
192 timeout: Option<u64>,
193 /// Preset for inherited parent environment. `minimal` keeps only PATH and a safe core set
194 /// (equivalent to --minimal). `full` inherits the full parent environment minus the strip list
195 /// (equivalent to the default). Explicit --no-inherit, --minimal, and --only override this.
196 #[arg(long, value_name = "PRESET")]
197 preset: Option<ExecPresetSetting>,
198 /// List env var names that would be injected (sorted, one per line) and exit 0; no command is run.
199 #[arg(long)]
200 dry_run: bool,
201 /// Show a human-readable plan: profile, namespace, injected names, --require checks,
202 /// parent env strips, and a copy-paste run line. Exit 0; no command is run.
203 #[arg(long)]
204 plan: bool,
205 /// Start from a clean environment: no parent env vars are inherited. Only vault secrets
206 /// (and any --only keys) are visible to the child. Mutually exclusive with --only and --minimal.
207 #[arg(long, conflicts_with_all = ["only", "minimal"])]
208 no_inherit: bool,
209 /// Inherit only a safe minimal set of parent env vars (PATH, HOME, USER, TMPDIR, LANG,
210 /// TERM, SSH_AUTH_SOCK, etc.) plus vault secrets. No tokens or credentials leak through.
211 /// Mutually exclusive with --no-inherit and --only.
212 #[arg(long, conflicts_with_all = ["no_inherit", "only"])]
213 minimal: bool,
214 /// Inherit only these parent env vars (comma-separated or repeat flag); all others are
215 /// stripped. Vault secrets are then added on top. Mutually exclusive with --no-inherit and --minimal.
216 #[arg(long, value_name = "KEY", value_delimiter = ',', action = clap::ArgAction::Append, conflicts_with_all = ["no_inherit", "minimal"])]
217 only: Vec<String>,
218 /// Require these vault keys (after --ns mapping) to be present. Comma-separated or repeat flag.
219 #[arg(long, value_name = "KEY", value_delimiter = ',', action = clap::ArgAction::Append)]
220 require: Vec<String>,
221 /// Map a vault key to a different env var name in the child process.
222 /// Format: ENV_VAR=VAULT_KEY (e.g. --env MY_DB=PROD_SECRET injects the vault value of
223 /// PROD_SECRET under the name MY_DB). When --keys is also given, only vault keys that are
224 /// in the --keys allowlist may be referenced; other vault keys are rejected with an error.
225 /// Repeat the flag for multiple mappings.
226 #[arg(long = "env", value_name = "ENV_VAR=VAULT_KEY", action = clap::ArgAction::Append)]
227 env_mappings: Vec<String>,
228 /// Abort if any injected name is a known high-risk env var (e.g. NODE_OPTIONS, LD_PRELOAD).
229 /// Redundant: this is now the default. Kept for backwards compatibility.
230 #[arg(long, conflicts_with = "allow_dangerous_env")]
231 deny_dangerous_env: bool,
232 /// Allow injection of known high-risk env var names (e.g. LD_PRELOAD, NODE_OPTIONS).
233 /// By default, dangerous names abort exec. Use this flag to inject them with a warning instead.
234 #[arg(long, conflicts_with = "deny_dangerous_env")]
235 allow_dangerous_env: bool,
236 /// Replace exact vault secret values in the child's stdout/stderr with `[REDACTED]`.
237 /// Useful for agent/tool wrappers where you trust the command less than the vault.
238 #[arg(long, conflicts_with = "no_redact_output")]
239 redact_output: bool,
240 /// Force raw child stdout/stderr even if config enables exec output redaction by default.
241 #[arg(long, conflicts_with = "redact_output")]
242 no_redact_output: bool,
243 /// Command and its arguments (omit when using --dry-run or --plan).
244 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
245 cmd: Vec<String>,
246 },
247
248 /// Import secrets from a `.env` file or another supported export source.
249 ///
250 /// `.env` paths work in every build. Some builds may also accept additional
251 /// source names for password-manager or browser CSV exports.
252 ///
253 /// When `--from` is a named export source, `--file` is required.
254 /// Skips keys that already exist unless --overwrite is passed.
255 ///
256 /// Use --ns to prefix all imported keys with a namespace, e.g. "cds-adf".
257 /// Keys are stored as `<ns>/<KEY>` allowing multiple projects in one vault
258 /// without collision (e.g. cds-adf/APP_PW vs mail-automation/APP_PW).
259 ///
260 #[command(
261 after_help = "Examples:\n tsafe import --from .env\n tsafe import --from .env.production --overwrite\n tsafe import --from ../cds-adf/.env --ns cds-adf\n tsafe import --from .env --dry-run"
262 )]
263 /// If `--from` is a **relative** path that does not exist, the error includes extra hints and
264 /// searches **downward** from the current directory (bounded depth; skips `target/`, `node_modules/`, `.git/`, etc.)
265 /// for files with the **same name** (e.g. `.env`) so you can copy-paste a suggested `tsafe import --from '…'` line.
266 Import {
267 /// `.env` file path or another supported source name for this build.
268 #[arg(long, default_value = ".env")]
269 from: String,
270 /// Export file path (required when `--from` is a named export source).
271 #[arg(long)]
272 file: Option<String>,
273 /// Overwrite existing keys (skip by default).
274 #[arg(long)]
275 overwrite: bool,
276 /// Skip duplicate keys silently instead of erroring (applies to both
277 /// within-file duplicates and keys already in the vault).
278 #[arg(long)]
279 skip_duplicates: bool,
280 /// Namespace prefix to prepend to imported keys (e.g. "cds-adf").
281 /// Keys are stored as `<ns>/<KEY>`, preventing collisions across projects.
282 #[arg(long)]
283 ns: Option<String>,
284 /// Show what would be imported without writing any secrets to the vault.
285 /// Prints each key and whether it would be skipped (existing) or imported.
286 #[arg(long)]
287 dry_run: bool,
288 },
289
290 /// Map browser domains to vault profiles for the browser extension.
291 ///
292 /// The extension uses these mappings to choose which vault profile to
293 /// query when filling credentials on a given domain.
294 ///
295 #[command(
296 after_help = "Examples:\n tsafe browser-profile add github.com\n tsafe browser-profile add paypal.com --profile finance\n tsafe browser-profile list\n tsafe browser-profile remove paypal.com"
297 )]
298 #[cfg(feature = "browser")]
299 #[command(name = "browser-profile")]
300 BrowserProfile {
301 #[command(subcommand)]
302 action: BrowserProfileAction,
303 },
304
305 /// Register or unregister the native messaging host for the browser extension.
306 ///
307 /// Writes the per-user manifest/registration files needed by supported browsers on the
308 /// current OS. This command does not require browser-profile mappings or vault access.
309 ///
310 #[command(
311 after_help = "Examples:\n tsafe browser-native-host detect\n tsafe browser-native-host register --extension-id <chromium-id>\n tsafe browser-native-host unregister"
312 )]
313 #[cfg(feature = "nativehost")]
314 #[command(name = "browser-native-host")]
315 BrowserNativeHost {
316 #[command(subcommand)]
317 action: BrowserNativeHostAction,
318 },
319
320 /// Re-encrypt all secrets with a new master password (vault re-key).
321 ///
322 /// Prompts for the current password, then the new password twice (unless non-interactive).
323 /// For automation / CI, set `TSAFE_PASSWORD` (current) and `TSAFE_NEW_MASTER_PASSWORD` (new);
324 /// confirmation is skipped when both are set (no OS keychain prompt in that case — run `biometric enable` after).
325 /// After interactive rotation, you are offered an OS keychain update so quick unlock matches the new password.
326 /// A snapshot is taken automatically before rotation. `tsafe doctor` suggests periodic rotation.
327 ///
328 #[command(after_help = "Examples:\n tsafe rotate\n tsafe --profile prod rotate")]
329 Rotate,
330
331 /// Re-encrypt the vault with a new master password and update the biometric credential.
332 ///
333 /// Prompts for the current password (or reads from TSAFE_PASSWORD), then the new password
334 /// twice (or reads from TSAFE_NEW_MASTER_PASSWORD). The vault is written atomically via a
335 /// temp-file rename. If biometric quick-unlock is active, the stored credential is re-stored
336 /// under the new password so subsequent unlocks continue to work.
337 ///
338 /// If the vault re-encryption succeeds but the biometric re-store fails, a warning is emitted
339 /// directing the user to `tsafe biometric re-enroll`.
340 ///
341 #[command(
342 name = "rotate-key",
343 after_help = "Examples:\n tsafe rotate-key\n tsafe --profile prod rotate-key"
344 )]
345 RotateKey {
346 /// Profile to re-key (defaults to the active profile).
347 #[arg(short, long)]
348 profile: Option<String>,
349 },
350
351 /// Manage profiles (named vaults).
352 ///
353 /// Each profile is an independent vault file under the platform data `vaults/` directory.
354 ///
355 #[command(
356 after_help = "Examples:\n tsafe profile list\n tsafe profile delete staging\n tsafe profile delete staging --force"
357 )]
358 Profile {
359 #[command(subcommand)]
360 action: ProfileAction,
361 },
362
363 /// Display recent audit log entries for the current profile in human-readable form.
364 #[command(after_help = AUDIT_AFTER_HELP)]
365 Audit {
366 /// Number of entries to display.
367 #[arg(short, long, default_value_t = 20)]
368 limit: usize,
369 /// Check all secret values against Have I Been Pwned (k-anonymity, no full hash sent).
370 #[arg(long, conflicts_with = "explain")]
371 hibp: bool,
372 /// Show a session-style explanation (grouped operations, exec authority summaries).
373 #[arg(long)]
374 explain: bool,
375 /// With `--explain`, print JSON instead of human text.
376 #[arg(long, requires = "explain")]
377 json: bool,
378 /// Filter entries to those with this CellOS cell ID in their audit context.
379 #[arg(long, value_name = "CELL_ID")]
380 cell_id: Option<String>,
381 },
382
383 /// Cross-check authority contracts against a CellOS policy pack.
384 ///
385 /// Loads authority contracts from the nearest `.tsafe.yml` and compares each
386 /// contract's `allowed_secrets` against `allowedSecretRefs` in the CellOS
387 /// policy pack JSON. Reports mismatches and exits non-zero if any are found.
388 ///
389 /// Use `--policy-file` as an alias for `--cellos-policy` (both accepted).
390 ///
391 #[command(
392 after_help = "Examples:\n tsafe validate --cellos-policy doom-airgapped-policy.json\n tsafe validate --policy-file policy.json\n tsafe validate --policy-file policy.json --json"
393 )]
394 Validate {
395 /// Path to the CellOS policy pack JSON file.
396 #[arg(long, value_name = "PATH", conflicts_with = "policy_file")]
397 cellos_policy: Option<std::path::PathBuf>,
398 /// Alias for --cellos-policy. Path to the policy pack JSON file.
399 #[arg(long, value_name = "PATH", conflicts_with = "cellos_policy")]
400 policy_file: Option<std::path::PathBuf>,
401 /// Emit machine-readable JSON output (exit codes are preserved).
402 #[arg(long)]
403 json: bool,
404 },
405
406 /// Manage local vault snapshots.
407 ///
408 /// Snapshots are encrypted copies of the vault file, taken automatically before
409 /// every write operation. Use them to recover from accidental changes.
410 ///
411 #[command(after_help = "Examples:\n tsafe snapshot list\n tsafe snapshot restore")]
412 Snapshot {
413 #[command(subcommand)]
414 action: SnapshotAction,
415 },
416
417 /// Pull secrets from Azure Key Vault into the local vault.
418 ///
419 /// Requires TSAFE_AKV_URL and either a service principal
420 /// (AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET) or
421 /// a managed identity (IMDS, automatic inside Azure VMs / ACI).
422 ///
423 #[command(
424 after_help = "Examples:\n tsafe kv-pull\n tsafe kv-pull --prefix MYAPP_ --overwrite"
425 )]
426 #[cfg(feature = "akv-pull")]
427 KvPull {
428 /// Only import secrets whose names start with this prefix (case-insensitive).
429 /// Omit to pull all secrets.
430 #[arg(long)]
431 prefix: Option<String>,
432
433 /// Overwrite existing local secrets (skip conflicts by default).
434 #[arg(long)]
435 overwrite: bool,
436 /// Failure handling mode for provider/network errors.
437 #[arg(long, value_enum, default_value = "fail-all")]
438 on_error: PullOnError,
439 },
440
441 /// Push local vault secrets to Azure Key Vault (upsert semantics).
442 ///
443 /// Requires TSAFE_AKV_URL and either a service principal
444 /// (AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET) or
445 /// a managed identity (IMDS, automatic inside Azure VMs / ACI).
446 ///
447 /// Local keys are reverse-normalised to Azure Key Vault format:
448 /// MY_SECRET → my-secret. Two local keys that normalise to the same
449 /// provider name are detected as a collision and abort pre-flight.
450 ///
451 /// Remote-only keys are left untouched unless --delete-missing is passed.
452 /// A pre-flight diff is always shown before writing. No secret values
453 /// are printed — only key names and 12-char SHA-256 hash prefixes.
454 ///
455 #[command(
456 after_help = "Examples:\n tsafe kv-push --dry-run\n tsafe kv-push --yes\n tsafe kv-push --prefix MYAPP_ --yes\n tsafe kv-push --delete-missing --yes"
457 )]
458 #[cfg(feature = "akv-pull")]
459 KvPush {
460 /// Only push secrets whose local key names start with this prefix (case-insensitive).
461 #[arg(long)]
462 prefix: Option<String>,
463
464 /// Only push secrets in this namespace (stored as `<ns>/KEY`).
465 #[arg(long)]
466 ns: Option<String>,
467
468 /// Show the diff without writing anything (always exits 0).
469 #[arg(long)]
470 dry_run: bool,
471
472 /// Skip the confirmation prompt (required in non-TTY / CI contexts).
473 #[arg(long)]
474 yes: bool,
475
476 /// Also delete remote secrets that are absent locally within the filtered scope.
477 /// Off by default — opt-in to avoid accidental mass deletion.
478 /// AKV uses soft-delete (30-day recoverable window).
479 #[arg(long)]
480 delete_missing: bool,
481 },
482
483 /// Share a vault secret as a one-time HTTPS link via a configured OTS (one-time secret) service.
484 ///
485 /// Set `TSAFE_OTS_BASE_URL` to your service HTTPS origin (no default). The CLI POSTs JSON
486 /// `{"secret","ttl"}` to `{base}{TSAFE_OTS_CREATE_PATH}` (default path `/create`) and prints the returned `url`.
487 ///
488 /// The one-time URL is printed to stdout — never the secret value. Use any server that implements this contract.
489 ///
490 #[command(
491 after_help = "Examples:\n tsafe share-once DB_PASSWORD\n tsafe share-once API_KEY --ttl 10m"
492 )]
493 #[cfg(feature = "ots-sharing")]
494 #[command(name = "share-once")]
495 ShareOnce {
496 /// Secret key to share.
497 key: String,
498 /// Link expiry (sent to the service; many accept 10m, 1h, 24h).
499 #[arg(short, long, default_value = "1h")]
500 ttl: String,
501 },
502
503 /// Receive a secret from a one-time link (from `share-once` or any compatible OTS server).
504 ///
505 /// POSTs to the exact HTTPS URL. The response may be JSON (`secret`, `plaintext`, or `value`) or HTML with
506 /// `<div id="secret-content">...</div>`.
507 ///
508 /// Optionally store the retrieved value directly into the vault with --store.
509 ///
510 #[command(
511 after_help = "Examples:\n tsafe receive-once 'https://ots.example.com/s/abc123'\n tsafe receive-once '<URL>' --store DB_PASSWORD"
512 )]
513 #[cfg(feature = "ots-sharing")]
514 #[command(name = "receive-once", visible_alias = "snap-receive")]
515 ReceiveOnce {
516 /// The full one-time URL (fragment `#...` is ignored).
517 url: String,
518 /// Store the received secret in the vault under this key name instead of printing it.
519 #[arg(long)]
520 store: Option<String>,
521 },
522
523 /// Generate a cryptographically random secret and store it in the vault.
524 ///
525 /// Uses a CSPRNG. Default length 32, character set 'alnum'.
526 ///
527 #[command(
528 after_help = "Examples:\n tsafe gen DB_PASSWORD\n tsafe gen SESSION_KEY --length 64 --charset hex --print\n tsafe gen TEMP_PASSWORD --exclude-ambiguous --print"
529 )]
530 Gen {
531 /// Key name to store the generated secret under.
532 key: String,
533 /// Length of the generated secret in characters (ignored if --words is set).
534 #[arg(short = 'l', long, default_value_t = 32)]
535 length: usize,
536 /// Character set: alnum (default), alpha, numeric, hex, symbol.
537 #[arg(short = 'c', long, default_value = "alnum")]
538 charset: String,
539 /// Generate a passphrase of N random words instead of a random string.
540 #[arg(short = 'w', long)]
541 words: Option<usize>,
542 /// Attach tags as KEY=VALUE pairs (repeatable).
543 #[arg(short = 't', long = "tag", value_name = "KEY=VALUE")]
544 tags: Vec<String>,
545 /// Print the generated value to stdout (otherwise the value is only in the vault).
546 #[arg(long)]
547 print: bool,
548 /// Remove visually ambiguous characters (0, O, l, 1, I) from the charset.
549 /// Useful when the secret will be read aloud or transcribed manually.
550 #[arg(long)]
551 exclude_ambiguous: bool,
552 },
553
554 /// Show key-level changes between the current vault and its most-recent snapshot.
555 ///
556 /// Highlights added, removed, and modified keys — values are never shown.
557 ///
558 #[command(after_help = "Examples:\n tsafe diff\n tsafe --profile staging diff")]
559 Diff,
560
561 /// Compare key names across two profiles without decrypting any values.
562 ///
563 /// Highlights keys present in one profile but missing from the other.
564 ///
565 #[command(
566 after_help = "Examples:\n tsafe compare staging\n tsafe --profile dev compare prod"
567 )]
568 Compare {
569 /// Second profile to compare against the active --profile.
570 profile_b: String,
571 },
572
573 /// Show version history for a secret.
574 ///
575 /// Lists all stored versions with timestamps. Version 0 is the current
576 /// value; higher numbers are older. Use `tsafe get KEY --version N` to
577 /// retrieve a specific version.
578 ///
579 #[command(after_help = "Examples:\n tsafe history DB_PASSWORD")]
580 History {
581 /// Secret key.
582 key: String,
583 },
584
585 /// Move or rename a secret within the vault, or to a different profile.
586 ///
587 /// Within a profile this is an atomic rename: key name, namespace prefix,
588 /// tags and full version history are all preserved.
589 ///
590 #[command(
591 after_help = "Examples:\n tsafe mv DB_HOST infra/DB_HOST (add namespace)\n tsafe mv infra/DB_HOST DB_HOST (remove namespace)\n tsafe mv DB_HOST --to-profile prod (move to other profile, same key)\n tsafe mv DB_HOST --to-profile prod NEW_NAME (move + rename)"
592 )]
593 Mv {
594 /// Source secret key.
595 source: String,
596
597 /// Destination key name. Omit when using --to-profile to keep the same key name.
598 dest: Option<String>,
599
600 /// Move the secret to this profile (cross-profile move).
601 #[arg(long, value_name = "PROFILE")]
602 to_profile: Option<String>,
603
604 /// Overwrite the destination key if it already exists.
605 #[arg(long, short = 'f')]
606 force: bool,
607 },
608
609 /// Install a secret-scanning git pre-commit hook in the current repo.
610 ///
611 /// Scans staged files for hardcoded secrets on every `git commit`.
612 ///
613 #[command(
614 after_help = "Examples:\n tsafe hook-install\n tsafe hook-install --dir /path/to/repo"
615 )]
616 #[cfg(feature = "git-helpers")]
617 HookInstall {
618 /// Repo root directory; defaults to walking up from the current directory.
619 #[arg(long)]
620 dir: Option<String>,
621 },
622
623 /// Export local audit log entries to stdout or a file as JSONL, Splunk-compatible JSON, or CloudEvents JSONL.
624 #[command(
625 after_help = "CloudEvents and Splunk formats are export shapes for local audit receipts; they do not send data unless an operator wires stdout or the output file into a shipper.\n\nExamples:\n tsafe audit-export --format json --output audit.jsonl\n tsafe audit-export --format cloud-events --output audit.cloudevents.jsonl\n tsafe audit-export --format splunk"
626 )]
627 AuditExport {
628 /// Output format.
629 #[arg(short, long, value_enum, default_value = "json")]
630 format: AuditExportFormat,
631 /// Write to a file instead of stdout.
632 #[arg(short, long)]
633 output: Option<String>,
634 },
635
636 /// Report HMAC chain coverage for the audit log of the current profile.
637 ///
638 /// Reads all entries from the audit log file and counts how many carry a
639 /// `prev_entry_hmac` field (written by a C8-capable tsafe build) versus
640 /// how many are unchained (written before C8 or at a session boundary).
641 ///
642 /// IMPORTANT — ephemeral-key limitation: the HMAC chain key is generated
643 /// fresh on every tsafe session and is never persisted. This command
644 /// cannot perform cryptographic verification of entries from a closed
645 /// session; it can only report chain coverage (presence of the field).
646 /// To detect within-session tampering, use AuditLog::verify_chain() from
647 /// a live session handle.
648 ///
649 /// Exit codes: 0 = log is structurally valid (or empty), 2 = at least one
650 /// entry could not be parsed as JSON.
651 #[command(
652 after_help = "Examples:\n tsafe audit-verify\n tsafe audit-verify --json\n tsafe --profile prod audit-verify"
653 )]
654 AuditVerify {
655 /// Emit machine-readable JSON output.
656 #[arg(long)]
657 json: bool,
658 },
659
660 /// Set or remove a rotation policy on a secret.
661 ///
662 /// Policies are stored as tags and checked by `tsafe doctor` and `tsafe rotate-due`.
663 ///
664 #[command(
665 after_help = "Examples:\n tsafe policy set DB_PASSWORD --rotate-every 90d\n tsafe policy remove DB_PASSWORD"
666 )]
667 Policy {
668 #[command(subcommand)]
669 action: PolicyAction,
670 },
671
672 /// List secrets that are overdue for rotation (per `rotate_policy` tags).
673 ///
674 /// Checks the `rotate_policy` tag against the secret's `updated_at` timestamp.
675 /// Use `--json` for automation; `--fail` exits with status 1 when anything is overdue (CI/cron).
676 ///
677 /// Set policies with: `tsafe policy set KEY --rotate-every 90d`
678 #[command(after_help = ROTATE_DUE_AFTER_HELP)]
679 RotateDue {
680 /// Print JSON to stdout (`overdue_count` + `items` with key, days_overdue, policy).
681 #[arg(long)]
682 json: bool,
683 /// Exit with status 1 when one or more secrets are overdue.
684 #[arg(long)]
685 fail: bool,
686 },
687
688 /// Pull secrets from a HashiCorp Vault KV v2 store.
689 ///
690 /// Requires TSAFE_HCP_URL or --addr and VAULT_TOKEN (or --token).
691 ///
692 #[command(
693 after_help = "Examples:\n tsafe vault-pull --addr http://vault:8200 --prefix myapp/\n tsafe vault-pull # uses TSAFE_HCP_URL + VAULT_TOKEN"
694 )]
695 #[cfg(feature = "cloud-pull-vault")]
696 VaultPull {
697 /// HashiCorp Vault address. Defaults to TSAFE_HCP_URL or http://127.0.0.1:8200.
698 #[arg(long)]
699 addr: Option<String>,
700 /// Vault token. Defaults to VAULT_TOKEN env var.
701 /// Deprecated: passing the token as a CLI argument exposes it in the process
702 /// table. Store the token in tsafe and use `tsafe exec -- tsafe vault-pull`
703 /// so the token is injected securely without appearing in the process table.
704 #[arg(long)]
705 token: Option<String>,
706 /// KV v2 mount path. Defaults to "secret".
707 #[arg(long)]
708 mount: Option<String>,
709 /// Only import secrets under this path prefix.
710 #[arg(long)]
711 prefix: Option<String>,
712 /// Overwrite existing local secrets (skip conflicts by default).
713 #[arg(long)]
714 overwrite: bool,
715 },
716
717 /// Pull fields from a 1Password item via the `op` CLI.
718 ///
719 /// Requires the 1Password CLI (`op`) installed and authenticated.
720 ///
721 #[command(
722 after_help = "Examples:\n tsafe op-pull 'Database Credentials'\n tsafe op-pull abc123xyz --op-vault Personal"
723 )]
724 #[cfg(feature = "cloud-pull-1password")]
725 OpPull {
726 /// Item title or ID.
727 item: String,
728 /// 1Password vault name (uses the default vault if omitted).
729 #[arg(long = "op-vault")]
730 op_vault: Option<String>,
731 /// Overwrite existing local secrets (skip conflicts by default).
732 #[arg(long)]
733 overwrite: bool,
734 },
735
736 /// Import Login items from Bitwarden into the local vault via the `bw` CLI.
737 ///
738 /// Bitwarden REST API ciphers are always E2E encrypted client-side. This command
739 /// shells to the `bw` CLI (which handles local decryption) rather than calling
740 /// the REST API directly — the same pattern as `tsafe op-pull` for 1Password.
741 ///
742 /// Requires TSAFE_BW_CLIENT_ID, TSAFE_BW_CLIENT_SECRET, and TSAFE_BW_PASSWORD
743 /// (master password for `bw unlock`). The `bw` CLI must be installed and on PATH.
744 ///
745 /// Item names are normalised: spaces and hyphens become underscores, uppercase.
746 /// Login.Username → ITEM_NAME_USERNAME, Login.Password → ITEM_NAME_PASSWORD.
747 /// Custom text/hidden fields → ITEM_NAME_<FIELD_NAME>. Boolean fields are skipped.
748 ///
749 #[command(
750 name = "bw-pull",
751 after_help = "Examples:\n tsafe bw-pull\n tsafe bw-pull --bw-folder my-folder-id --overwrite\n tsafe bw-pull --bw-client-id org.abc --bw-password-env MY_BW_PW"
752 )]
753 #[cfg(feature = "cloud-pull-bitwarden")]
754 BwPull {
755 /// Bitwarden API client ID. Reads TSAFE_BW_CLIENT_ID if not set.
756 #[arg(long = "bw-client-id")]
757 bw_client_id: Option<String>,
758 /// Bitwarden API client secret. Reads TSAFE_BW_CLIENT_SECRET if not set.
759 #[arg(long = "bw-client-secret")]
760 bw_client_secret: Option<String>,
761 /// Bitwarden API base URL (for self-hosted / Vaultwarden).
762 /// Default: https://api.bitwarden.com
763 #[arg(long = "bw-api-url")]
764 bw_api_url: Option<String>,
765 /// Bitwarden identity base URL (for self-hosted / Vaultwarden).
766 /// Default: https://identity.bitwarden.com
767 #[arg(long = "bw-identity-url")]
768 bw_identity_url: Option<String>,
769 /// Bitwarden folder ID to filter items. Imports all items when omitted.
770 #[arg(long = "bw-folder")]
771 bw_folder: Option<String>,
772 /// Name of the env var holding the Bitwarden master password for `bw unlock`.
773 /// Default: TSAFE_BW_PASSWORD
774 #[arg(long = "bw-password-env")]
775 bw_password_env: Option<String>,
776 /// Overwrite existing local secrets (skip conflicts by default).
777 #[arg(long)]
778 overwrite: bool,
779 /// Failure handling mode for provider/network errors.
780 #[arg(long, value_enum, default_value = "fail-all")]
781 on_error: PullOnError,
782 /// Show which items would be imported without writing any secrets.
783 #[arg(long)]
784 dry_run: bool,
785 },
786
787 /// Import secrets from a KeePass `.kdbx` file into the local vault.
788 ///
789 /// Opens a local KeePass database using the master password (from the env var
790 /// named by --kp-password-env, default TSAFE_KP_PASSWORD) and/or a key file.
791 ///
792 /// Entry titles are used as key prefixes. Standard fields (UserName, Password, URL)
793 /// map to TITLE_USERNAME, TITLE_PASSWORD, TITLE_URL. Custom fields map to
794 /// TITLE_<FIELD_NAME_NORMALISED>. Notes are skipped.
795 ///
796 #[command(
797 after_help = "Examples:\n tsafe kp-pull --kp-path /home/user/vault.kdbx\n tsafe kp-pull --kp-path ~/db.kdbx --kp-password-env MY_KP_PW --kp-group Infra\n tsafe kp-pull --kp-path db.kdbx --kp-keyfile ~/my.keyx"
798 )]
799 #[cfg(feature = "cloud-pull-keepass")]
800 KpPull {
801 /// Absolute path to the `.kdbx` database file.
802 #[arg(long = "kp-path")]
803 kp_path: String,
804 /// Name of the env var that holds the master password.
805 /// Defaults to TSAFE_KP_PASSWORD.
806 #[arg(long = "kp-password-env", default_value = "TSAFE_KP_PASSWORD")]
807 kp_password_env: String,
808 /// Path to a KeePass key file (optional).
809 #[arg(long = "kp-keyfile")]
810 kp_keyfile: Option<String>,
811 /// Only import entries from this group name (case-insensitive).
812 #[arg(long = "kp-group")]
813 kp_group: Option<String>,
814 /// When set, also traverse descendant groups under the matched group.
815 #[arg(long = "kp-recursive")]
816 kp_recursive: bool,
817 /// Overwrite existing local secrets (skip conflicts by default).
818 #[arg(long)]
819 overwrite: bool,
820 /// Failure handling mode for provider/network errors.
821 #[arg(long, value_enum, default_value = "fail-all")]
822 on_error: PullOnError,
823 },
824
825 /// Import secrets from AWS Secrets Manager into the local vault.
826 ///
827 /// Authenticates via (in order): static env vars (AWS_ACCESS_KEY_ID +
828 /// AWS_SECRET_ACCESS_KEY), ECS task role, or IMDSv2 (EC2 instance profile).
829 ///
830 /// Region is read from AWS_DEFAULT_REGION / AWS_REGION or --region.
831 ///
832 /// Secret names are normalised: slashes and hyphens become underscores and
833 /// the result is uppercased (e.g. `myapp/db-password` → `MYAPP_DB_PASSWORD`).
834 ///
835 #[command(
836 after_help = "Examples:\n tsafe aws-pull --region us-east-1\n tsafe aws-pull --prefix myapp/ --overwrite"
837 )]
838 #[cfg(feature = "cloud-pull-aws")]
839 AwsPull {
840 /// AWS region (overrides AWS_DEFAULT_REGION / AWS_REGION).
841 #[arg(long)]
842 region: Option<String>,
843 /// Only import secrets whose names start with this prefix.
844 #[arg(long)]
845 prefix: Option<String>,
846 /// Overwrite existing local secrets (skip conflicts by default).
847 #[arg(long)]
848 overwrite: bool,
849 /// Failure handling mode for provider/network errors.
850 #[arg(long, value_enum, default_value = "fail-all")]
851 on_error: PullOnError,
852 },
853
854 /// Import secrets from GCP Secret Manager into the local vault.
855 ///
856 /// Authenticates via (in order): GOOGLE_OAUTH_TOKEN env var, GCE/Cloud Run/GKE
857 /// metadata server, or ADC file (gcloud auth application-default login).
858 ///
859 /// Project is read from GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT or --project.
860 ///
861 /// Secret names are normalised: hyphens and dots become underscores and
862 /// the result is uppercased (e.g. `db-password` → `DB_PASSWORD`).
863 ///
864 #[command(
865 after_help = "Examples:\n tsafe gcp-pull --project my-gcp-project\n tsafe gcp-pull --prefix myapp- --overwrite"
866 )]
867 #[cfg(feature = "cloud-pull-gcp")]
868 GcpPull {
869 /// GCP project ID (overrides GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT).
870 #[arg(long)]
871 project: Option<String>,
872 /// Only import secrets whose names start with this prefix.
873 #[arg(long)]
874 prefix: Option<String>,
875 /// Overwrite existing local secrets (skip conflicts by default).
876 #[arg(long)]
877 overwrite: bool,
878 /// Failure handling mode for provider/network errors.
879 #[arg(long, value_enum, default_value = "fail-all")]
880 on_error: PullOnError,
881 },
882
883 /// Push local vault secrets to GCP Secret Manager (upsert semantics).
884 ///
885 /// Authenticates via (in order): GOOGLE_OAUTH_TOKEN env var, GCE/Cloud Run/GKE
886 /// metadata server, or ADC file (gcloud auth application-default login).
887 ///
888 /// Project is read from GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT or --project.
889 ///
890 /// GCP Secret Manager uses a two-call pattern for new secrets: create the
891 /// secret resource, then add a version. Existing secrets only need a new version.
892 ///
893 /// Local keys are reverse-normalised to GCP format:
894 /// MY_SECRET → my-secret. Two local keys that normalise to the same
895 /// provider name are detected as a collision and abort pre-flight.
896 ///
897 /// Remote-only keys are left untouched unless --delete-missing is passed.
898 /// A pre-flight diff is always shown before writing. No secret values
899 /// are printed — only key names and 12-char SHA-256 hash prefixes.
900 ///
901 #[command(
902 after_help = "Examples:\n tsafe gcp-push --project my-project --dry-run\n tsafe gcp-push --project my-project --yes\n tsafe gcp-push --prefix MYAPP_ --yes\n tsafe gcp-push --delete-missing --yes"
903 )]
904 #[cfg(feature = "cloud-pull-gcp")]
905 GcpPush {
906 /// GCP project ID (overrides GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT).
907 #[arg(long)]
908 project: Option<String>,
909
910 /// Only push secrets whose local key names start with this prefix (case-insensitive).
911 #[arg(long)]
912 prefix: Option<String>,
913
914 /// Only push secrets in this namespace (stored as `<ns>/KEY`).
915 #[arg(long)]
916 ns: Option<String>,
917
918 /// Show the diff without writing anything (always exits 0).
919 #[arg(long)]
920 dry_run: bool,
921
922 /// Skip the confirmation prompt (required in non-TTY / CI contexts).
923 #[arg(long)]
924 yes: bool,
925
926 /// Also delete remote secrets absent locally within the filtered scope.
927 /// Off by default — opt-in to avoid accidental mass deletion.
928 /// Note: GCP Secret Manager deletion requires the Secret Manager Admin API.
929 #[arg(long)]
930 delete_missing: bool,
931 },
932
933 /// Import parameters from AWS SSM Parameter Store into the local vault.
934 ///
935 /// Authenticates via (in order): static env vars (AWS_ACCESS_KEY_ID +
936 /// AWS_SECRET_ACCESS_KEY), ECS task role, or IMDSv2 (EC2 instance profile).
937 ///
938 /// Region is read from AWS_DEFAULT_REGION / AWS_REGION or --region.
939 /// Parameters are fetched recursively under the given path.
940 /// SecureString parameters are decrypted automatically (WithDecryption=true).
941 ///
942 /// Parameter names are normalised: leading `/` stripped, remaining `/` and `-`
943 /// become `_`, uppercased (e.g. `/myapp/db-password` → `MYAPP_DB_PASSWORD`).
944 ///
945 #[command(
946 after_help = "Examples:\n tsafe ssm-pull --region us-east-1 --path /myapp/prod/\n tsafe ssm-pull --path /shared/ --overwrite"
947 )]
948 #[cfg(feature = "cloud-pull-aws")]
949 SsmPull {
950 /// AWS region (overrides AWS_DEFAULT_REGION / AWS_REGION).
951 #[arg(long)]
952 region: Option<String>,
953 /// Parameter path prefix (e.g. `/myapp/prod/`). Defaults to `/` (all parameters).
954 #[arg(long)]
955 path: Option<String>,
956 /// Overwrite existing local secrets (skip conflicts by default).
957 #[arg(long)]
958 overwrite: bool,
959 /// Failure handling mode for provider/network errors.
960 #[arg(long, value_enum, default_value = "fail-all")]
961 on_error: PullOnError,
962 },
963
964 /// Push local vault secrets to AWS Secrets Manager (upsert semantics).
965 ///
966 /// Authenticates via (in order): static env vars (AWS_ACCESS_KEY_ID +
967 /// AWS_SECRET_ACCESS_KEY), ECS task role, or IMDSv2 (EC2 instance profile).
968 ///
969 /// Local keys are reverse-normalised to AWS Secrets Manager format:
970 /// MY_SECRET → my-secret. Two local keys that normalise to the same
971 /// provider name are detected as a collision and abort pre-flight.
972 ///
973 /// Remote-only secrets are left untouched unless --delete-missing is passed.
974 /// A pre-flight diff is always shown before writing. No secret values
975 /// are printed — only key names and 12-char SHA-256 hash prefixes.
976 ///
977 #[command(
978 after_help = "Examples:\n tsafe aws-push --dry-run\n tsafe aws-push --yes\n tsafe aws-push --prefix myapp/ --yes\n tsafe aws-push --delete-missing --yes"
979 )]
980 #[cfg(feature = "cloud-pull-aws")]
981 AwsPush {
982 /// AWS region (overrides AWS_DEFAULT_REGION / AWS_REGION).
983 #[arg(long)]
984 region: Option<String>,
985 /// Only push secrets whose local key names start with this prefix (case-insensitive).
986 #[arg(long)]
987 prefix: Option<String>,
988 /// Show the diff without writing anything (always exits 0).
989 #[arg(long)]
990 dry_run: bool,
991 /// Skip the confirmation prompt (required in non-TTY / CI contexts).
992 #[arg(long)]
993 yes: bool,
994 /// Also delete remote secrets absent locally within the filtered scope.
995 /// Off by default — opt-in to avoid accidental mass deletion.
996 #[arg(long)]
997 delete_missing: bool,
998 },
999
1000 /// Push local vault secrets to AWS SSM Parameter Store (upsert semantics).
1001 ///
1002 /// Authenticates via (in order): static env vars (AWS_ACCESS_KEY_ID +
1003 /// AWS_SECRET_ACCESS_KEY), ECS task role, or IMDSv2 (EC2 instance profile).
1004 ///
1005 /// Local keys are reverse-normalised to SSM parameter names:
1006 /// given `--path /myapp/`, MYAPP_DB_PASSWORD → /myapp/db-password.
1007 ///
1008 /// Remote-only parameters are left untouched unless --delete-missing is passed.
1009 /// A pre-flight diff is always shown before writing. No secret values
1010 /// are printed — only key names and 12-char SHA-256 hash prefixes.
1011 ///
1012 #[command(
1013 after_help = "Examples:\n tsafe ssm-push --path /myapp/ --dry-run\n tsafe ssm-push --path /myapp/ --yes\n tsafe ssm-push --path /myapp/ --delete-missing --yes"
1014 )]
1015 #[cfg(feature = "cloud-pull-aws")]
1016 SsmPush {
1017 /// AWS region (overrides AWS_DEFAULT_REGION / AWS_REGION).
1018 #[arg(long)]
1019 region: Option<String>,
1020 /// SSM path prefix that scopes the push (e.g. `/myapp/`).
1021 #[arg(long)]
1022 path: Option<String>,
1023 /// Show the diff without writing anything (always exits 0).
1024 #[arg(long)]
1025 dry_run: bool,
1026 /// Skip the confirmation prompt (required in non-TTY / CI contexts).
1027 #[arg(long)]
1028 yes: bool,
1029 /// Also delete remote parameters absent locally within the path scope.
1030 /// Off by default — opt-in to avoid accidental mass deletion.
1031 #[arg(long)]
1032 delete_missing: bool,
1033 },
1034
1035 /// Print a shell completion script and exit.
1036 ///
1037 #[command(
1038 after_help = "Examples:\n tsafe completions powershell | Out-String | Invoke-Expression"
1039 )]
1040 Completions {
1041 /// Shell to generate completions for.
1042 shell: Shell,
1043 },
1044
1045 /// Output completion candidates for use by shell completion scripts (internal).
1046 ///
1047 /// Called by the patched completion scripts generated by `tsafe completions`.
1048 /// Not intended for direct use.
1049 #[command(name = "_completions-data", hide = true)]
1050 CompletionsData {
1051 /// Type of completion data to emit: `profiles` or `contracts`.
1052 data_type: String,
1053 },
1054
1055 /// Diagnose vault health: file presence, snapshots, env vars, secret expiry, and operator-facing health hints.
1056 #[command(long_about = DOCTOR_LONG_ABOUT, after_help = DOCTOR_AFTER_HELP)]
1057 Doctor {
1058 /// Emit machine-readable JSON and use health exit codes (0=healthy, 1=warning, 2=critical).
1059 #[arg(long)]
1060 json: bool,
1061 },
1062
1063 /// Explain a concept in the terminal (`exec`, namespaces, compiled agent/browser pull lanes, …).
1064 ///
1065 /// Omit the topic to list available explanations.
1066 ///
1067 #[command(
1068 after_help = "Examples:\n tsafe explain\n tsafe explain exec\n tsafe explain exec-security"
1069 )]
1070 Explain {
1071 /// Topic to print (omit to list all topics).
1072 #[arg(value_name = "TOPIC")]
1073 topic: Option<crate::explain::ExplainTopic>,
1074 },
1075
1076 /// Remove a stale vault lock file (use after a crash leaves the vault locked).
1077 ///
1078 /// Deletes `<profile>.vault.lock` if it exists. Safe to run — the lock is
1079 /// advisory only. Use when `tsafe` reports "vault is locked by another process"
1080 /// but no other process is actually running.
1081 ///
1082 #[command(after_help = "Examples:\n tsafe unlock\n tsafe --profile prod unlock")]
1083 Unlock,
1084
1085 /// Launch the full-screen interactive terminal UI.
1086 ///
1087 /// Supports add/edit/delete/reveal/rotate/snapshot restore and audit log viewing.
1088 /// Press ? inside the TUI for a contextual keyboard reference.
1089 ///
1090 #[command(after_help = "Examples:\n tsafe ui\n tsafe --profile prod ui")]
1091 #[cfg(feature = "tui")]
1092 Ui,
1093
1094 /// Render a secret value as a QR code in the terminal.
1095 ///
1096 /// Opens the vault, retrieves KEY, prints the QR code to stdout, then waits
1097 /// for Enter before clearing — so the value is never left on-screen.
1098 ///
1099 #[command(after_help = "Examples:\n tsafe qr WIFI_PASSWORD\n tsafe qr API_KEY")]
1100 Qr {
1101 /// Secret key whose value to render as a QR code.
1102 key: String,
1103 },
1104
1105 /// Store a TOTP secret and retrieve live codes.
1106 ///
1107 /// add: store a TOTP seed for the given key
1108 /// get: compute and print the current 6-digit code
1109 ///
1110 #[command(
1111 after_help = "Examples:\n tsafe totp add GITHUB_2FA JBSWY3DPEHPK3PXP\n tsafe totp get GITHUB_2FA"
1112 )]
1113 Totp {
1114 #[command(subcommand)]
1115 action: TotpAction,
1116 },
1117
1118 /// Pin a secret to the top of lists.
1119 ///
1120 #[command(
1121 after_help = "Examples:\n tsafe pin DB_PASSWORD\n tsafe --profile prod pin API_KEY"
1122 )]
1123 Pin { key: String },
1124
1125 /// Remove pin from a secret.
1126 ///
1127 #[command(after_help = "Examples:\n tsafe unpin DB_PASSWORD")]
1128 Unpin { key: String },
1129
1130 /// Create an alias: ALIAS_NAME resolves to an existing KEY.
1131 ///
1132 /// tsafe get ALIAS_NAME returns the value of KEY.
1133 /// Use tsafe alias --list to view all aliases.
1134 ///
1135 #[command(
1136 after_help = "Examples:\n tsafe alias DB_PASS DATABASE_PASSWORD\n tsafe alias --list"
1137 )]
1138 Alias {
1139 /// Key this alias should resolve to (omit with --list to view all aliases).
1140 target_key: Option<String>,
1141 /// Name of the alias to create.
1142 alias_name: Option<String>,
1143 /// List all aliases in the vault.
1144 #[arg(long)]
1145 list: bool,
1146 },
1147
1148 /// Replace `{{KEY}}` placeholders in a file with vault secret values.
1149 ///
1150 /// Reads the input file, replaces each `{{KEY}}` with the corresponding
1151 /// vault secret, and writes to stdout (or `--output PATH`).
1152 ///
1153 #[command(
1154 after_help = "Examples:\n tsafe template config.yml.tmpl > config.yml\n tsafe template app.conf.tmpl --output app.conf"
1155 )]
1156 Template {
1157 /// Input template file containing {{KEY}} placeholders.
1158 file: String,
1159 /// Write output to a file instead of stdout.
1160 #[arg(short, long)]
1161 output: Option<String>,
1162 /// Ignore missing keys instead of failing.
1163 #[arg(long)]
1164 ignore_missing: bool,
1165 },
1166
1167 /// Read stdin and replace any vault secret values with `[REDACTED]`.
1168 ///
1169 /// Useful for piping logs through to scrub sensitive values.
1170 ///
1171 #[command(
1172 after_help = "Examples:\n cargo test 2>&1 | tsafe redact\n tsafe exec -- myapp | tsafe redact"
1173 )]
1174 Redact,
1175
1176 /// Manage the repo-local tsafe tooling inventory.
1177 ///
1178 /// The inventory lives under `.tsafe/tooling/` and records secret slots,
1179 /// consumers, and rotation expectations. It never stores secret values.
1180 #[command(
1181 after_help = "Examples:\n tsafe tooling init --namespace databricks/athn_dev/\n tsafe tooling check --json\n tsafe tooling suggest --namespace databricks/athn_dev/ --section ci-cd-spn --key ci_secret --purpose \"SPN secret\" --consumer \"ADO service connection\" --rotation \"365d KV policy\" --apply"
1182 )]
1183 Tooling {
1184 #[command(subcommand)]
1185 action: ToolingAction,
1186 },
1187
1188 /// Show the active build profile label and compile-time capabilities.
1189 ///
1190 /// This reports the compiled truth for the running `tsafe` binary only.
1191 /// Companion runtimes such as `tsafe-agent` have separate install and release truth.
1192 #[command(name = "build-info", after_help = BUILD_INFO_AFTER_HELP)]
1193 BuildInfo {
1194 /// Emit machine-readable JSON output.
1195 #[arg(long)]
1196 json: bool,
1197 },
1198
1199 #[cfg(feature = "plugins")]
1200 /// Run a tool with its required vault secrets injected automatically.
1201 ///
1202 /// Each plugin knows which vault keys map to which environment variables for the
1203 /// named tool. Run `tsafe plugin` (no args) to list available plugins.
1204 ///
1205 /// Missing optional keys are silently skipped; missing required keys abort with an error.
1206 ///
1207 #[command(
1208 after_help = "Examples:\n tsafe plugin gh repo list\n tsafe plugin aws s3 ls --bucket my-bucket\n tsafe plugin az group list --subscription my-sub\n tsafe plugin (list all available plugins)"
1209 )]
1210 Plugin {
1211 /// Tool name (e.g. gh, aws, az, docker, npm, pypi, terraform). Omit to list.
1212 tool: Option<String>,
1213 /// Arguments to pass to the tool.
1214 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1215 args: Vec<String>,
1216 },
1217
1218 /// Act as a git credential helper (install/get/store/erase protocol).
1219 ///
1220 /// Run `tsafe credential-helper install` once to configure git to use tsafe
1221 /// as the credential store. Git will then call `tsafe credential-helper get`,
1222 /// `store`, or `erase` automatically.
1223 ///
1224 /// In `get` mode, reads protocol/host from stdin and returns username/password
1225 /// from the vault. Keys are matched by `<HOST>_USERNAME` / `<HOST>_PASSWORD`
1226 /// pattern, or by tags `host=<HOST>`.
1227 #[cfg(feature = "git-helpers")]
1228 #[command(
1229 name = "credential-helper",
1230 after_help = "Examples:\n tsafe credential-helper install\n tsafe credential-helper install --global\n git credential fill # (git calls tsafe automatically after install)"
1231 )]
1232 CredentialHelper {
1233 /// Git credential helper action.
1234 #[arg(value_enum, default_value = "install")]
1235 action: CredentialHelperOperation,
1236 /// For `install`: configure at the --global level (user-wide).
1237 /// By default, configures the local repository git config.
1238 #[arg(long)]
1239 global: bool,
1240 },
1241
1242 /// Collaboration service commands (team membership, DEK delivery, recovery).
1243 ///
1244 /// Scaffolding only in Tranche 2 — no network calls are made.
1245 /// Enable with `--features collab`.
1246 ///
1247 #[command(
1248 after_help = "Examples:\n tsafe collab join <team-id>\n tsafe collab status <team-id>"
1249 )]
1250 #[cfg(feature = "collab")]
1251 Collab {
1252 #[command(subcommand)]
1253 action: CollabAction,
1254 },
1255
1256 /// Add an SSH key from the vault to the running ssh-agent.
1257 ///
1258 /// The key is passed via stdin to `ssh-add -` so it never touches disk.
1259 ///
1260 #[command(after_help = "Examples:\n tsafe ssh-add SSH_KEY\n tsafe ssh-add id_ed25519")]
1261 #[cfg(feature = "ssh")]
1262 SshAdd {
1263 /// Vault key name containing the SSH private key.
1264 key: String,
1265 },
1266
1267 /// Import an SSH private key file into the vault.
1268 ///
1269 #[command(
1270 after_help = "Examples:\n tsafe ssh-import ~/.ssh/id_ed25519\n tsafe ssh-import ~/.ssh/id_rsa --name SSH_RSA_KEY"
1271 )]
1272 #[cfg(feature = "ssh")]
1273 SshImport {
1274 /// Path to the SSH private key file.
1275 path: String,
1276 /// Vault key name to store under (defaults to filename).
1277 #[arg(long)]
1278 name: Option<String>,
1279 /// Attach tags as KEY=VALUE pairs (repeatable).
1280 #[arg(short, long = "tag", value_name = "KEY=VALUE")]
1281 tags: Vec<String>,
1282 },
1283
1284 /// SSH key inventory and operations.
1285 ///
1286 /// Subcommands: list, public-key, generate, config, agent
1287 ///
1288 #[command(
1289 after_help = "Examples:\n tsafe ssh list\n tsafe ssh public-key my_ed25519_key\n tsafe ssh generate my_key\n tsafe ssh generate my_key --type rsa\n tsafe ssh config\n eval $(tsafe ssh-agent)"
1290 )]
1291 #[cfg(feature = "ssh")]
1292 Ssh {
1293 #[command(subcommand)]
1294 action: SshAction,
1295 },
1296
1297 /// List namespaces or copy/move all keys under one prefix to another.
1298 ///
1299 /// A namespace is any key-prefix of the form `<name>/KEY`. They are not stored
1300 /// explicitly — this command introspects the key names in the vault.
1301 ///
1302 #[command(
1303 after_help = "Examples:\n tsafe ns list\n tsafe ns copy prod staging\n tsafe ns move oldapp newapp --force"
1304 )]
1305 Ns {
1306 #[command(subcommand)]
1307 action: NsAction,
1308 },
1309
1310 /// Pull secrets from all sources defined in `.tsafe.yml`.
1311 ///
1312 /// Searches upward from the current directory for `.tsafe.yml` or `.tsafe.json`
1313 /// and executes each pull source in manifest order (sequential; see ADR-012).
1314 ///
1315 /// Use --dry-run to preview which sources would be invoked without making any
1316 /// live API calls. Note: collision detection is not available in dry-run mode —
1317 /// detecting key conflicts requires fetching keys from each provider.
1318 ///
1319 /// Use --source to narrow execution to one or more named sources. Sources are
1320 /// named with the `name` field in the manifest. Multiple --source flags are OR'd.
1321 ///
1322 #[command(
1323 after_help = "Examples:\n tsafe pull\n tsafe pull --config path/to/.tsafe.yml\n tsafe pull --dry-run\n tsafe pull --source prod-akv\n tsafe pull --source prod-akv --source staging-aws"
1324 )]
1325 #[cfg(feature = "multi-pull")]
1326 Pull {
1327 /// Path to config file (auto-detected if omitted).
1328 #[arg(long)]
1329 config: Option<String>,
1330 /// Overwrite all existing secrets (overrides per-source settings).
1331 #[arg(long)]
1332 overwrite: bool,
1333 /// Failure handling mode for source errors in multi-source pull.
1334 #[arg(long, value_enum, default_value = "fail-all")]
1335 on_error: PullOnError,
1336 /// Preview which sources would be invoked without making any live API calls.
1337 /// Collision detection is not available in dry-run mode.
1338 #[arg(long)]
1339 dry_run: bool,
1340 /// Narrow execution to sources with this `name` label (repeatable).
1341 /// Sources without a `name` field are excluded when any --source filter is active.
1342 #[arg(long = "source", value_name = "LABEL", action = clap::ArgAction::Append)]
1343 sources: Vec<String>,
1344 },
1345
1346 /// Push local vault secrets to all destinations defined in `.tsafe.yml`.
1347 ///
1348 /// Searches upward from the current directory for `.tsafe.yml` or `.tsafe.json`
1349 /// and executes each push destination in manifest order (sequential; see ADR-030).
1350 ///
1351 /// Use --dry-run to preview which destinations would be invoked without making
1352 /// any live API calls or writes.
1353 ///
1354 /// Use --source to narrow execution to one or more named destinations. Destinations
1355 /// are named with the `name` field in the manifest. Multiple --source flags are OR'd.
1356 ///
1357 /// A pre-flight diff is shown before any writes. Secret values are never printed —
1358 /// only key names and 12-char SHA-256 hash prefixes are shown (ADR-030).
1359 ///
1360 #[command(
1361 after_help = "Examples:\n tsafe push\n tsafe push --config path/to/.tsafe.yml\n tsafe push --dry-run\n tsafe push --source prod-akv\n tsafe push --yes\n tsafe push --on-error skip-failed"
1362 )]
1363 #[cfg(feature = "akv-pull")]
1364 Push {
1365 /// Path to config file (auto-detected if omitted).
1366 #[arg(long, value_name = "PATH")]
1367 config: Option<std::path::PathBuf>,
1368 /// Narrow execution to destinations with this `name` label (repeatable).
1369 /// Destinations without a `name` field are excluded when any --source filter is active.
1370 #[arg(long = "source", value_name = "LABEL", action = clap::ArgAction::Append)]
1371 source: Vec<String>,
1372 /// Show the diff without writing anything (always exits 0).
1373 #[arg(long)]
1374 dry_run: bool,
1375 /// Skip confirmation prompts (required in non-TTY / CI contexts).
1376 #[arg(long)]
1377 yes: bool,
1378 /// Also delete remote secrets that are absent locally within each destination's scope.
1379 /// Off by default — opt-in to avoid accidental mass deletion (ADR-030).
1380 #[arg(long)]
1381 delete_missing: bool,
1382 /// Failure handling mode for destination errors.
1383 #[arg(long, value_enum, default_value = "fail-all")]
1384 on_error: PushOnError,
1385 },
1386
1387 /// Synchronise a vault file with a git remote.
1388 ///
1389 /// Fetches the remote branch, performs a per-key three-way merge between
1390 /// the common ancestor, the local vault, and the remote vault, then commits
1391 /// and pushes the merged result.
1392 ///
1393 /// Conflicts (both sides edited the same key) are resolved by last-write-wins
1394 /// using the secret's `updated_at` timestamp. Conflicts are reported but do
1395 /// not block the sync.
1396 ///
1397 #[command(
1398 after_help = "Examples:\n tsafe sync\n tsafe sync --remote origin --branch main\n tsafe sync --dry-run"
1399 )]
1400 #[cfg(feature = "git-helpers")]
1401 #[command(name = "sync")]
1402 Sync {
1403 /// Git remote name.
1404 #[arg(long, default_value = "origin")]
1405 remote: String,
1406 /// Git branch to sync with.
1407 #[arg(long, default_value = "main")]
1408 branch: String,
1409 /// Vault file path relative to repo root (auto-detected if omitted).
1410 #[arg(long)]
1411 file: Option<String>,
1412 /// Show what would change without modifying anything.
1413 #[arg(long)]
1414 dry_run: bool,
1415 },
1416
1417 /// Manage team vaults (multi-recipient age encryption).
1418 ///
1419 /// Team vaults use X25519 (age) keypairs so multiple people can decrypt
1420 /// the same vault without sharing a password.
1421 ///
1422 #[command(
1423 after_help = "Examples:\n tsafe team init --identity ~/.age/key.txt\n tsafe team add-member age1qyqszqgpqyqszqgpqyqszqgpqyqszqgp...\n tsafe team members"
1424 )]
1425 #[cfg(feature = "team-core")]
1426 Team {
1427 #[command(subcommand)]
1428 action: TeamAction,
1429 },
1430
1431 /// Enable or disable biometric / keyring unlock for the current profile.
1432 ///
1433 /// When enabled, the vault password is stored in the OS credential store
1434 /// (macOS Keychain, Windows Credential Manager, Linux Secret Service).
1435 /// The credential store is itself protected by biometric or PIN.
1436 ///
1437 /// After `tsafe init`, the CLI may offer the same setup interactively ("quick unlock").
1438 /// You can always run `biometric enable` later if you skipped it.
1439 ///
1440 #[command(
1441 after_help = "Examples:\n tsafe biometric enable\n tsafe biometric disable\n tsafe biometric status"
1442 )]
1443 #[cfg(feature = "biometric")]
1444 Biometric {
1445 #[command(subcommand)]
1446 action: BiometricAction,
1447 },
1448
1449 /// Manage the per-process vault unlock agent.
1450 ///
1451 /// `tsafe agent unlock` prints terminal approval text, may show an OS notification,
1452 /// then prompts for the vault password once and starts a background agent that holds
1453 /// it in memory. The token it prints must be set in the calling process's environment
1454 /// as `TSAFE_AGENT_SOCK` — all subsequent `tsafe` invocations that inherit that
1455 /// env var will be granted vault access without re-entering the password.
1456 ///
1457 /// Requests must present the session token and come from a live OS-reported peer
1458 /// PID; the unlock process PID is recorded for audit/context, not as the only
1459 /// process allowed to use the session.
1460 ///
1461 #[command(
1462 after_help = "Examples:\n tsafe agent unlock # unlock for 30 minutes (default)\n tsafe agent unlock --ttl 8h # unlock for 8 hours\n tsafe agent unlock --ttl 30m --absolute-ttl 8h\n tsafe agent status # check whether the current agent socket is reachable\n tsafe agent lock # immediately revoke the session"
1463 )]
1464 #[cfg(feature = "agent")]
1465 Agent {
1466 #[command(subcommand)]
1467 action: AgentAction,
1468 },
1469
1470 /// First-party MCP server — exposes tsafe to MCP-aware hosts (Claude
1471 /// Desktop, Cursor, Continue, Windsurf, Codex) over stdio JSON-RPC.
1472 ///
1473 /// Each `tsafe-mcp` process binds to exactly one profile; request-time
1474 /// profile or scope widening is rejected. See ADR-006
1475 /// (docs/architecture/ADR-006-mcp-server.md) and design doc §5.2 for the
1476 /// full surface.
1477 ///
1478 /// `tsafe mcp install <host>` writes the per-host MCP config file. The
1479 /// resulting host launch shells out to `tsafe-mcp serve` directly, not
1480 /// through this CLI.
1481 #[command(
1482 after_help = "Examples:\n tsafe mcp serve --profile ops --contract cordance-diagnostics --workdir .\n tsafe mcp config codex --name tsafe-cordance --profile ops --contract cordance-diagnostics --workdir .\n tsafe mcp doctor --code missing_contract --contract cordance-diagnostics --workdir . --json\n tsafe mcp install claude --allowed-keys \"demo/*\"\n tsafe mcp install cursor --project . --allowed-keys \"demo/*\"\n tsafe mcp serve --allowed-keys \"demo/*\" --allow-reveal\n tsafe mcp uninstall claude\n tsafe mcp status"
1483 )]
1484 #[cfg(feature = "mcp")]
1485 Mcp {
1486 #[command(subcommand)]
1487 action: McpCliAction,
1488 },
1489
1490 /// Run a git command with vault credentials injected automatically.
1491 ///
1492 /// Opens the vault, reads `ADO_PAT` (or the key named by TSAFE_GIT_PAT_KEY),
1493 /// and injects it as a git `http.extraHeader` so HTTPS remotes authenticate
1494 /// without embedding tokens in URLs.
1495 ///
1496 /// Detects the nearest `.git` directory automatically — no repo flags needed.
1497 /// Exits with git's exit code.
1498 ///
1499 /// Override the PAT key name: $env:TSAFE_GIT_PAT_KEY = "MY_GIT_PAT"
1500 ///
1501 #[command(
1502 after_help = "Examples:\n tsafe git push ado main\n tsafe git pull\n tsafe git fetch --all\n tsafe -p work git push origin main"
1503 )]
1504 #[cfg(feature = "git-helpers")]
1505 #[command(name = "git")]
1506 Git {
1507 /// git subcommand and its arguments (e.g. `push ado main`).
1508 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1509 args: Vec<String>,
1510 },
1511
1512 /// Run the tsafe attestation scanner over a repo.
1513 ///
1514 /// The scanner is the Phase 3 port of algol's secret-detection +
1515 /// env-authority scanner. It walks a repo tree, emits a JSON / markdown
1516 /// / text report, and writes a CloudEvents `algol.scan.v1` envelope
1517 /// for the audit trail. Default-on per Phase 3 of the algol→tsafe
1518 /// migration — no `--experimental-scan` flag needed.
1519 ///
1520 /// Honest disclosure: scanner P/R = 1.000/1.000 on the synthetic N=100
1521 /// corpus at `tsafe/benchmarks/scanner-corpus/v1/` (see
1522 /// `ecosystem-catalog/portfolio-algol-tsafe-phase2-1-precision-recovery-2026-05-21.md`
1523 /// for confidence intervals). Real-world false-positive / negative
1524 /// rates may differ; report issues to the tsafe repo. Scanner uses
1525 /// BLAKE3 content fingerprints per ec ADR-0003 (wire-format change
1526 /// from algol's SHA-256 — see CHANGELOG).
1527 #[command(
1528 after_help = "Examples:\n tsafe attest scan # scan current dir\n tsafe attest scan ./my-repo # scan specific repo\n tsafe attest scan --format json -o scan.json\n tsafe attest scan --strict # non-zero exit on any secret finding\n tsafe attest scan --extra-paths ./submodule"
1529 )]
1530 Attest {
1531 #[command(subcommand)]
1532 action: AttestAction,
1533 },
1534}
1535
1536/// Collab subcommands — scaffolding for D3.5 (Tranche 2).
1537/// No network calls in this release; real implementation is Tranche 3+.
1538#[derive(Subcommand)]
1539#[cfg(feature = "collab")]
1540pub enum CollabAction {
1541 /// Join a collaboration team (scaffolding — prints status and exits 0).
1542 Join {
1543 /// Team ID to join.
1544 team_id: String,
1545 },
1546 /// Show collaboration status for a team (scaffolding — prints status and exits 0).
1547 Status {
1548 /// Team ID to query.
1549 team_id: String,
1550 },
1551}
1552
1553#[derive(Subcommand)]
1554pub enum SnapshotAction {
1555 /// List snapshots for the current profile.
1556 List,
1557 /// Restore the most-recent snapshot, overwriting the current vault.
1558 Restore,
1559}
1560
1561/// Global config.json settings (not tied to `--profile`).
1562#[derive(Subcommand)]
1563pub enum ConfigAction {
1564 /// Show config file path, default profile, exec trust settings, and password-backup settings.
1565 Show,
1566 /// After each new password vault is created, copy its master password into this vault at `profile-passwords/<new-profile>` (recovery / main-vault bridging). Common values: `main`, `default`. Use `off` to disable.
1567 #[command(name = "set-backup-vault")]
1568 SetBackupVault {
1569 /// Target vault profile (`main`, `default`) or `off` to clear.
1570 target: String,
1571 },
1572 /// Persist whether normal vault opens should automatically try OS quick unlock.
1573 #[command(name = "set-auto-quick-unlock")]
1574 SetAutoQuickUnlock {
1575 /// `on` to allow automatic keychain reads, `off` to require agent / env / typed password instead.
1576 mode: ToggleSetting,
1577 },
1578 /// Persist the retry cooldown, in seconds, after an automatic quick-unlock failure.
1579 #[command(name = "set-quick-unlock-retry-cooldown")]
1580 SetQuickUnlockRetryCooldown {
1581 /// Seconds to wait before the next automatic keychain attempt. Use `0` to disable the cooldown.
1582 seconds: u64,
1583 },
1584 /// Persist the default exec trust mode.
1585 #[command(name = "set-exec-mode")]
1586 SetExecMode {
1587 /// One of: `standard`, `hardened`, `custom`.
1588 mode: ExecModeSetting,
1589 },
1590 /// Persist whether `tsafe exec` should redact child stdout/stderr by default.
1591 #[command(name = "set-exec-redact-output")]
1592 SetExecRedactOutput {
1593 /// `on` to redact child output by default, `off` to leave it raw unless `--redact-output` is passed.
1594 mode: ToggleSetting,
1595 },
1596 /// Persist the inherit strategy used when exec mode is `custom`.
1597 #[command(name = "set-exec-custom-inherit")]
1598 SetExecCustomInherit {
1599 /// One of: `full`, `minimal`, `clean`.
1600 mode: ExecCustomInheritSetting,
1601 },
1602 /// Persist whether dangerous injected env names should abort exec when mode is `custom`.
1603 #[command(name = "set-exec-custom-deny-dangerous-env")]
1604 SetExecCustomDenyDangerousEnv {
1605 /// `on` to abort, `off` to warn only.
1606 mode: ToggleSetting,
1607 },
1608 /// Add a parent environment variable name to the extra strip list for `tsafe exec`.
1609 #[command(name = "add-exec-extra-strip")]
1610 AddExecExtraStrip {
1611 /// Environment variable name, e.g. OPENAI_API_KEY.
1612 name: String,
1613 },
1614 /// Remove a parent environment variable name from the extra strip list for `tsafe exec`.
1615 #[command(name = "remove-exec-extra-strip")]
1616 RemoveExecExtraStrip {
1617 /// Environment variable name, e.g. OPENAI_API_KEY.
1618 name: String,
1619 },
1620}
1621
1622#[derive(Clone, Copy, ValueEnum)]
1623pub enum ToggleSetting {
1624 /// Enable the setting.
1625 On,
1626 /// Disable the setting.
1627 Off,
1628}
1629
1630#[derive(Clone, Copy, ValueEnum)]
1631pub enum ExecModeSetting {
1632 /// Broad compatibility: full inherited env (minus strip list), raw output, and abort on dangerous injected names by default.
1633 Standard,
1634 /// Stricter preset: minimal inherited env, redacted output, and deny dangerous injected names.
1635 Hardened,
1636 /// Use persisted custom exec trust settings from config.json.
1637 Custom,
1638}
1639
1640/// Controls which host environment variables the child process inherits.
1641#[derive(Clone, Copy, ValueEnum)]
1642pub enum ExecPresetSetting {
1643 /// Inherit only PATH and a safe core set (HOME, USER, TMPDIR, LANG, TERM, SSH_AUTH_SOCK, etc.)
1644 /// plus vault secrets. No tokens or credentials from the parent environment leak through.
1645 Minimal,
1646 /// Inherit the full parent environment minus the known-sensitive strip list. This is the
1647 /// current default behavior when no preset or inheritance flag is given.
1648 Full,
1649}
1650
1651#[derive(Clone, Copy, ValueEnum)]
1652pub enum ExecCustomInheritSetting {
1653 /// Full inherited parent env (minus strip list).
1654 Full,
1655 /// Minimal inherited env plus vault secrets.
1656 Minimal,
1657 /// No inherited parent env; only vault secrets.
1658 Clean,
1659}
1660
1661#[derive(Subcommand)]
1662pub enum ProfileAction {
1663 /// List all profiles that have an existing vault.
1664 List,
1665 /// Permanently delete a profile vault.
1666 Delete {
1667 name: String,
1668 /// Skip the confirmation prompt.
1669 #[arg(long)]
1670 force: bool,
1671 },
1672 /// Set the default profile used when -p / TSAFE_PROFILE is not specified.
1673 ///
1674 #[command(after_help = "Examples:\n tsafe profile set-default work")]
1675 SetDefault {
1676 /// Profile name to use as the new default.
1677 name: String,
1678 },
1679 /// Rename a profile (renames the vault file and updates the default if needed).
1680 ///
1681 #[command(after_help = "Examples:\n tsafe profile rename old new")]
1682 Rename {
1683 /// Existing profile name.
1684 from: String,
1685 /// New profile name.
1686 to: String,
1687 },
1688}
1689
1690#[derive(Clone, ValueEnum)]
1691pub enum ExportFormat {
1692 /// KEY=VALUE (posix, one per line)
1693 Env,
1694 /// export KEY="VALUE" (bash/zsh source-able)
1695 Dotenv,
1696 /// $env:KEY = "VALUE" (PowerShell source-able)
1697 Powershell,
1698 /// JSON object
1699 Json,
1700 /// ::add-mask::VALUE + KEY=VALUE (GitHub Actions GITHUB_ENV format)
1701 GithubActions,
1702 /// YAML mapping (KEY: "VALUE" per entry)
1703 Yaml,
1704 /// KEY=VALUE per line suitable for Docker --env-file (alias for env, Docker-compatible)
1705 DockerEnv,
1706 /// TOML flat top-level table (KEY = "VALUE" per entry)
1707 Toml,
1708}
1709
1710#[derive(Clone, ValueEnum)]
1711pub enum AuditExportFormat {
1712 /// JSONL (one JSON object per line, same as stored on disk)
1713 Json,
1714 /// Splunk HEC-compatible JSON events
1715 Splunk,
1716 /// CloudEvents 1.0 JSONL (application/cloudevents+json per line)
1717 CloudEvents,
1718}
1719
1720/// Actions for `tsafe audit` subcommand.
1721///
1722/// `Rotate` is a stub reserved for the audit-log rotation handler implemented
1723/// in `cmd_audit_cmd.rs` by a separate agent. The variant is declared here so
1724/// that `cli.rs` is the single source of truth for the CLI surface.
1725#[derive(Subcommand)]
1726#[allow(dead_code)]
1727pub enum AuditAction {
1728 /// Rotate (trim) the audit log to keep only the most-recent entries.
1729 ///
1730 /// Reserved — handler implemented in `cmd_audit_cmd.rs`.
1731 #[command(
1732 after_help = "Examples:\n tsafe audit rotate --keep 1000\n tsafe audit rotate --max-size-mb 10"
1733 )]
1734 Rotate {
1735 /// Maximum audit log size in megabytes before trimming.
1736 #[arg(long, default_value_t = 50)]
1737 max_size_mb: u64,
1738 /// Number of most-recent entries to keep after trimming.
1739 #[arg(long, default_value_t = 5000)]
1740 keep: u32,
1741 },
1742}
1743
1744#[derive(Clone, Copy, ValueEnum)]
1745pub enum CredentialHelperOperation {
1746 /// Install tsafe as the git credential helper in git config.
1747 Install,
1748 Get,
1749 Store,
1750 Erase,
1751}
1752
1753#[cfg(feature = "ssh")]
1754#[derive(Subcommand)]
1755pub enum SshAction {
1756 /// List SSH keys stored in the vault (tagged type=ssh or containing PRIVATE KEY).
1757 #[command(after_help = "Examples:\n tsafe ssh list")]
1758 List,
1759
1760 /// Extract the public key from a stored SSH private key.
1761 ///
1762 /// Prints the OpenSSH public key in authorized_keys format to stdout.
1763 #[command(
1764 name = "public-key",
1765 after_help = "Examples:\n tsafe ssh public-key my_ed25519_key\n tsafe ssh public-key SSH_ID_ED25519"
1766 )]
1767 PublicKey {
1768 /// Vault key name containing the SSH private key.
1769 key: String,
1770 },
1771
1772 /// Generate a new SSH key pair and store the private key in the vault.
1773 ///
1774 /// Uses a CSPRNG (no subprocess). The private key is stored encrypted in
1775 /// the vault; the public key is printed to stdout.
1776 #[command(
1777 after_help = "Examples:\n tsafe ssh generate my_deploy_key\n tsafe ssh generate my_rsa_key --type rsa --bits 4096\n tsafe ssh generate ci_key --comment \"ci@example.com\" --print"
1778 )]
1779 Generate {
1780 /// Vault key name to store the generated private key under.
1781 key: String,
1782 /// Key type: ed25519 (default, recommended) or rsa.
1783 #[arg(long, value_name = "TYPE", default_value = "ed25519")]
1784 r#type: SshKeyType,
1785 /// RSA key size in bits (only used with --type rsa; default 4096).
1786 #[arg(long, value_name = "BITS", default_value = "4096")]
1787 bits: u32,
1788 /// Comment to embed in the key (e.g. an email address).
1789 #[arg(long, value_name = "COMMENT")]
1790 comment: Option<String>,
1791 /// Print the public key to stdout after storing the private key.
1792 #[arg(long)]
1793 print: bool,
1794 },
1795
1796 /// Print an ~/.ssh/config snippet that points IdentityAgent at tsafe.
1797 ///
1798 /// Pipe or append the output to ~/.ssh/config.
1799 #[command(
1800 name = "config",
1801 after_help = "Examples:\n tsafe ssh config\n tsafe ssh config --host '*.corp.example'\n tsafe ssh config >> ~/.ssh/config"
1802 )]
1803 Config {
1804 /// SSH Host pattern (defaults to `*`).
1805 #[arg(long, value_name = "PATTERN")]
1806 host: Option<String>,
1807 },
1808
1809 /// Start a persistent SSH agent serving vault keys on a Unix socket.
1810 ///
1811 /// Keys are loaded once at startup and served for the configured TTL.
1812 /// On Windows this subcommand prints a clear error — Unix socket required.
1813 ///
1814 /// Eval idiom: eval $(tsafe ssh-agent)
1815 #[command(
1816 name = "agent",
1817 after_help = "Examples:\n eval $(tsafe ssh agent)\n tsafe ssh agent --ttl 4h\n tsafe ssh agent --sock /run/user/1000/tsafe.sock"
1818 )]
1819 Agent {
1820 /// How long loaded keys remain valid (e.g. 8h, 30m, 1h30m). Default 8h.
1821 #[arg(long, value_name = "DURATION")]
1822 ttl: Option<String>,
1823 /// Override the Unix socket path.
1824 #[arg(long, value_name = "PATH")]
1825 sock: Option<String>,
1826 },
1827}
1828
1829#[cfg(feature = "ssh")]
1830#[derive(Clone, Copy, ValueEnum)]
1831pub enum SshKeyType {
1832 Ed25519,
1833 Rsa,
1834}
1835
1836#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
1837pub enum PullOnError {
1838 /// Abort immediately on first source/provider error.
1839 FailAll,
1840 /// Skip failed source and continue remaining sources.
1841 SkipFailed,
1842 /// Continue and only warn on source/provider errors.
1843 WarnOnly,
1844}
1845
1846#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
1847pub enum PushOnError {
1848 /// Abort immediately on first source error.
1849 FailAll,
1850 /// Log the error, skip the failed source, and continue with the next.
1851 SkipFailed,
1852}
1853
1854#[derive(Subcommand)]
1855pub enum BrowserProfileAction {
1856 /// Add or update a domain → vault-profile mapping.
1857 ///
1858 /// DOMAIN may be an exact hostname or a wildcard pattern (e.g. *.corp.example).
1859 /// Defaults to the active --profile if --profile is omitted.
1860 ///
1861 #[command(
1862 after_help = "Examples:\n tsafe browser-profile add github.com\n tsafe browser-profile add corp.example --profile work"
1863 )]
1864 Add {
1865 /// Domain or pattern (e.g. github.com, *.corp.example).
1866 domain: String,
1867 /// Vault profile to use for this domain. Defaults to the active --profile.
1868 #[arg(long)]
1869 profile: Option<String>,
1870 },
1871 /// List all domain → vault-profile mappings.
1872 ///
1873 #[command(after_help = "Examples:\n tsafe browser-profile list")]
1874 List,
1875 /// Remove the mapping for DOMAIN.
1876 ///
1877 #[command(after_help = "Examples:\n tsafe browser-profile remove github.com")]
1878 Remove {
1879 /// Domain or pattern to remove.
1880 domain: String,
1881 },
1882}
1883
1884#[derive(Subcommand)]
1885pub enum BrowserNativeHostAction {
1886 /// Write the per-browser native-messaging-host manifest pointing at
1887 /// `tsafe-nativehost`. Per-user; never elevates. On Windows it writes
1888 /// Chromium-family HKCU registry keys for a 32-char extension ID, or a
1889 /// Firefox filesystem manifest under `%APPDATA%\Mozilla\NativeMessagingHosts\`
1890 /// for an email-style or UUID-style Firefox addon ID. On macOS/Linux it
1891 /// skips browsers that are not installed.
1892 ///
1893 /// `--extension-id` is REQUIRED — defaulting to a known development ID
1894 /// would let any installed extension with that ID talk to your vault.
1895 /// Chromium ID: 32-char string at `chrome://extensions` (Developer mode).
1896 /// Firefox ID: the `gecko.id` value from `browser_specific_settings` in
1897 /// the extension manifest (e.g. `tsafe@tsafe.dev`).
1898 Register {
1899 /// The extension ID to allow. Chromium-family: 32-character lowercase
1900 /// ID from `chrome://extensions`. Firefox: email-style addon ID
1901 /// (e.g. `tsafe@tsafe.dev`) or UUID in curly braces.
1902 #[arg(long)]
1903 extension_id: String,
1904 },
1905 /// Remove the per-browser manifest files (and HKCU keys on Windows).
1906 Unregister,
1907 /// Detect the native-host binary location and print the manifest paths that
1908 /// `register` would write for each installed browser — without writing
1909 /// anything. Use this when you do not yet know your extension ID:
1910 ///
1911 /// 1. Run `tsafe browser-native-host detect` to confirm the binary is
1912 /// found and see which browsers are detected.
1913 /// 2. Load the extension in your browser, find your extension ID at
1914 /// `chrome://extensions` (Developer mode), then run:
1915 /// tsafe browser-native-host register --extension-id ID
1916 ///
1917 /// On Windows, prints the HKCU registry keys and manifest directory that
1918 /// would be written; never modifies registry or filesystem.
1919 Detect,
1920}
1921
1922#[derive(Clone, Copy, ValueEnum)]
1923pub enum TotpAlgorithm {
1924 /// HMAC-SHA1 (default; most compatible)
1925 Sha1,
1926 /// HMAC-SHA256
1927 Sha256,
1928 /// HMAC-SHA512
1929 Sha512,
1930}
1931
1932impl TotpAlgorithm {
1933 pub fn as_uri_str(self) -> &'static str {
1934 match self {
1935 Self::Sha1 => "SHA1",
1936 Self::Sha256 => "SHA256",
1937 Self::Sha512 => "SHA512",
1938 }
1939 }
1940}
1941
1942#[derive(Subcommand)]
1943pub enum TotpAction {
1944 /// Store a TOTP seed for KEY. Accepts a raw base32 secret or an otpauth:// URI.
1945 ///
1946 #[command(
1947 after_help = "Examples:\n tsafe totp add GITHUB_2FA JBSWY3DPEHPK3PXP\n tsafe totp add CORP_2FA JBSWY3DPEHPK3PXP --digits 8 --period 60\n tsafe totp add CORP_2FA JBSWY3DPEHPK3PXP --algorithm sha256"
1948 )]
1949 Add {
1950 /// Vault key name to store under.
1951 key: String,
1952 /// Base32-encoded TOTP secret or full otpauth:// URI.
1953 secret: String,
1954 /// HMAC algorithm to use (default: sha1, most widely supported).
1955 #[arg(long, default_value = "sha1")]
1956 algorithm: TotpAlgorithm,
1957 /// Number of digits in each OTP code (default: 6; some services use 8).
1958 #[arg(long, default_value_t = 6)]
1959 digits: u32,
1960 /// Time step in seconds (default: 30; some services use 60).
1961 #[arg(long, default_value_t = 30)]
1962 period: u64,
1963 },
1964 /// Print the current TOTP code + seconds remaining.
1965 ///
1966 #[command(after_help = "Examples:\n tsafe totp get GITHUB_2FA")]
1967 Get {
1968 /// Vault key name where the TOTP seed is stored.
1969 key: String,
1970 },
1971}
1972
1973#[derive(Subcommand)]
1974pub enum NsAction {
1975 /// List all namespaces present in the vault (inferred from key prefixes).
1976 List,
1977 /// Copy every secret under FROM/ to TO/ (same suffix). Source keys stay.
1978 ///
1979 #[command(after_help = "Examples:\n tsafe ns copy prod staging")]
1980 Copy {
1981 /// Namespace prefix to read from (keys must be `FROM/...`).
1982 from: String,
1983 /// Namespace prefix to write to (`TO/<same-suffix>`).
1984 to: String,
1985 /// Overwrite destination keys if they already exist.
1986 #[arg(long)]
1987 force: bool,
1988 },
1989 /// Move every secret under FROM/ to TO/ (same suffix). Source keys are removed.
1990 ///
1991 #[command(after_help = "Examples:\n tsafe ns move prod staging")]
1992 Move {
1993 /// Namespace prefix to read from and delete after rename.
1994 from: String,
1995 /// Namespace prefix to write to (`TO/<same-suffix>`).
1996 to: String,
1997 /// Overwrite destination keys if they already exist.
1998 #[arg(long)]
1999 force: bool,
2000 },
2001}
2002
2003/// Repo-local `.tsafe/tooling` inventory commands.
2004#[derive(Subcommand)]
2005pub enum ToolingAction {
2006 /// Create `.tsafe/tooling/keys.ini`, policy, and README scaffolding.
2007 Init {
2008 /// Repository root. Defaults to the current directory.
2009 #[arg(long, default_value = ".")]
2010 root: std::path::PathBuf,
2011 /// Vault namespace prefix to use for this repo, e.g. databricks/athn_dev/.
2012 #[arg(long)]
2013 namespace: Option<String>,
2014 /// Replace existing scaffold files.
2015 #[arg(long)]
2016 force: bool,
2017 },
2018 /// Validate `.tsafe/tooling/keys.ini`.
2019 Check {
2020 /// Repository root. Defaults to the current directory.
2021 #[arg(long, default_value = ".")]
2022 root: std::path::PathBuf,
2023 /// Emit machine-readable JSON output.
2024 #[arg(long)]
2025 json: bool,
2026 },
2027 /// Suggest a missing secret slot and optionally add it to `keys.ini`.
2028 Suggest {
2029 /// Repository root. Defaults to the current directory.
2030 #[arg(long, default_value = ".")]
2031 root: std::path::PathBuf,
2032 /// Vault namespace prefix for the suggested key.
2033 #[arg(long)]
2034 namespace: String,
2035 /// Inventory section to append under.
2036 #[arg(long)]
2037 section: Option<String>,
2038 /// Key name. Relative keys are prefixed with `--namespace`.
2039 #[arg(long)]
2040 key: String,
2041 /// Human purpose for this secret slot.
2042 #[arg(long)]
2043 purpose: String,
2044 /// Tool, pipeline, or runbook that consumes this secret.
2045 #[arg(long)]
2046 consumer: String,
2047 /// Rotation expectation, e.g. static, manual, 365d KV policy.
2048 #[arg(long)]
2049 rotation: String,
2050 /// Write the suggested slot to `keys.ini`. Without this, preview only.
2051 #[arg(long)]
2052 apply: bool,
2053 /// Reason or task context for the receipt.
2054 #[arg(long, default_value = "operator suggestion")]
2055 reason: String,
2056 },
2057}
2058
2059/// `tsafe mcp ...` subcommands per design §5.2 / ADR-006. Gated behind the
2060/// `mcp` feature.
2061#[derive(Subcommand)]
2062#[cfg(feature = "mcp")]
2063pub enum McpCliAction {
2064 /// Start the MCP stdio JSON-RPC server bound to the current profile.
2065 ///
2066 /// Hosts invoke this directly via the entry written by `tsafe mcp install`.
2067 /// Running it interactively is mainly useful for diagnostics.
2068 Serve {
2069 /// Comma-separated glob list of vault keys this server may expose.
2070 #[arg(long, value_name = "GLOBS")]
2071 allowed_keys: Option<String>,
2072 /// Comma-separated glob list of vault keys to exclude even when
2073 /// matched by `--allowed-keys` or the bound contract.
2074 #[arg(long, value_name = "GLOBS")]
2075 denied_keys: Option<String>,
2076 /// Bind an authority contract for stricter scope and per-key intent.
2077 #[arg(long, value_name = "NAME")]
2078 contract: Option<String>,
2079 /// Bind this server to a single repository/work directory.
2080 #[arg(long, value_name = "PATH")]
2081 workdir: Option<String>,
2082 /// Enable the opt-in `tsafe_reveal` tool. Off by default.
2083 #[arg(long)]
2084 allow_reveal: bool,
2085 /// Label written to the audit `source` field (e.g. `mcp:claude:1234`).
2086 #[arg(long, value_name = "LABEL")]
2087 audit_source: Option<String>,
2088 },
2089 /// Write an MCP server entry into the host's config file.
2090 ///
2091 /// Refuses to write without an explicit `--allowed-keys` or `--contract`.
2092 Install {
2093 /// Host name. One of: claude, cursor, continue, windsurf, codex.
2094 host: String,
2095 /// Server entry name. Defaults to `tsafe-<profile>`.
2096 #[arg(long)]
2097 name: Option<String>,
2098 /// Write to the global per-user config (default when no scope flag is set).
2099 #[arg(long)]
2100 global: bool,
2101 /// Write to a project-local config under the given directory.
2102 #[arg(long, value_name = "DIR")]
2103 project: Option<String>,
2104 /// Print the proposed file change without modifying disk.
2105 #[arg(long)]
2106 dry_run: bool,
2107 /// Comma-separated glob list of vault keys the server may expose.
2108 #[arg(long, value_name = "GLOBS")]
2109 allowed_keys: Option<String>,
2110 /// Comma-separated glob list of vault keys to exclude.
2111 #[arg(long, value_name = "GLOBS")]
2112 denied_keys: Option<String>,
2113 /// Bind an authority contract for stricter scope.
2114 #[arg(long, value_name = "NAME")]
2115 contract: Option<String>,
2116 /// Bind the installed server entry to a single repository/work directory.
2117 #[arg(long, value_name = "PATH")]
2118 workdir: Option<String>,
2119 /// Enable the opt-in `tsafe_reveal` tool on the installed entry.
2120 #[arg(long)]
2121 allow_reveal: bool,
2122 },
2123 /// Emit a safe MCP config entry for a host without embedding secret values.
2124 Config {
2125 /// Host name. Currently: codex.
2126 host: String,
2127 /// Server entry name. Defaults to `tsafe-<profile>`.
2128 #[arg(long)]
2129 name: Option<String>,
2130 /// Bind an authority contract for command execution.
2131 #[arg(long, value_name = "NAME")]
2132 contract: Option<String>,
2133 /// Bind this server entry to a single repository/work directory.
2134 #[arg(long, value_name = "PATH")]
2135 workdir: Option<String>,
2136 },
2137 /// Remove an MCP server entry from the host's config file.
2138 Uninstall {
2139 /// Host name. One of: claude, cursor, continue, windsurf, codex.
2140 host: String,
2141 /// Server entry name. Defaults to `tsafe-<profile>`.
2142 #[arg(long)]
2143 name: Option<String>,
2144 },
2145 /// Render model-safe remediation for a bound MCP denial code.
2146 Doctor {
2147 /// Bound MCP authority denial code, e.g. missing_contract.
2148 #[arg(long, value_name = "DENIAL_CODE")]
2149 code: String,
2150 /// Bound authority contract name.
2151 #[arg(long, value_name = "NAME")]
2152 contract: String,
2153 /// Bound repository or work directory.
2154 #[arg(long, value_name = "PATH")]
2155 workdir: String,
2156 /// Existing receipt id if the denial already produced one.
2157 #[arg(long, value_name = "ID")]
2158 receipt_id: Option<String>,
2159 /// Emit structured JSON instead of the human summary.
2160 #[arg(long)]
2161 json: bool,
2162 },
2163 /// Print binary version + resolved scope. Lightweight diagnostic;
2164 /// does not speak JSON-RPC.
2165 Status,
2166}
2167
2168#[cfg(feature = "mcp")]
2169pub enum McpAction {
2170 Serve {
2171 allowed_keys: Option<String>,
2172 denied_keys: Option<String>,
2173 contract: Option<String>,
2174 workdir: Option<String>,
2175 allow_reveal: bool,
2176 audit_source: Option<String>,
2177 },
2178 Install {
2179 host: String,
2180 name: Option<String>,
2181 global: bool,
2182 project: Option<String>,
2183 dry_run: bool,
2184 allowed_keys: Option<String>,
2185 denied_keys: Option<String>,
2186 contract: Option<String>,
2187 workdir: Option<String>,
2188 allow_reveal: bool,
2189 },
2190 Config {
2191 host: String,
2192 name: Option<String>,
2193 contract: Option<String>,
2194 workdir: Option<String>,
2195 },
2196 Uninstall {
2197 host: String,
2198 name: Option<String>,
2199 },
2200 Status,
2201}
2202
2203#[derive(Subcommand)]
2204pub enum AgentAction {
2205 /// Prompt for approval + vault password, then start the background agent daemon.
2206 ///
2207 /// Prints a shell export line to stdout:
2208 /// $env:TSAFE_AGENT_SOCK = "..." # PowerShell
2209 /// export TSAFE_AGENT_SOCK="..." # bash/zsh
2210 ///
2211 /// Copy-paste or eval this line in the calling shell/process that needs access.
2212 Unlock {
2213 /// Idle TTL — how long the agent stays alive without a vault request.
2214 /// Resets on each vault access. Common values include 15m, 1h, and 4h. Default: 30m.
2215 #[arg(long, default_value = "30m")]
2216 ttl: String,
2217 /// Absolute TTL — hard cap regardless of activity. Default: 8h.
2218 /// Must be >= idle TTL. Common values include 8h, 12h, and 24h.
2219 #[arg(long, default_value = "8h")]
2220 absolute_ttl: String,
2221 },
2222 /// Immediately revoke the current session and stop the agent.
2223 Lock,
2224 /// Show whether the current agent socket is reachable.
2225 ///
2226 /// Use `--json` for a stable machine-readable output (ADR-029). Consumers such
2227 /// as the VS Code extension and tray agent depend on this flag. The schema
2228 /// `version` field must be checked before reading any other field.
2229 #[command(
2230 after_help = "Examples:\n tsafe agent status\n tsafe agent status --json\n tsafe --profile prod agent status --json"
2231 )]
2232 Status {
2233 /// Emit a stable JSON object to stdout (schema version \"1\").
2234 /// See docs/decisions/agent-status-json-contract.md (ADR-029) for the full schema.
2235 #[arg(long)]
2236 json: bool,
2237 },
2238}
2239
2240#[derive(Subcommand)]
2241pub enum TeamAction {
2242 /// Create a new team vault encrypted to your age identity.
2243 ///
2244 #[command(after_help = "Examples:\n tsafe team init --identity ~/.age/key.txt")]
2245 Init {
2246 /// Path to your age identity file (contains AGE-SECRET-KEY-1...).
2247 #[arg(long)]
2248 identity: String,
2249 },
2250 /// Add a team member by their age public key.
2251 ///
2252 #[command(after_help = "Examples:\n tsafe team add-member age1qyqszqgp...")]
2253 AddMember {
2254 /// age X25519 public key (starts with "age1...").
2255 public_key: String,
2256 /// Path to your age identity file (for re-wrapping the DEK).
2257 #[arg(long)]
2258 identity: String,
2259 },
2260 /// Remove a team member and re-encrypt all secrets with a new key.
2261 ///
2262 #[command(after_help = "Examples:\n tsafe team remove-member age1qyqszqgp...")]
2263 RemoveMember {
2264 /// age X25519 public key to remove.
2265 public_key: String,
2266 /// Path to your age identity file (for re-keying).
2267 #[arg(long)]
2268 identity: String,
2269 },
2270 /// List current team members (public keys).
2271 Members,
2272 /// Generate a new age identity (keypair) and print the JSON block to add
2273 /// to `.tsafe/team-keys.json` via a PR.
2274 ///
2275 /// The private key is saved to `~/.age/tsafe-<profile>.txt`.
2276 /// The public key is printed as a ready-to-paste JSON entry.
2277 ///
2278 #[command(
2279 after_help = "Examples:\n tsafe team keygen\n tsafe team keygen --name \"Alice Smith\" --email alice@corp.example"
2280 )]
2281 Keygen {
2282 /// Your display name for the team-keys entry.
2283 #[arg(long)]
2284 name: Option<String>,
2285 /// Your email for the team-keys entry.
2286 #[arg(long)]
2287 email: Option<String>,
2288 },
2289 /// Print your age public key from an existing identity file.
2290 ///
2291 #[command(
2292 after_help = "Examples:\n tsafe team show-key\n tsafe team show-key --identity ~/.age/key.txt"
2293 )]
2294 ShowKey {
2295 /// Path to identity file (default: `~/.age/tsafe-<profile>.txt`).
2296 #[arg(long)]
2297 identity: Option<String>,
2298 },
2299 /// Reconcile vault recipients with `.tsafe/team-keys.json`.
2300 ///
2301 /// Adds any new members found in the keys file. Removes members no longer
2302 /// listed. Re-keys the vault if the member list changed.
2303 ///
2304 #[command(after_help = "Examples:\n tsafe team sync-keys --identity ~/.age/key.txt")]
2305 SyncKeys {
2306 /// Path to your age identity file (required for re-wrapping the DEK).
2307 #[arg(long)]
2308 identity: String,
2309 /// Path to team-keys.json (auto-detected if omitted).
2310 #[arg(long)]
2311 keys_file: Option<String>,
2312 },
2313}
2314
2315#[derive(Subcommand)]
2316pub enum BiometricAction {
2317 /// Store the vault password in the OS credential store (same as accepting quick unlock after `tsafe init`).
2318 Enable,
2319 /// Remove the vault password from the OS credential store.
2320 Disable,
2321 /// Check if biometric/keyring unlock is configured for this profile.
2322 Status,
2323 /// Re-enroll biometric/keyring unlock after a stale-credential error.
2324 ///
2325 /// Use this when `tsafe` reports "stale biometric credential" — for example after
2326 /// rotating the vault password (`tsafe rotate`) or after enrolling a new fingerprint.
2327 /// This is equivalent to `tsafe biometric disable` followed by `tsafe biometric enable`
2328 /// but makes the recovery intent explicit and prints a confirmation.
2329 #[command(name = "re-enroll")]
2330 ReEnroll,
2331}
2332
2333#[cfg(test)]
2334mod tests {
2335 use super::*;
2336 use clap::CommandFactory;
2337
2338 fn has_subcommand(command: &clap::Command, name: &str) -> bool {
2339 command
2340 .get_subcommands()
2341 .any(|subcommand| subcommand.get_name() == name)
2342 }
2343
2344 #[test]
2345 fn root_command_visibility_matches_feature_gates() {
2346 let command = Cli::command();
2347
2348 assert!(has_subcommand(&command, "tooling"));
2349 assert_eq!(has_subcommand(&command, "ui"), cfg!(feature = "tui"));
2350 assert_eq!(
2351 has_subcommand(&command, "kv-pull"),
2352 cfg!(feature = "akv-pull")
2353 );
2354 assert_eq!(
2355 has_subcommand(&command, "aws-pull"),
2356 cfg!(feature = "cloud-pull-aws")
2357 );
2358 assert_eq!(
2359 has_subcommand(&command, "gcp-pull"),
2360 cfg!(feature = "cloud-pull-gcp")
2361 );
2362 assert_eq!(
2363 has_subcommand(&command, "gcp-push"),
2364 cfg!(feature = "cloud-pull-gcp")
2365 );
2366 assert_eq!(
2367 has_subcommand(&command, "ssm-pull"),
2368 cfg!(feature = "cloud-pull-aws")
2369 );
2370 assert_eq!(
2371 has_subcommand(&command, "aws-push"),
2372 cfg!(feature = "cloud-pull-aws")
2373 );
2374 assert_eq!(
2375 has_subcommand(&command, "ssm-push"),
2376 cfg!(feature = "cloud-pull-aws")
2377 );
2378 assert_eq!(
2379 has_subcommand(&command, "vault-pull"),
2380 cfg!(feature = "cloud-pull-vault")
2381 );
2382 assert_eq!(
2383 has_subcommand(&command, "op-pull"),
2384 cfg!(feature = "cloud-pull-1password")
2385 );
2386 assert_eq!(
2387 has_subcommand(&command, "pull"),
2388 cfg!(feature = "multi-pull")
2389 );
2390 assert_eq!(
2391 has_subcommand(&command, "share-once"),
2392 cfg!(feature = "ots-sharing")
2393 );
2394 assert_eq!(
2395 has_subcommand(&command, "receive-once"),
2396 cfg!(feature = "ots-sharing")
2397 );
2398 assert_eq!(
2399 has_subcommand(&command, "browser-profile"),
2400 cfg!(feature = "browser")
2401 );
2402 assert_eq!(
2403 has_subcommand(&command, "browser-native-host"),
2404 cfg!(feature = "nativehost")
2405 );
2406 assert_eq!(has_subcommand(&command, "ssh-add"), cfg!(feature = "ssh"));
2407 assert_eq!(
2408 has_subcommand(&command, "ssh-import"),
2409 cfg!(feature = "ssh")
2410 );
2411 assert_eq!(
2412 has_subcommand(&command, "plugin"),
2413 cfg!(feature = "plugins")
2414 );
2415 assert_eq!(
2416 has_subcommand(&command, "hook-install"),
2417 cfg!(feature = "git-helpers")
2418 );
2419 assert_eq!(
2420 has_subcommand(&command, "git"),
2421 cfg!(feature = "git-helpers")
2422 );
2423 assert_eq!(
2424 has_subcommand(&command, "sync"),
2425 cfg!(feature = "git-helpers")
2426 );
2427 assert_eq!(
2428 has_subcommand(&command, "credential-helper"),
2429 cfg!(feature = "git-helpers")
2430 );
2431 assert_eq!(
2432 has_subcommand(&command, "biometric"),
2433 cfg!(feature = "biometric")
2434 );
2435 assert_eq!(
2436 has_subcommand(&command, "team"),
2437 cfg!(feature = "team-core")
2438 );
2439 assert_eq!(has_subcommand(&command, "agent"), cfg!(feature = "agent"));
2440 }
2441
2442 #[test]
2443 fn tooling_suggest_parses_inventory_request() {
2444 let cli = Cli::try_parse_from([
2445 "tsafe",
2446 "tooling",
2447 "suggest",
2448 "--root",
2449 ".",
2450 "--namespace",
2451 "databricks/athn_dev/",
2452 "--section",
2453 "ci-cd-spn",
2454 "--key",
2455 "ci_secret",
2456 "--purpose",
2457 "SPN secret",
2458 "--consumer",
2459 "ADO service connection athn-dev-sc",
2460 "--rotation",
2461 "365d KV policy",
2462 "--reason",
2463 "terraform deployment",
2464 "--apply",
2465 ])
2466 .unwrap();
2467
2468 match cli.command {
2469 Commands::Tooling {
2470 action:
2471 ToolingAction::Suggest {
2472 root,
2473 namespace,
2474 section,
2475 key,
2476 purpose,
2477 consumer,
2478 rotation,
2479 apply,
2480 reason,
2481 },
2482 } => {
2483 assert_eq!(root, std::path::PathBuf::from("."));
2484 assert_eq!(namespace, "databricks/athn_dev/");
2485 assert_eq!(section.as_deref(), Some("ci-cd-spn"));
2486 assert_eq!(key, "ci_secret");
2487 assert_eq!(purpose, "SPN secret");
2488 assert_eq!(consumer, "ADO service connection athn-dev-sc");
2489 assert_eq!(rotation, "365d KV policy");
2490 assert_eq!(reason, "terraform deployment");
2491 assert!(apply);
2492 }
2493 _ => panic!("expected tooling suggest action"),
2494 }
2495 }
2496
2497 #[test]
2498 #[cfg(feature = "mcp")]
2499 fn mcp_config_parses_bound_contract_flags() {
2500 let cli = Cli::try_parse_from([
2501 "tsafe",
2502 "mcp",
2503 "config",
2504 "codex",
2505 "--name",
2506 "tsafe-cordance",
2507 "--profile",
2508 "ops",
2509 "--contract",
2510 "cordance-diagnostics",
2511 "--workdir",
2512 "C:\\Users\\0ryant\\prj\\cordance",
2513 ])
2514 .unwrap();
2515
2516 assert_eq!(cli.profile.as_deref(), Some("ops"));
2517 match cli.command {
2518 Commands::Mcp {
2519 action:
2520 McpCliAction::Config {
2521 host,
2522 name,
2523 contract,
2524 workdir,
2525 },
2526 } => {
2527 assert_eq!(host, "codex");
2528 assert_eq!(name.as_deref(), Some("tsafe-cordance"));
2529 assert_eq!(contract.as_deref(), Some("cordance-diagnostics"));
2530 assert_eq!(workdir.as_deref(), Some("C:\\Users\\0ryant\\prj\\cordance"));
2531 }
2532 _ => panic!("expected mcp config action"),
2533 }
2534 }
2535
2536 #[test]
2537 #[cfg(feature = "mcp")]
2538 fn mcp_serve_parses_profile_contract_and_workdir_after_action() {
2539 let cli = Cli::try_parse_from([
2540 "tsafe",
2541 "mcp",
2542 "serve",
2543 "--profile",
2544 "ops",
2545 "--contract",
2546 "cordance-diagnostics",
2547 "--workdir",
2548 "C:\\Users\\0ryant\\prj\\cordance",
2549 ])
2550 .unwrap();
2551
2552 assert_eq!(cli.profile.as_deref(), Some("ops"));
2553 match cli.command {
2554 Commands::Mcp {
2555 action:
2556 McpCliAction::Serve {
2557 contract, workdir, ..
2558 },
2559 } => {
2560 assert_eq!(contract.as_deref(), Some("cordance-diagnostics"));
2561 assert_eq!(workdir.as_deref(), Some("C:\\Users\\0ryant\\prj\\cordance"));
2562 }
2563 _ => panic!("expected mcp serve action"),
2564 }
2565 }
2566
2567 #[test]
2568 #[cfg(feature = "mcp")]
2569 fn mcp_doctor_parses_denial_contract_workdir_and_json() {
2570 let cli = Cli::try_parse_from([
2571 "tsafe",
2572 "mcp",
2573 "doctor",
2574 "--profile",
2575 "ops",
2576 "--code",
2577 "missing_contract",
2578 "--contract",
2579 "cordance-diagnostics",
2580 "--workdir",
2581 "C:\\Users\\0ryant\\prj\\cordance",
2582 "--json",
2583 ])
2584 .unwrap();
2585
2586 assert_eq!(cli.profile.as_deref(), Some("ops"));
2587 match cli.command {
2588 Commands::Mcp {
2589 action:
2590 McpCliAction::Doctor {
2591 code,
2592 contract,
2593 workdir,
2594 receipt_id,
2595 json,
2596 },
2597 } => {
2598 assert_eq!(code, "missing_contract");
2599 assert_eq!(contract, "cordance-diagnostics");
2600 assert_eq!(workdir, "C:\\Users\\0ryant\\prj\\cordance");
2601 assert_eq!(receipt_id, None);
2602 assert!(json);
2603 }
2604 _ => panic!("expected mcp doctor action"),
2605 }
2606 }
2607
2608 #[test]
2609 #[cfg(feature = "mcp")]
2610 fn mcp_help_documents_bound_serve_and_codex_config_forms() {
2611 let mut command = Cli::command();
2612 let mcp = command.find_subcommand_mut("mcp").unwrap();
2613 let mut help = Vec::new();
2614 mcp.write_long_help(&mut help).unwrap();
2615 let help = String::from_utf8(help).unwrap();
2616
2617 assert!(help
2618 .contains("tsafe mcp serve --profile ops --contract cordance-diagnostics --workdir ."));
2619 assert!(help.contains(
2620 "tsafe mcp config codex --name tsafe-cordance --profile ops --contract cordance-diagnostics --workdir ."
2621 ));
2622 assert!(help.contains(
2623 "tsafe mcp doctor --code missing_contract --contract cordance-diagnostics --workdir . --json"
2624 ));
2625 }
2626}
2627
2628#[derive(Subcommand)]
2629pub enum PolicyAction {
2630 /// Set a rotation policy on a secret.
2631 ///
2632 #[command(after_help = "Examples:\n tsafe policy set DB_PASSWORD --rotate-every 90d")]
2633 Set {
2634 /// Secret key.
2635 key: String,
2636 /// Rotation interval (e.g. 90d, 30d, 7d).
2637 #[arg(long)]
2638 rotate_every: String,
2639 },
2640 /// Remove the rotation policy from a secret.
2641 ///
2642 #[command(after_help = "Examples:\n tsafe policy remove DB_PASSWORD")]
2643 Remove {
2644 /// Secret key.
2645 key: String,
2646 },
2647}
2648
2649/// Attestation subcommands — Phases 3 and 4 of the algol→tsafe migration.
2650///
2651/// - `scan` (Phase 3) — secret + env-authority scanner.
2652/// - `run` (Phase 4) — env-injection enforcement harness; emits
2653/// RunEvidence + CloudEvents audit trail.
2654#[derive(Subcommand)]
2655pub enum AttestAction {
2656 /// Scan a repo for committed secrets and env-authority signals.
2657 ///
2658 /// Defaults to scanning the current directory. The scanner is the
2659 /// Phase 3 port of the algol Phase 2.1 scanner; see crate docs for
2660 /// the full provenance trail.
2661 ///
2662 /// Scanner P/R on synthetic N=100 corpus: 1.000 / 1.000. Real-world
2663 /// rates may differ. See
2664 /// `ecosystem-catalog/portfolio-algol-tsafe-phase2-1-precision-recovery-2026-05-21.md`
2665 /// for the verdict.
2666 #[command(
2667 after_help = "Examples:\n tsafe attest scan\n tsafe attest scan ./repo\n tsafe attest scan --format json -o scan.json\n tsafe attest scan --strict # exit code 2 if any secret-class finding\n\nHonest disclosure: synthetic N=100 corpus (Phase 2.1); real repos may differ. Wire format: `tsafe.scan.v1` (BLAKE3 fingerprints) since Phase 4."
2668 )]
2669 Scan {
2670 /// Repo path to scan (defaults to current directory).
2671 path: Option<std::path::PathBuf>,
2672 /// Exit with code 2 if any secret-class finding is present.
2673 ///
2674 /// Secret-class kinds: `ENV_FILE`, `HARDCODED_SECRET`, `PRIVATE_KEY`.
2675 /// `SECRET_PLACEHOLDER` (Phase 2.1 — placeholder/comment context)
2676 /// is NOT counted as a secret finding.
2677 #[arg(long)]
2678 strict: bool,
2679 /// Additional paths to scan (repeatable). Findings from all paths
2680 /// are merged into a single report.
2681 #[arg(long = "extra-paths", value_name = "PATH", num_args = 0..)]
2682 extra_paths: Vec<std::path::PathBuf>,
2683 /// Output format.
2684 #[arg(long, default_value = "text")]
2685 format: AttestScanFormat,
2686 /// Write the report to this file (otherwise printed to stdout).
2687 #[arg(short, long, value_name = "FILE")]
2688 output: Option<std::path::PathBuf>,
2689 },
2690 /// Run a command under env-injection enforcement.
2691 ///
2692 /// Loads an `AttestContract`, strips the parent env, injects declared
2693 /// variables from the configured sources, spawns the command, and
2694 /// emits a `RunEvidence` artifact + CloudEvents audit-trail entry.
2695 ///
2696 /// Phase 4 wire formats: `tsafe.run.v1` RunEvidence,
2697 /// `tsafe.audit_event.v1` audit events, BLAKE3 fingerprints.
2698 /// Legacy `algol.*` schemas + SHA-256 hashes are accepted on parse
2699 /// during the v1.x compat window.
2700 #[command(
2701 after_help = "Examples:\n tsafe attest run -- echo hello\n tsafe attest run --contract tsafe.contract.yaml -- npm test\n tsafe attest run --emit-run-evidence run.json --audit-trail audit.ndjson -- true\n tsafe attest run --no-sign -- npm test # opt out of Ed25519 attestation\n\nHonest disclosure: Phase 4 lift from algol/src/enforce.rs @ 6956cfd. Linux + macOS first; Windows is experimental (env_clear() is portable, but PATH semantics differ — see ec Phase 0 audit §3 Option (c)).\n\nPhase 5 (this version) adds Ed25519 authorship signatures by default: the producer's keyring entry (or an auto-generated one with stderr warning) signs the emitted RunEvidence under the `tsafe.run_evidence.v1` domain tag. Pubkey trust is TOFU; verify operator-pinned pubkeys out of band (`tsafe attest verify --pubkey <key>`).\n\nThe child process inherits ONLY the explicitly declared env vars + the safe baseline. There is no escape hatch — if a required env is unresolvable, the run aborts before spawn."
2702 )]
2703 Run {
2704 /// Path to the `AttestContract` to enforce. Defaults to
2705 /// `tsafe.contract.yaml` in the current directory.
2706 #[arg(long, value_name = "PATH")]
2707 contract: Option<std::path::PathBuf>,
2708 /// Path to write the `RunEvidence` artifact. Defaults to
2709 /// `tsafe-run.json` in the current directory.
2710 #[arg(long = "emit-run-evidence", value_name = "PATH")]
2711 emit_run_evidence: Option<std::path::PathBuf>,
2712 /// Path to the audit-trail NDJSON log. Each line is a CloudEvents
2713 /// envelope. Defaults to `tsafe-audit-events.ndjson`.
2714 #[arg(long = "audit-trail", value_name = "PATH")]
2715 audit_trail: Option<std::path::PathBuf>,
2716 /// Allow the supplied command to differ from the contract's
2717 /// `command` field. Disabled by default; useful only for testing.
2718 #[arg(long = "allow-command-override")]
2719 allow_command_override: bool,
2720 /// Phase 5: sign the emitted `RunEvidence` with the per-profile
2721 /// Ed25519 keyring entry. Default is ON — if no key is
2722 /// provisioned, one is auto-generated on first use with a stderr
2723 /// warning (`tsafe attest key generate` is the explicit form).
2724 /// Use `--no-sign` to opt out.
2725 #[arg(long = "sign-run-evidence", overrides_with = "no_sign")]
2726 sign_run_evidence: bool,
2727 /// Phase 5: explicitly disable Ed25519 signing of the emitted
2728 /// `RunEvidence`. Overrides the default-on `--sign-run-evidence`
2729 /// behaviour.
2730 #[arg(long = "no-sign", overrides_with = "sign_run_evidence")]
2731 no_sign: bool,
2732 /// Command to execute under enforcement. Pass after `--`.
2733 #[arg(last = true, required = true)]
2734 command: Vec<String>,
2735 },
2736 /// Verify the Ed25519 signature on a `RunEvidence` artifact.
2737 ///
2738 /// Phase 5 (this version) emits artifacts with a `signature` field
2739 /// carrying an Ed25519 signature over the canonical encoding of every
2740 /// other field. `verify` re-derives the canonical bytes, prepends
2741 /// the `tsafe.run_evidence.v1` domain tag, and checks the signature.
2742 ///
2743 /// Without `--pubkey`, the embedded pubkey on the artifact is used
2744 /// (TOFU); a stderr warning is emitted reminding the operator to pin
2745 /// the pubkey out of band. With `--pubkey <base64url>`, the supplied
2746 /// key takes precedence.
2747 ///
2748 /// Exit codes:
2749 /// - `0` — signature is valid
2750 /// - `5` — artifact has no `signature` field
2751 /// - `6` — signature verification failed (tampered or wrong key)
2752 /// - other — internal error
2753 #[command(
2754 after_help = "Examples:\n tsafe attest verify ./tsafe-run.json\n tsafe attest verify ./tsafe-run.json --pubkey AAAAC3NzaC1lZDI1NTE5...\n\nHonest disclosure: signatures bind authorship to the artifact's content at sign time. They do NOT establish trust in the producer — that requires out-of-band pubkey verification. The default TOFU path is convenience-tier, not security-tier."
2755 )]
2756 Verify {
2757 /// Path to the `RunEvidence` JSON artifact to verify.
2758 evidence: std::path::PathBuf,
2759 /// Operator-supplied verifying key (base64url-encoded, no
2760 /// padding, 32 bytes after decoding). If omitted, the pubkey
2761 /// embedded in the artifact is used (TOFU; see honest
2762 /// disclosure on `--help`).
2763 #[arg(long, value_name = "BASE64URL")]
2764 pubkey: Option<String>,
2765 },
2766 /// Manage the `tsafe-attest` Ed25519 signing key for a profile.
2767 ///
2768 /// The key lives in the OS credential store under the per-profile
2769 /// `tsafe-attest-signing-key` account name. `tsafe attest run`
2770 /// signs emitted `RunEvidence` artifacts with this key by default;
2771 /// `tsafe attest verify` can use the embedded pubkey or an
2772 /// operator-supplied one.
2773 Key {
2774 #[command(subcommand)]
2775 action: AttestKeyAction,
2776 },
2777}
2778
2779/// Subcommands of `tsafe attest key` — operator-facing keyring
2780/// management for the Phase 5 signing flow.
2781#[derive(Subcommand)]
2782pub enum AttestKeyAction {
2783 /// Generate a fresh Ed25519 keypair and store the signing half in
2784 /// the OS credential store under the active profile.
2785 ///
2786 /// Refuses to overwrite an existing entry unless `--force` is
2787 /// passed. Prints the corresponding base64url-encoded pubkey to
2788 /// stdout so operators can pin it out of band.
2789 Generate {
2790 /// Overwrite any existing signing key for this profile.
2791 #[arg(long)]
2792 force: bool,
2793 },
2794 /// Print the base64url-encoded verifying key (pubkey) for the
2795 /// active profile.
2796 ///
2797 /// Fails non-zero if no signing key is provisioned for this
2798 /// profile.
2799 Pubkey,
2800 /// Remove the signing key for the active profile from the OS
2801 /// credential store. Subsequent `tsafe attest run` calls auto-
2802 /// generate a new key on first use (with stderr warning) unless
2803 /// `--no-sign` is passed.
2804 Remove,
2805}
2806
2807/// Output format for `tsafe attest scan`.
2808#[derive(Clone, Copy, Debug, ValueEnum)]
2809pub enum AttestScanFormat {
2810 /// Pretty-printed JSON `ScanReport` artifact.
2811 Json,
2812 /// Human-readable markdown summary + finding table.
2813 Markdown,
2814 /// Compact text summary (default).
2815 Text,
2816}