Skip to main content

tsafe_cli/
lib.rs

1//! Library backing the `tsafe` command-line binary.
2//!
3//! The CLI is the operator entry point for local encrypted vault workflows,
4//! safe command execution, authority contracts, audits, and companion process
5//! launchers. `tsafe exec --contract <name> -- <command>` runs a command under
6//! a named authority contract; `tsafe mcp serve --profile <profile> --contract
7//! <contract> --workdir <repo>` starts the bound MCP normal form by resolving
8//! the companion `tsafe-mcp` binary.
9//!
10//! In Agent Authority Firewall usage, the CLI keeps authority selection at
11//! startup: one profile, one contract, and one workdir per server process.
12//! Separate host entries such as `tsafe-cordance` and `tsafe-cortex` should use
13//! separate server instances rather than request-time profile or contract
14//! switching.
15
16pub mod cli;
17pub mod explain;
18pub mod manpages;
19pub mod providers;
20#[cfg(feature = "cloud-pull-aws")]
21pub use providers::aws as tsafe_aws;
22#[cfg(feature = "cloud-pull-gcp")]
23pub use providers::gcp as tsafe_gcp;
24#[cfg(feature = "cloud-pull-vault")]
25pub use providers::hcp as tsafe_hcp;
26#[cfg(feature = "cloud-pull-keepass")]
27pub use providers::keepass as tsafe_keepass;
28#[cfg(feature = "cloud-pull-1password")]
29pub use providers::onepassword as tsafe_op;
30
31// Re-export the 1Password field-label normalisation function so that
32// integration tests can verify the mapping contract without shelling out
33// to `op`.
34#[cfg(any(feature = "cloud-pull-1password", feature = "cloud-pull-vault", test))]
35pub use op_mapping::op_field_label_to_key;
36
37// Always-compiled module that holds the pure mapping logic so it is
38// available for `#[cfg(test)]` regardless of feature flags.
39pub mod op_mapping {
40    /// Normalise a 1Password field label to an env-style vault key.
41    ///
42    /// Rule: spaces and hyphens → underscores, then uppercase.
43    /// The 1Password item name is **not** included in the output key.
44    ///
45    /// Examples:
46    /// - `"My Secret"` → `"MY_SECRET"`
47    /// - `"db-password"` → `"DB_PASSWORD"`
48    /// - `"API_KEY"` → `"API_KEY"`
49    pub fn op_field_label_to_key(label: &str) -> String {
50        label.replace([' ', '-'], "_").to_uppercase()
51    }
52}
53
54// ── Sub-command modules ───────────────────────────────────────────────────────
55// Declared here (lib root) so both the binary's main.rs and the meta-crate
56// shim can reach them through the library crate.
57
58#[cfg(feature = "agent")]
59mod cmd_agent;
60mod cmd_alias;
61mod cmd_attest;
62mod cmd_audit_cmd;
63#[cfg(feature = "cloud-pull-aws")]
64mod cmd_aws_pull;
65#[cfg(feature = "cloud-pull-aws")]
66mod cmd_aws_push;
67#[cfg(feature = "biometric")]
68mod cmd_biometric;
69#[cfg(feature = "cloud-pull-bitwarden")]
70mod cmd_bitwarden_pull;
71#[cfg(feature = "nativehost")]
72mod cmd_browser_native_host;
73#[cfg(feature = "browser")]
74mod cmd_browser_profile;
75#[cfg(feature = "collab")]
76mod cmd_collab;
77mod cmd_config_cmd;
78#[cfg(feature = "git-helpers")]
79mod cmd_credential_helper;
80mod cmd_diff;
81mod cmd_doctor;
82mod cmd_exec;
83#[cfg(feature = "cloud-pull-gcp")]
84mod cmd_gcp_pull;
85#[cfg(feature = "cloud-pull-gcp")]
86mod cmd_gcp_push;
87mod cmd_gen;
88#[cfg(feature = "git-helpers")]
89mod cmd_git;
90mod cmd_import;
91#[cfg(feature = "cloud-pull-keepass")]
92mod cmd_keepass_pull;
93#[cfg(feature = "akv-pull")]
94mod cmd_kv_pull;
95#[cfg(feature = "akv-pull")]
96mod cmd_kv_push;
97#[cfg(feature = "mcp")]
98mod cmd_mcp;
99mod cmd_ns;
100#[cfg(feature = "plugins")]
101mod cmd_plugin;
102mod cmd_policy;
103mod cmd_profile_cmd;
104#[cfg(feature = "multi-pull")]
105mod cmd_pull;
106#[cfg(feature = "akv-pull")]
107mod cmd_push;
108mod cmd_rotate;
109#[cfg(feature = "ots-sharing")]
110mod cmd_share;
111mod cmd_snapshot_cmd;
112#[cfg(feature = "ssh")]
113mod cmd_ssh;
114#[cfg(feature = "cloud-pull-aws")]
115mod cmd_ssm_pull;
116#[cfg(feature = "cloud-pull-aws")]
117mod cmd_ssm_push;
118#[cfg(feature = "git-helpers")]
119mod cmd_sync;
120#[cfg(feature = "team-core")]
121mod cmd_team;
122mod cmd_template;
123mod cmd_tooling;
124mod cmd_totp;
125mod cmd_validate;
126mod cmd_vault;
127#[cfg(any(feature = "cloud-pull-vault", feature = "cloud-pull-1password"))]
128mod cmd_vault_pull;
129mod helpers;
130#[cfg(all(windows, feature = "biometric"))]
131mod windows_hello;
132
133// ── Command dispatch ──────────────────────────────────────────────────────────
134
135#[cfg(feature = "agent")]
136use cmd_agent::cmd_agent;
137use cmd_alias::{cmd_alias, cmd_history, cmd_mv};
138use cmd_attest::cmd_attest;
139use cmd_audit_cmd::{cmd_audit, cmd_audit_verify};
140#[cfg(feature = "cloud-pull-aws")]
141use cmd_aws_pull::cmd_aws_pull;
142#[cfg(feature = "cloud-pull-aws")]
143use cmd_aws_push::cmd_aws_push;
144#[cfg(feature = "biometric")]
145use cmd_biometric::cmd_biometric;
146#[cfg(feature = "cloud-pull-bitwarden")]
147use cmd_bitwarden_pull::cmd_bitwarden_pull;
148#[cfg(feature = "nativehost")]
149use cmd_browser_native_host::cmd_browser_native_host;
150#[cfg(feature = "browser")]
151use cmd_browser_profile::cmd_browser_profile;
152#[cfg(feature = "collab")]
153use cmd_collab::cmd_collab;
154use cmd_config_cmd::cmd_config;
155#[cfg(feature = "git-helpers")]
156use cmd_credential_helper::cmd_credential_helper;
157#[cfg(feature = "git-helpers")]
158use cmd_diff::cmd_hook_install;
159use cmd_diff::{cmd_audit_export, cmd_compare, cmd_diff};
160use cmd_doctor::cmd_doctor;
161#[cfg(feature = "mcp")]
162use cmd_doctor::cmd_mcp_doctor;
163use cmd_exec::cmd_exec;
164#[cfg(feature = "cloud-pull-gcp")]
165use cmd_gcp_pull::cmd_gcp_pull;
166#[cfg(feature = "cloud-pull-gcp")]
167use cmd_gcp_push::cmd_gcp_push;
168use cmd_gen::{cmd_completions, cmd_completions_data, cmd_gen};
169#[cfg(feature = "git-helpers")]
170use cmd_git::cmd_git;
171use cmd_import::cmd_import;
172#[cfg(feature = "cloud-pull-keepass")]
173use cmd_keepass_pull::cmd_keepass_pull;
174#[cfg(feature = "akv-pull")]
175use cmd_kv_pull::cmd_kv_pull;
176#[cfg(feature = "akv-pull")]
177use cmd_kv_push::cmd_kv_push;
178#[cfg(feature = "mcp")]
179use cmd_mcp::cmd_mcp;
180use cmd_ns::cmd_ns;
181#[cfg(feature = "plugins")]
182use cmd_plugin::cmd_plugin;
183use cmd_policy::{cmd_policy, cmd_rotate_due};
184use cmd_profile_cmd::{cmd_profile, cmd_rotate, cmd_unlock};
185#[cfg(feature = "multi-pull")]
186use cmd_pull::cmd_pull;
187#[cfg(feature = "akv-pull")]
188use cmd_push::cmd_push;
189use cmd_rotate::cmd_rotate_key;
190#[cfg(feature = "ots-sharing")]
191use cmd_share::{cmd_receive_once, cmd_share_once};
192use cmd_snapshot_cmd::cmd_snapshot;
193#[cfg(feature = "ssh")]
194use cmd_ssh::{cmd_ssh, cmd_ssh_add, cmd_ssh_import};
195#[cfg(feature = "cloud-pull-aws")]
196use cmd_ssm_pull::cmd_ssm_pull;
197#[cfg(feature = "cloud-pull-aws")]
198use cmd_ssm_push::cmd_ssm_push;
199#[cfg(feature = "git-helpers")]
200use cmd_sync::cmd_sync;
201#[cfg(feature = "team-core")]
202use cmd_team::cmd_team;
203use cmd_template::{cmd_redact, cmd_template};
204use cmd_tooling::cmd_tooling;
205use cmd_totp::{cmd_pin, cmd_qr, cmd_totp, cmd_unpin};
206use cmd_validate::cmd_validate;
207use cmd_vault::{cmd_delete, cmd_export, cmd_get, cmd_init, cmd_list, cmd_set};
208#[cfg(feature = "cloud-pull-1password")]
209use cmd_vault_pull::cmd_op_pull;
210#[cfg(feature = "cloud-pull-vault")]
211use cmd_vault_pull::cmd_vault_pull;
212
213use crate::cli::{Cli, Commands, ExecPresetSetting};
214#[cfg(feature = "mcp")]
215use crate::cli::{McpAction, McpCliAction};
216use anyhow::Result;
217use clap::Parser;
218use colored::Colorize;
219use tsafe_core::profile;
220
221// ── Entry point ───────────────────────────────────────────────────────────────
222
223/// Launch the tsafe CLI.
224///
225/// Initialises tracing/OTel (if enabled), parses `std::env::args()` via clap,
226/// dispatches to the appropriate sub-command handler, and exits with code 1 on
227/// error.  Called from `main.rs` and from the tsafe meta-crate's bin shim.
228pub fn run() {
229    // Structured logging via tracing. Controlled by TSAFE_LOG env var:
230    //   TSAFE_LOG=debug   — verbose debug output to stderr
231    //   TSAFE_LOG=info    — informational messages only
232    //   (unset)           — logging disabled (zero overhead)
233    // Optional: TSAFE_LOG_FORMAT=json — newline-delimited JSON on stderr (CI / log aggregators).
234    // JSON mode enables span-close events so `#[instrument]`d calls (e.g. vault open, KDF) emit lines.
235    //
236    // Feature-gated: when compiled with `--features otel`, OpenTelemetry can be added
237    // on top of the same subscriber via either:
238    //   TSAFE_OTEL_STDOUT=1              — stdout exporter (debug/local)
239    //   OTEL_EXPORTER_OTLP_ENDPOINT=...  — OTLP HTTP exporter
240    //   OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=... — traces-specific OTLP HTTP endpoint
241    // See docs/features/opentelemetry.md for full configuration.
242    #[cfg(feature = "otel")]
243    let _otel_provider = init_tracing();
244
245    #[cfg(not(feature = "otel"))]
246    init_tracing();
247
248    let cli = Cli::parse();
249    if let Err(e) = dispatch(cli) {
250        eprintln!("{} {e:#}", "error:".red().bold());
251        std::process::exit(1);
252    }
253}
254
255// ── dispatch ─────────────────────────────────────────────────────────────────
256
257fn dispatch(cli: Cli) -> Result<()> {
258    // Resolve active profile: explicit -p flag > TSAFE_PROFILE env > persisted default > "default"
259    let profile_explicit = cli.profile.is_some();
260    let profile = cli.profile.unwrap_or_else(profile::get_default_profile);
261    if command_requires_valid_profile(&cli.command) {
262        profile::validate_profile_name(&profile)?;
263    }
264
265    match cli.command {
266        Commands::Init => cmd_init(&profile, profile_explicit),
267        Commands::Config { action } => cmd_config(action),
268        Commands::Set {
269            key,
270            value,
271            tags,
272            overwrite,
273        } => cmd_set(&profile, &key, value, tags, overwrite),
274        Commands::Get { key, copy, version } => cmd_get(&profile, &key, copy, version),
275        Commands::Delete { key } => cmd_delete(&profile, &key),
276        Commands::List { tags, ns } => cmd_list(&profile, &tags, ns.as_deref()),
277        Commands::Export {
278            format,
279            keys,
280            tags,
281            ns,
282        } => cmd_export(&profile, format, keys, tags, ns.as_deref()),
283        Commands::Exec {
284            cmd,
285            contract,
286            ns,
287            keys,
288            mode,
289            timeout,
290            preset,
291            dry_run,
292            plan,
293            no_inherit,
294            minimal,
295            only,
296            require,
297            env_mappings,
298            deny_dangerous_env,
299            allow_dangerous_env,
300            redact_output,
301            no_redact_output,
302        } => {
303            // --preset minimal is equivalent to --minimal; --preset full is the default.
304            let effective_minimal = minimal || matches!(preset, Some(ExecPresetSetting::Minimal));
305            cmd_exec(
306                &profile,
307                profile_explicit,
308                contract.as_deref(),
309                cmd,
310                ns.as_deref(),
311                keys,
312                mode,
313                timeout,
314                dry_run,
315                plan,
316                no_inherit,
317                effective_minimal,
318                only,
319                require,
320                env_mappings,
321                deny_dangerous_env,
322                allow_dangerous_env,
323                redact_output,
324                no_redact_output,
325            )
326        }
327        Commands::Import {
328            from,
329            file,
330            overwrite,
331            skip_duplicates,
332            ns,
333            dry_run,
334        } => cmd_import(
335            &profile,
336            &from,
337            file.as_deref(),
338            overwrite,
339            skip_duplicates,
340            ns.as_deref(),
341            dry_run,
342        ),
343        Commands::Ns { action } => cmd_ns(&profile, action),
344        Commands::Rotate => cmd_rotate(&profile),
345        Commands::RotateKey {
346            profile: profile_override,
347        } => {
348            let effective = profile_override.as_deref().unwrap_or(&profile);
349            cmd_rotate_key(effective)
350        }
351        Commands::Profile { action } => cmd_profile(&profile, action),
352        Commands::Audit {
353            limit,
354            hibp,
355            explain,
356            json,
357            cell_id,
358        } => cmd_audit(&profile, limit, hibp, explain, json, cell_id.as_deref()),
359        Commands::Validate {
360            cellos_policy,
361            policy_file,
362            json,
363        } => {
364            let path = cellos_policy.or(policy_file).ok_or_else(|| {
365                anyhow::anyhow!("one of --cellos-policy or --policy-file is required")
366            })?;
367            cmd_validate(&path, json)
368        }
369        Commands::Snapshot { action } => cmd_snapshot(&profile, action),
370        #[cfg(feature = "akv-pull")]
371        Commands::KvPull {
372            prefix,
373            overwrite,
374            on_error,
375        } => cmd_kv_pull(&profile, prefix.as_deref(), overwrite, on_error),
376        #[cfg(feature = "akv-pull")]
377        Commands::KvPush {
378            prefix,
379            ns,
380            dry_run,
381            yes,
382            delete_missing,
383        } => cmd_kv_push(
384            &profile,
385            prefix.as_deref(),
386            ns.as_deref(),
387            dry_run,
388            yes,
389            delete_missing,
390        ),
391        #[cfg(feature = "ots-sharing")]
392        Commands::ShareOnce { key, ttl } => cmd_share_once(&profile, &key, &ttl),
393        Commands::Gen {
394            key,
395            length,
396            charset,
397            words,
398            tags,
399            print,
400            exclude_ambiguous,
401        } => cmd_gen(
402            &profile,
403            &key,
404            length,
405            &charset,
406            words,
407            tags,
408            print,
409            exclude_ambiguous,
410        ),
411        Commands::Diff => cmd_diff(&profile),
412        Commands::Compare { profile_b } => cmd_compare(&profile, &profile_b),
413        #[cfg(feature = "git-helpers")]
414        Commands::HookInstall { dir } => cmd_hook_install(dir.as_deref()),
415        Commands::AuditExport { format, output } => {
416            cmd_audit_export(&profile, format, output.as_deref())
417        }
418        Commands::AuditVerify { json } => cmd_audit_verify(&profile, json),
419        #[cfg(feature = "cloud-pull-vault")]
420        Commands::VaultPull {
421            addr,
422            token,
423            mount,
424            prefix,
425            overwrite,
426        } => cmd_vault_pull(
427            &profile,
428            addr.as_deref(),
429            token.as_deref(),
430            mount.as_deref(),
431            prefix.as_deref(),
432            overwrite,
433        ),
434        #[cfg(feature = "cloud-pull-1password")]
435        Commands::OpPull {
436            item,
437            op_vault,
438            overwrite,
439        } => cmd_op_pull(&profile, &item, op_vault.as_deref(), overwrite),
440        #[cfg(feature = "cloud-pull-bitwarden")]
441        Commands::BwPull {
442            bw_client_id,
443            bw_client_secret,
444            bw_api_url,
445            bw_identity_url,
446            bw_folder,
447            bw_password_env,
448            overwrite,
449            on_error,
450            dry_run,
451        } => {
452            if dry_run {
453                println!("Dry run — Bitwarden pull would contact the bw CLI.");
454                println!(
455                    "  client_id:    {}",
456                    bw_client_id
457                        .as_deref()
458                        .unwrap_or("(from TSAFE_BW_CLIENT_ID)")
459                );
460                println!(
461                    "  api_url:      {}",
462                    bw_api_url.as_deref().unwrap_or("https://api.bitwarden.com")
463                );
464                println!(
465                    "  identity_url: {}",
466                    bw_identity_url
467                        .as_deref()
468                        .unwrap_or("https://identity.bitwarden.com")
469                );
470                println!(
471                    "  folder:       {}",
472                    bw_folder.as_deref().unwrap_or("(all items)")
473                );
474                println!(
475                    "  password_env: {}",
476                    bw_password_env.as_deref().unwrap_or("TSAFE_BW_PASSWORD")
477                );
478                println!("  overwrite:    {overwrite}");
479                return Ok(());
480            }
481            cmd_bitwarden_pull(
482                &profile,
483                bw_api_url.as_deref(),
484                bw_identity_url.as_deref(),
485                bw_client_id.as_deref(),
486                bw_client_secret.as_deref(),
487                bw_folder.as_deref(),
488                bw_password_env.as_deref(),
489                overwrite,
490                on_error,
491            )
492        }
493        #[cfg(feature = "cloud-pull-keepass")]
494        Commands::KpPull {
495            kp_path,
496            kp_password_env,
497            kp_keyfile,
498            kp_group,
499            kp_recursive,
500            overwrite,
501            on_error,
502        } => {
503            use tsafe_core::pullconfig::PullSource;
504            let src = PullSource::Keepass {
505                name: None,
506                ns: None,
507                path: kp_path,
508                password_env: Some(kp_password_env),
509                keyfile_path: kp_keyfile,
510                group: kp_group,
511                recursive: Some(kp_recursive),
512                overwrite,
513            };
514            cmd_keepass_pull(&profile, &src, overwrite, on_error)
515        }
516        #[cfg(feature = "cloud-pull-aws")]
517        Commands::AwsPull {
518            region,
519            prefix,
520            overwrite,
521            on_error,
522        } => cmd_aws_pull(
523            &profile,
524            region.as_deref(),
525            prefix.as_deref(),
526            overwrite,
527            on_error,
528        ),
529        #[cfg(feature = "cloud-pull-gcp")]
530        Commands::GcpPull {
531            project,
532            prefix,
533            overwrite,
534            on_error,
535        } => cmd_gcp_pull(
536            &profile,
537            project.as_deref(),
538            prefix.as_deref(),
539            overwrite,
540            on_error,
541        ),
542        #[cfg(feature = "cloud-pull-gcp")]
543        Commands::GcpPush {
544            project,
545            prefix,
546            ns,
547            dry_run,
548            yes,
549            delete_missing,
550        } => cmd_gcp_push(
551            &profile,
552            project.as_deref(),
553            prefix.as_deref(),
554            ns.as_deref(),
555            dry_run,
556            yes,
557            delete_missing,
558        ),
559        #[cfg(feature = "cloud-pull-aws")]
560        Commands::AwsPush {
561            region,
562            prefix,
563            dry_run,
564            yes,
565            delete_missing,
566        } => cmd_aws_push(
567            &profile,
568            region.as_deref(),
569            prefix.as_deref(),
570            dry_run,
571            yes,
572            delete_missing,
573        ),
574        #[cfg(feature = "cloud-pull-aws")]
575        Commands::SsmPull {
576            region,
577            path,
578            overwrite,
579            on_error,
580        } => cmd_ssm_pull(
581            &profile,
582            region.as_deref(),
583            path.as_deref(),
584            overwrite,
585            on_error,
586        ),
587        #[cfg(feature = "cloud-pull-aws")]
588        Commands::SsmPush {
589            region,
590            path,
591            dry_run,
592            yes,
593            delete_missing,
594        } => cmd_ssm_push(
595            &profile,
596            region.as_deref(),
597            path.as_deref(),
598            dry_run,
599            yes,
600            delete_missing,
601        ),
602        Commands::Completions { shell } => cmd_completions(shell),
603        Commands::CompletionsData { data_type } => cmd_completions_data(&data_type),
604        Commands::Doctor { json } => cmd_doctor(&profile, json),
605        Commands::Explain { topic } => {
606            crate::explain::run(topic);
607            Ok(())
608        }
609        Commands::Unlock => cmd_unlock(&profile),
610        #[cfg(feature = "tui")]
611        Commands::Ui => {
612            // Inject the CLI binary version so the TUI version badge shows
613            // the installed binary version rather than the tsafe-core version.
614            std::env::set_var("TSAFE_CLI_VERSION", env!("CARGO_PKG_VERSION"));
615            tsafe_tui::run().map_err(|e| anyhow::anyhow!(e))
616        }
617        Commands::Qr { key } => cmd_qr(&profile, &key),
618        Commands::Totp { action } => cmd_totp(&profile, action),
619        Commands::Pin { key } => cmd_pin(&profile, &key),
620        Commands::Unpin { key } => cmd_unpin(&profile, &key),
621        Commands::Alias {
622            target_key,
623            alias_name,
624            list,
625        } => cmd_alias(&profile, target_key.as_deref(), alias_name.as_deref(), list),
626        #[cfg(feature = "browser")]
627        Commands::BrowserProfile { action } => cmd_browser_profile(&profile, action),
628        #[cfg(feature = "nativehost")]
629        Commands::BrowserNativeHost { action } => cmd_browser_native_host(action),
630        #[cfg(feature = "ots-sharing")]
631        Commands::ReceiveOnce { url, store } => cmd_receive_once(&profile, &url, store.as_deref()),
632        #[cfg(feature = "agent")]
633        Commands::Agent { action } => cmd_agent(&profile, action),
634        #[cfg(feature = "mcp")]
635        Commands::Mcp { action } => cmd_mcp_cli(&profile, action),
636        #[cfg(feature = "git-helpers")]
637        Commands::Git { args } => cmd_git(&profile, args),
638        Commands::Attest { action } => cmd_attest(&profile, action),
639        Commands::History { key } => cmd_history(&profile, &key),
640        Commands::Mv {
641            source,
642            dest,
643            to_profile,
644            force,
645        } => cmd_mv(
646            &profile,
647            &source,
648            dest.as_deref(),
649            to_profile.as_deref(),
650            force,
651        ),
652        Commands::Policy { action } => cmd_policy(&profile, action),
653        Commands::RotateDue { json, fail } => cmd_rotate_due(&profile, json, fail),
654        #[cfg(feature = "ssh")]
655        Commands::SshAdd { key } => cmd_ssh_add(&profile, &key),
656        #[cfg(feature = "ssh")]
657        Commands::SshImport { path, name, tags } => {
658            cmd_ssh_import(&profile, &path, name.as_deref(), tags)
659        }
660        #[cfg(feature = "ssh")]
661        Commands::Ssh { action } => cmd_ssh(&profile, action),
662        #[cfg(feature = "multi-pull")]
663        Commands::Pull {
664            config,
665            overwrite,
666            on_error,
667            dry_run,
668            sources,
669        } => cmd_pull(
670            &profile,
671            config.as_deref(),
672            overwrite,
673            on_error,
674            dry_run,
675            &sources,
676        ),
677        #[cfg(feature = "akv-pull")]
678        Commands::Push {
679            config,
680            source,
681            dry_run,
682            yes,
683            delete_missing,
684            on_error,
685        } => cmd_push(
686            &profile,
687            config.as_deref(),
688            &source,
689            dry_run,
690            yes,
691            delete_missing,
692            on_error,
693        ),
694        #[cfg(feature = "biometric")]
695        Commands::Biometric { action } => cmd_biometric(&profile, action),
696        #[cfg(feature = "team-core")]
697        Commands::Team { action } => cmd_team(&profile, action),
698        #[cfg(feature = "git-helpers")]
699        Commands::Sync {
700            remote,
701            branch,
702            file,
703            dry_run,
704        } => cmd_sync(&profile, &remote, &branch, file.as_deref(), dry_run),
705        Commands::Template {
706            file,
707            output,
708            ignore_missing,
709        } => cmd_template(&profile, &file, output.as_deref(), ignore_missing),
710        Commands::Redact => cmd_redact(&profile),
711        Commands::Tooling { action } => cmd_tooling(action),
712        Commands::BuildInfo { json } => cmd_build_info(json),
713        #[cfg(feature = "plugins")]
714        Commands::Plugin { tool, args } => cmd_plugin(&profile, tool.as_deref(), &args),
715        #[cfg(feature = "git-helpers")]
716        Commands::CredentialHelper { action, global } => {
717            cmd_credential_helper(&profile, action, global)
718        }
719        #[cfg(feature = "collab")]
720        Commands::Collab { action } => cmd_collab(&profile, action),
721    }
722}
723
724#[cfg(feature = "mcp")]
725fn cmd_mcp_cli(profile: &str, action: McpCliAction) -> Result<()> {
726    let action = match action {
727        McpCliAction::Serve {
728            allowed_keys,
729            denied_keys,
730            contract,
731            workdir,
732            allow_reveal,
733            audit_source,
734        } => McpAction::Serve {
735            allowed_keys,
736            denied_keys,
737            contract,
738            workdir,
739            allow_reveal,
740            audit_source,
741        },
742        McpCliAction::Install {
743            host,
744            name,
745            global,
746            project,
747            dry_run,
748            allowed_keys,
749            denied_keys,
750            contract,
751            workdir,
752            allow_reveal,
753        } => McpAction::Install {
754            host,
755            name,
756            global,
757            project,
758            dry_run,
759            allowed_keys,
760            denied_keys,
761            contract,
762            workdir,
763            allow_reveal,
764        },
765        McpCliAction::Config {
766            host,
767            name,
768            contract,
769            workdir,
770        } => McpAction::Config {
771            host,
772            name,
773            contract,
774            workdir,
775        },
776        McpCliAction::Uninstall { host, name } => McpAction::Uninstall { host, name },
777        McpCliAction::Status => McpAction::Status,
778        McpCliAction::Doctor {
779            code,
780            contract,
781            workdir,
782            receipt_id,
783            json,
784        } => {
785            return cmd_mcp_doctor(
786                profile,
787                &code,
788                &contract,
789                &workdir,
790                receipt_id.as_deref(),
791                json,
792            );
793        }
794    };
795
796    cmd_mcp(profile, action)
797}
798
799fn command_requires_valid_profile(command: &Commands) -> bool {
800    let skips_profile_validation = matches!(
801        command,
802        Commands::BuildInfo { .. }
803            | Commands::Completions { .. }
804            | Commands::CompletionsData { .. }
805            | Commands::Config { .. }
806            | Commands::Explain { .. }
807            | Commands::Tooling { .. }
808            | Commands::Validate { .. }
809            | Commands::Attest { .. }
810    );
811
812    #[cfg(feature = "tui")]
813    let skips_profile_validation = skips_profile_validation || matches!(command, Commands::Ui);
814
815    #[cfg(feature = "nativehost")]
816    let skips_profile_validation =
817        skips_profile_validation || matches!(command, Commands::BrowserNativeHost { .. });
818
819    !skips_profile_validation
820}
821
822fn compile_time_feature_flags() -> Vec<&'static str> {
823    let mut feature_flags = Vec::new();
824
825    if cfg!(feature = "tui") {
826        feature_flags.push("tui");
827    }
828    if cfg!(feature = "akv-pull") {
829        feature_flags.push("akv-pull");
830    }
831    if cfg!(feature = "biometric") {
832        feature_flags.push("biometric");
833    }
834    if cfg!(feature = "agent") {
835        feature_flags.push("agent");
836    }
837    if cfg!(feature = "mcp") {
838        feature_flags.push("mcp");
839    }
840    if cfg!(feature = "team-core") {
841        feature_flags.push("team-core");
842    }
843    if cfg!(feature = "cloud-pull-aws") {
844        feature_flags.push("cloud-pull-aws");
845    }
846    if cfg!(feature = "cloud-pull-gcp") {
847        feature_flags.push("cloud-pull-gcp");
848    }
849    if cfg!(feature = "cloud-pull-vault") {
850        feature_flags.push("cloud-pull-vault");
851    }
852    if cfg!(feature = "cloud-pull-1password") {
853        feature_flags.push("cloud-pull-1password");
854    }
855    if cfg!(feature = "cloud-pull-keepass") {
856        feature_flags.push("cloud-pull-keepass");
857    }
858    if cfg!(feature = "cloud-pull-bitwarden") {
859        feature_flags.push("cloud-pull-bitwarden");
860    }
861    if cfg!(feature = "multi-pull") {
862        feature_flags.push("multi-pull");
863    }
864    if cfg!(feature = "pm-import-extended") {
865        feature_flags.push("pm-import-extended");
866    }
867    if cfg!(feature = "ots-sharing") {
868        feature_flags.push("ots-sharing");
869    }
870    if cfg!(feature = "git-helpers") {
871        feature_flags.push("git-helpers");
872    }
873    if cfg!(feature = "browser") {
874        feature_flags.push("browser");
875    }
876    if cfg!(feature = "nativehost") {
877        feature_flags.push("nativehost");
878    }
879    if cfg!(feature = "ssh") {
880        feature_flags.push("ssh");
881    }
882    if cfg!(feature = "plugins") {
883        feature_flags.push("plugins");
884    }
885    if cfg!(feature = "otel") {
886        feature_flags.push("otel");
887    }
888
889    feature_flags.sort_unstable();
890    feature_flags
891}
892
893const DEFAULT_CORE_BUILD_PROFILE: &[&str] = &[
894    "agent",
895    "akv-pull",
896    "biometric",
897    "mcp",
898    "ssh",
899    "team-core",
900    "tui",
901];
902
903fn build_profile_label(capabilities: &[&'static str]) -> &'static str {
904    if capabilities.is_empty() {
905        "enterprise-minimal"
906    } else if capabilities == DEFAULT_CORE_BUILD_PROFILE {
907        "default-core"
908    } else {
909        "custom"
910    }
911}
912
913fn cmd_build_info(json: bool) -> Result<()> {
914    let capabilities = compile_time_feature_flags();
915    let profile = build_profile_label(&capabilities);
916
917    if json {
918        let payload = serde_json::json!({
919            "build_profile": profile,
920            "capabilities": capabilities,
921        });
922        println!("{}", serde_json::to_string_pretty(&payload)?);
923    } else {
924        println!("build_profile: {profile}");
925        if capabilities.is_empty() {
926            println!("capabilities: none");
927        } else {
928            println!("capabilities: {}", capabilities.join(","));
929        }
930    }
931
932    Ok(())
933}
934
935// ── Tracing / OpenTelemetry feature gate ─────────────────────────────────────
936//
937// Compiled only when `--features otel` is passed. Returns the provider so the
938// caller can hold it alive until program exit (Drop flushes the span pipeline).
939//
940// Environment variables:
941//   TSAFE_LOG=debug/info               — stderr structured logging
942//   TSAFE_LOG_FORMAT=json             — JSON stderr logging
943//   TSAFE_OTEL_STDOUT=1               — emit OTel spans to stdout
944//   OTEL_EXPORTER_OTLP_ENDPOINT=...   — OTLP HTTP exporter endpoint
945//   OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=... — traces-specific OTLP HTTP endpoint
946// See docs/features/opentelemetry.md for details.
947
948fn tracing_log_enabled() -> bool {
949    std::env::var("TSAFE_LOG")
950        .ok()
951        .filter(|v| !v.is_empty())
952        .is_some()
953}
954
955fn tracing_json_enabled() -> bool {
956    std::env::var("TSAFE_LOG_FORMAT")
957        .map(|v| v.eq_ignore_ascii_case("json"))
958        .unwrap_or(false)
959}
960
961#[cfg(not(feature = "otel"))]
962fn init_tracing() {
963    if !tracing_log_enabled() {
964        return;
965    }
966
967    use tracing_subscriber::fmt::format::FmtSpan;
968    use tracing_subscriber::layer::SubscriberExt as _;
969    use tracing_subscriber::util::SubscriberInitExt as _;
970    use tracing_subscriber::Layer as _;
971    use tracing_subscriber::{fmt, EnvFilter};
972
973    let filter = EnvFilter::from_env("TSAFE_LOG");
974    let fmt_layer = if tracing_json_enabled() {
975        fmt::layer()
976            .with_writer(std::io::stderr)
977            .with_target(false)
978            .json()
979            .with_span_events(FmtSpan::CLOSE)
980            .boxed()
981    } else {
982        fmt::layer()
983            .with_writer(std::io::stderr)
984            .with_target(false)
985            .compact()
986            .boxed()
987    };
988
989    tracing_subscriber::registry()
990        .with(filter)
991        .with(fmt_layer)
992        .init();
993}
994
995#[cfg(feature = "otel")]
996fn otel_stdout_enabled() -> bool {
997    std::env::var("TSAFE_OTEL_STDOUT")
998        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
999        .unwrap_or(false)
1000}
1001
1002#[cfg(feature = "otel")]
1003fn otel_trace_endpoint() -> Option<String> {
1004    std::env::var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
1005        .ok()
1006        .filter(|value| !value.trim().is_empty())
1007        .or_else(|| {
1008            std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
1009                .ok()
1010                .filter(|value| !value.trim().is_empty())
1011        })
1012}
1013
1014#[cfg(feature = "otel")]
1015fn build_otel_provider() -> Option<opentelemetry_sdk::trace::SdkTracerProvider> {
1016    use opentelemetry::trace::TracerProvider as _;
1017    use opentelemetry_otlp::{Protocol, WithExportConfig};
1018    use opentelemetry_sdk::trace::SdkTracerProvider;
1019
1020    let stdout_enabled = otel_stdout_enabled();
1021    let otlp_endpoint = otel_trace_endpoint();
1022    if !stdout_enabled && otlp_endpoint.is_none() {
1023        return None;
1024    }
1025
1026    let mut builder = SdkTracerProvider::builder();
1027    let mut has_exporter = false;
1028
1029    if stdout_enabled {
1030        builder = builder.with_simple_exporter(opentelemetry_stdout::SpanExporter::default());
1031        has_exporter = true;
1032    }
1033
1034    if let Some(endpoint) = otlp_endpoint {
1035        let exporter = opentelemetry_otlp::SpanExporter::builder()
1036            .with_http()
1037            .with_endpoint(endpoint)
1038            .with_protocol(Protocol::HttpBinary)
1039            .build();
1040        match exporter {
1041            Ok(exporter) => {
1042                builder = builder.with_batch_exporter(exporter);
1043                has_exporter = true;
1044            }
1045            Err(err) => {
1046                eprintln!(
1047                    "{} could not initialize OTLP HTTP exporter: {err}",
1048                    "warn:".yellow()
1049                );
1050            }
1051        }
1052    }
1053
1054    if !has_exporter {
1055        return None;
1056    }
1057
1058    let provider = builder.build();
1059
1060    opentelemetry::global::set_tracer_provider(provider.clone());
1061    let _ = provider.tracer("tsafe");
1062
1063    Some(provider)
1064}
1065
1066#[cfg(feature = "otel")]
1067fn init_tracing() -> Option<opentelemetry_sdk::trace::SdkTracerProvider> {
1068    use opentelemetry::trace::TracerProvider as _;
1069    use tracing_subscriber::fmt::format::FmtSpan;
1070    use tracing_subscriber::layer::SubscriberExt as _;
1071    use tracing_subscriber::util::SubscriberInitExt as _;
1072    use tracing_subscriber::Layer as _;
1073    use tracing_subscriber::{fmt, EnvFilter};
1074
1075    let otel_provider = build_otel_provider();
1076    let log_enabled = tracing_log_enabled();
1077
1078    if log_enabled {
1079        let filter = EnvFilter::from_env("TSAFE_LOG");
1080        let otel_layer = otel_provider.as_ref().map(|provider| {
1081            tracing_opentelemetry::layer()
1082                .with_tracer(provider.tracer("tsafe"))
1083                .boxed()
1084        });
1085        let fmt_layer = if tracing_json_enabled() {
1086            fmt::layer()
1087                .with_writer(std::io::stderr)
1088                .with_target(false)
1089                .json()
1090                .with_span_events(FmtSpan::CLOSE)
1091                .boxed()
1092        } else {
1093            fmt::layer()
1094                .with_writer(std::io::stderr)
1095                .with_target(false)
1096                .compact()
1097                .boxed()
1098        };
1099
1100        tracing_subscriber::registry()
1101            .with(filter)
1102            .with(fmt_layer)
1103            .with(otel_layer)
1104            .init();
1105    } else if let Some(provider) = otel_provider.as_ref() {
1106        let tracer = provider.tracer("tsafe");
1107        tracing_subscriber::registry()
1108            .with(tracing_opentelemetry::layer().with_tracer(tracer))
1109            .init();
1110    }
1111
1112    otel_provider
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118
1119    #[test]
1120    fn build_profile_label_marks_empty_build_as_enterprise_minimal() {
1121        assert_eq!(build_profile_label(&[]), "enterprise-minimal");
1122    }
1123
1124    #[test]
1125    fn build_profile_label_marks_core_bundle_as_default_core() {
1126        assert_eq!(
1127            build_profile_label(DEFAULT_CORE_BUILD_PROFILE),
1128            "default-core"
1129        );
1130    }
1131
1132    #[test]
1133    fn build_profile_label_marks_extra_opt_in_capabilities_as_custom() {
1134        let capabilities = [
1135            "agent",
1136            "akv-pull",
1137            "biometric",
1138            "nativehost",
1139            "team-core",
1140            "tui",
1141        ];
1142        assert_eq!(build_profile_label(&capabilities), "custom");
1143    }
1144}
1145
1146#[cfg(all(test, feature = "otel"))]
1147mod otel_tests {
1148    use super::*;
1149
1150    #[test]
1151    fn otel_trace_endpoint_prefers_traces_specific_env() {
1152        temp_env::with_vars(
1153            [
1154                (
1155                    "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
1156                    Some(std::ffi::OsStr::new("http://localhost:4318/v1/traces")),
1157                ),
1158                (
1159                    "OTEL_EXPORTER_OTLP_ENDPOINT",
1160                    Some(std::ffi::OsStr::new("http://localhost:4318")),
1161                ),
1162            ],
1163            || {
1164                assert_eq!(
1165                    otel_trace_endpoint().as_deref(),
1166                    Some("http://localhost:4318/v1/traces")
1167                );
1168            },
1169        );
1170    }
1171
1172    #[test]
1173    fn otel_trace_endpoint_falls_back_to_base_endpoint() {
1174        temp_env::with_vars(
1175            [
1176                ("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", None),
1177                (
1178                    "OTEL_EXPORTER_OTLP_ENDPOINT",
1179                    Some(std::ffi::OsStr::new("http://localhost:4318")),
1180                ),
1181            ],
1182            || {
1183                assert_eq!(
1184                    otel_trace_endpoint().as_deref(),
1185                    Some("http://localhost:4318")
1186                );
1187            },
1188        );
1189    }
1190
1191    #[test]
1192    fn otel_stdout_enabled_accepts_boolean_forms() {
1193        temp_env::with_var("TSAFE_OTEL_STDOUT", Some("true"), || {
1194            assert!(otel_stdout_enabled());
1195        });
1196        temp_env::with_var("TSAFE_OTEL_STDOUT", Some("1"), || {
1197            assert!(otel_stdout_enabled());
1198        });
1199        temp_env::with_var("TSAFE_OTEL_STDOUT", Some("0"), || {
1200            assert!(!otel_stdout_enabled());
1201        });
1202    }
1203
1204    // ── Task 6.3: no-secret-in-span invariant (ADR-024) ───────────────────
1205    //
1206    // OTel spans must never contain plaintext secret values or plaintext key
1207    // names. This test verifies that the span fields produced by instrumented
1208    // code in tsafe-core use `skip(password, ...)`, `skip(key, ...)`, and
1209    // `skip(value, ...)` attributes and that no secret-bearing parameter name
1210    // appears as a span field. The invariant applies with equal force to OTel
1211    // spans as to CloudEvents (ADR-024).
1212    //
1213    // This is a static verification test: it checks the known span field names
1214    // that the `#[instrument]` macros in tsafe-core actually record. If a new
1215    // `#[instrument]` call is added that records a secret-bearing field, this
1216    // test must be updated to demonstrate the field is safe.
1217
1218    #[test]
1219    fn otel_span_fields_do_not_include_secret_bearing_names() {
1220        // These are the known span field names from `#[instrument]` calls in
1221        // tsafe-core. Fields that could carry secret material are explicitly
1222        // skipped via `skip(...)` in the macro invocation.
1223        //
1224        // derive_key:    #[instrument(skip(password, salt), fields(m_cost, t_cost, p_cost))]
1225        // aes_encrypt:   #[instrument(skip_all, fields(plaintext_len = plaintext.len()))]
1226        // aes_decrypt:   #[instrument(skip_all, fields(ciphertext_len = ciphertext.len()))]
1227        // Vault::open:   #[instrument(skip(password, path))]
1228        // Vault::save:   #[instrument(skip(password, path))]
1229        // Vault::set:    #[instrument(skip(self, value, tags, key))]
1230        // Vault::delete: #[instrument(skip(self, key))]
1231        // Vault::rotate: #[instrument(skip(self, new_password), fields(secret_count = ...))]
1232
1233        // The span fields that are actually recorded (not skipped):
1234        let safe_span_fields = [
1235            "m_cost",
1236            "t_cost",
1237            "p_cost",
1238            "plaintext_len",
1239            "ciphertext_len",
1240            "secrets",
1241            "secret_count",
1242        ];
1243
1244        // None of these should ever be a secret-bearing parameter name.
1245        let secret_bearing_names = [
1246            "password",
1247            "new_password",
1248            "salt",
1249            "key",
1250            "value",
1251            "plaintext",
1252            "ciphertext",
1253            "secret",
1254        ];
1255
1256        for safe_field in &safe_span_fields {
1257            for secret_name in &secret_bearing_names {
1258                assert_ne!(
1259                    safe_field, secret_name,
1260                    "span field '{safe_field}' must not be a secret-bearing parameter name"
1261                );
1262            }
1263        }
1264    }
1265}