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