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