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