Skip to main content

tsafe_cli/
explain.rs

1//! In-CLI documentation for concepts (`tsafe explain <topic>`).
2
3use clap::ValueEnum;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum ExplainTopicSurface {
7    AlwaysAvailable,
8    CoreDefault,
9    GatedNonCore,
10}
11
12/// Topics for `tsafe explain`.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
14pub enum ExplainTopic {
15    /// Run commands with vault secrets as environment variables (main workflow).
16    Exec,
17    /// Print or pipe secrets without a subprocess (`export`, `get`).
18    Export,
19    /// Per-project key prefixes (`ns/KEY`) and `tsafe exec --ns`.
20    Namespaces,
21    /// Background unlock session (`tsafe agent`) for fewer password prompts.
22    #[cfg(feature = "agent")]
23    Agent,
24    /// How `exec` handles env inheritance, stripping, and risky variable names.
25    #[value(name = "exec-security")]
26    ExecSecurity,
27    /// Named authority contracts in `.tsafe.yml` — reusable exec policy.
28    Contracts,
29    /// Vault recovery options when you forget the master password.
30    #[value(name = "vault-recovery")]
31    VaultRecovery,
32    /// Pull secrets from cloud providers into the local vault.
33    #[cfg(any(
34        feature = "akv-pull",
35        feature = "cloud-pull-aws",
36        feature = "cloud-pull-gcp",
37        feature = "cloud-pull-vault",
38        feature = "cloud-pull-1password",
39        feature = "multi-pull"
40    ))]
41    Pull,
42    /// Cloud pull credential setup (AWS, GCP, Azure, HashiCorp, 1Password).
43    #[value(name = "pull-auth")]
44    #[cfg(any(
45        feature = "akv-pull",
46        feature = "cloud-pull-aws",
47        feature = "cloud-pull-gcp",
48        feature = "cloud-pull-vault",
49        feature = "cloud-pull-1password",
50        feature = "multi-pull"
51    ))]
52    PullAuth,
53    /// Pull retry behavior and failure-handling modes.
54    #[value(name = "pull-reliability")]
55    #[cfg(any(
56        feature = "akv-pull",
57        feature = "cloud-pull-aws",
58        feature = "cloud-pull-gcp",
59        feature = "cloud-pull-vault",
60        feature = "cloud-pull-1password",
61        feature = "multi-pull"
62    ))]
63    PullReliability,
64}
65
66impl ExplainTopic {
67    pub const fn surface(self) -> ExplainTopicSurface {
68        match self {
69            ExplainTopic::Exec
70            | ExplainTopic::Export
71            | ExplainTopic::Namespaces
72            | ExplainTopic::ExecSecurity
73            | ExplainTopic::Contracts
74            | ExplainTopic::VaultRecovery => ExplainTopicSurface::AlwaysAvailable,
75            #[cfg(feature = "agent")]
76            ExplainTopic::Agent => ExplainTopicSurface::CoreDefault,
77            #[cfg(any(
78                feature = "akv-pull",
79                feature = "cloud-pull-aws",
80                feature = "cloud-pull-gcp",
81                feature = "cloud-pull-vault",
82                feature = "cloud-pull-1password",
83                feature = "multi-pull"
84            ))]
85            ExplainTopic::Pull | ExplainTopic::PullAuth | ExplainTopic::PullReliability => {
86                pull_topic_surface()
87            }
88        }
89    }
90
91    pub const fn as_str(self) -> &'static str {
92        match self {
93            ExplainTopic::Exec => "exec",
94            ExplainTopic::Export => "export",
95            ExplainTopic::Namespaces => "namespaces",
96            #[cfg(feature = "agent")]
97            ExplainTopic::Agent => "agent",
98            ExplainTopic::ExecSecurity => "exec-security",
99            ExplainTopic::Contracts => "contracts",
100            ExplainTopic::VaultRecovery => "vault-recovery",
101            #[cfg(any(
102                feature = "akv-pull",
103                feature = "cloud-pull-aws",
104                feature = "cloud-pull-gcp",
105                feature = "cloud-pull-vault",
106                feature = "cloud-pull-1password",
107                feature = "multi-pull"
108            ))]
109            ExplainTopic::Pull => "pull",
110            #[cfg(any(
111                feature = "akv-pull",
112                feature = "cloud-pull-aws",
113                feature = "cloud-pull-gcp",
114                feature = "cloud-pull-vault",
115                feature = "cloud-pull-1password",
116                feature = "multi-pull"
117            ))]
118            ExplainTopic::PullAuth => "pull-auth",
119            #[cfg(any(
120                feature = "akv-pull",
121                feature = "cloud-pull-aws",
122                feature = "cloud-pull-gcp",
123                feature = "cloud-pull-vault",
124                feature = "cloud-pull-1password",
125                feature = "multi-pull"
126            ))]
127            ExplainTopic::PullReliability => "pull-reliability",
128        }
129    }
130
131    pub fn blurb(self) -> &'static str {
132        match self {
133            ExplainTopic::Exec => "inject secrets into one child process (primary workflow)",
134            ExplainTopic::Export => "print secrets to stdout / shell (secondary)",
135            ExplainTopic::Namespaces => "isolate keys per app or repo in one vault",
136            #[cfg(feature = "agent")]
137            ExplainTopic::Agent => "keep vault unlocked for a TTL; reuse across commands",
138            ExplainTopic::ExecSecurity => {
139                "inheritance, stripped vars, NODE_OPTIONS / LD_* warnings"
140            }
141            ExplainTopic::Contracts => {
142                "named exec policy in .tsafe.yml (allowed secrets, targets, trust level)"
143            }
144            ExplainTopic::VaultRecovery => "options when you forget the master password",
145            #[cfg(any(
146                feature = "akv-pull",
147                feature = "cloud-pull-aws",
148                feature = "cloud-pull-gcp",
149                feature = "cloud-pull-vault",
150                feature = "cloud-pull-1password",
151                feature = "multi-pull"
152            ))]
153            ExplainTopic::Pull => {
154                if has_non_azure_pull_family() {
155                    "sync secrets from compiled-in pull sources into the local vault"
156                } else {
157                    "sync secrets from Azure Key Vault into the local vault"
158                }
159            }
160            #[cfg(any(
161                feature = "akv-pull",
162                feature = "cloud-pull-aws",
163                feature = "cloud-pull-gcp",
164                feature = "cloud-pull-vault",
165                feature = "cloud-pull-1password",
166                feature = "multi-pull"
167            ))]
168            ExplainTopic::PullAuth => {
169                if has_non_azure_pull_family() {
170                    "credential setup for compiled-in pull sources"
171                } else {
172                    "credential setup for Azure Key Vault pull"
173                }
174            }
175            #[cfg(any(
176                feature = "akv-pull",
177                feature = "cloud-pull-aws",
178                feature = "cloud-pull-gcp",
179                feature = "cloud-pull-vault",
180                feature = "cloud-pull-1password",
181                feature = "multi-pull"
182            ))]
183            ExplainTopic::PullReliability => "retries, transient failures, and --on-error modes",
184        }
185    }
186}
187
188fn surface_label(surface: ExplainTopicSurface) -> &'static str {
189    match surface {
190        ExplainTopicSurface::AlwaysAvailable => "always",
191        ExplainTopicSurface::CoreDefault => "core/default",
192        ExplainTopicSurface::GatedNonCore => "gated non-core",
193    }
194}
195
196#[cfg(any(
197    feature = "akv-pull",
198    feature = "cloud-pull-aws",
199    feature = "cloud-pull-gcp",
200    feature = "cloud-pull-vault",
201    feature = "cloud-pull-1password",
202    feature = "multi-pull"
203))]
204const fn pull_topic_surface() -> ExplainTopicSurface {
205    if cfg!(feature = "akv-pull")
206        && !cfg!(feature = "cloud-pull-aws")
207        && !cfg!(feature = "cloud-pull-gcp")
208        && !cfg!(feature = "cloud-pull-vault")
209        && !cfg!(feature = "cloud-pull-1password")
210        && !cfg!(feature = "multi-pull")
211    {
212        ExplainTopicSurface::CoreDefault
213    } else {
214        ExplainTopicSurface::GatedNonCore
215    }
216}
217
218pub static ALL_TOPICS: &[ExplainTopic] = &[
219    ExplainTopic::Exec,
220    ExplainTopic::Export,
221    ExplainTopic::Namespaces,
222    #[cfg(feature = "agent")]
223    ExplainTopic::Agent,
224    ExplainTopic::ExecSecurity,
225    ExplainTopic::Contracts,
226    ExplainTopic::VaultRecovery,
227    #[cfg(any(
228        feature = "akv-pull",
229        feature = "cloud-pull-aws",
230        feature = "cloud-pull-gcp",
231        feature = "cloud-pull-vault",
232        feature = "cloud-pull-1password",
233        feature = "multi-pull"
234    ))]
235    ExplainTopic::Pull,
236    #[cfg(any(
237        feature = "akv-pull",
238        feature = "cloud-pull-aws",
239        feature = "cloud-pull-gcp",
240        feature = "cloud-pull-vault",
241        feature = "cloud-pull-1password",
242        feature = "multi-pull"
243    ))]
244    ExplainTopic::PullAuth,
245    #[cfg(any(
246        feature = "akv-pull",
247        feature = "cloud-pull-aws",
248        feature = "cloud-pull-gcp",
249        feature = "cloud-pull-vault",
250        feature = "cloud-pull-1password",
251        feature = "multi-pull"
252    ))]
253    ExplainTopic::PullReliability,
254];
255
256#[cfg(any(
257    feature = "akv-pull",
258    feature = "cloud-pull-aws",
259    feature = "cloud-pull-gcp",
260    feature = "cloud-pull-vault",
261    feature = "cloud-pull-1password",
262    feature = "multi-pull"
263))]
264struct PullSourceDoc {
265    label: &'static str,
266    command: &'static str,
267}
268
269#[cfg(any(
270    feature = "akv-pull",
271    feature = "cloud-pull-aws",
272    feature = "cloud-pull-gcp",
273    feature = "cloud-pull-vault",
274    feature = "cloud-pull-1password",
275    feature = "multi-pull"
276))]
277static PULL_SOURCES: &[PullSourceDoc] = &[
278    #[cfg(feature = "akv-pull")]
279    PullSourceDoc {
280        label: "Azure Key Vault",
281        command: "tsafe kv-pull",
282    },
283    #[cfg(feature = "cloud-pull-aws")]
284    PullSourceDoc {
285        label: "AWS Secrets Manager",
286        command: "tsafe aws-pull",
287    },
288    #[cfg(feature = "cloud-pull-aws")]
289    PullSourceDoc {
290        label: "AWS SSM Parameter Store",
291        command: "tsafe ssm-pull",
292    },
293    #[cfg(feature = "cloud-pull-gcp")]
294    PullSourceDoc {
295        label: "GCP Secret Manager",
296        command: "tsafe gcp-pull",
297    },
298    #[cfg(feature = "cloud-pull-vault")]
299    PullSourceDoc {
300        label: "HashiCorp Vault KV v2",
301        command: "tsafe vault-pull",
302    },
303    #[cfg(feature = "cloud-pull-1password")]
304    PullSourceDoc {
305        label: "1Password",
306        command: "tsafe op-pull",
307    },
308];
309
310#[cfg(any(
311    feature = "akv-pull",
312    feature = "cloud-pull-aws",
313    feature = "cloud-pull-gcp",
314    feature = "cloud-pull-vault",
315    feature = "cloud-pull-1password",
316    feature = "multi-pull"
317))]
318struct PullAuthSection {
319    heading: &'static str,
320    body: &'static str,
321}
322
323#[cfg(any(
324    feature = "akv-pull",
325    feature = "cloud-pull-aws",
326    feature = "cloud-pull-gcp",
327    feature = "cloud-pull-vault",
328    feature = "cloud-pull-1password",
329    feature = "multi-pull"
330))]
331static PULL_AUTH_SECTIONS: &[PullAuthSection] = &[
332    #[cfg(feature = "cloud-pull-aws")]
333    PullAuthSection {
334        heading: "AWS SECRETS MANAGER  (tsafe aws-pull)",
335        body: r#"  Static credentials (local dev):
336    export AWS_ACCESS_KEY_ID=AKIA...
337    export AWS_SECRET_ACCESS_KEY=...
338    export AWS_DEFAULT_REGION=us-east-1
339
340  Temporary credentials (assumed role / SSO):
341    eval $(aws sts assume-role ... --query Credentials --output text | ...)
342    # or: aws configure sso, then run tsafe — SDK picks up the profile
343
344  EC2 / ECS / Lambda — nothing to set; IMDSv2 / ECS task role used automatically."#,
345    },
346    #[cfg(feature = "cloud-pull-aws")]
347    PullAuthSection {
348        heading: "AWS SSM PARAMETER STORE  (tsafe ssm-pull)",
349        body: r#"  Same credentials as Secrets Manager. The IAM policy needs:
350    ssm:GetParametersByPath  on arn:aws:ssm:REGION:ACCOUNT:parameter/PATH*
351    kms:Decrypt             on the KMS key for SecureString parameters"#,
352    },
353    #[cfg(feature = "cloud-pull-gcp")]
354    PullAuthSection {
355        heading: "GCP SECRET MANAGER  (tsafe gcp-pull)",
356        body: r#"  Local dev (gcloud CLI):
357    gcloud auth application-default login
358    export GOOGLE_CLOUD_PROJECT=my-project
359
360  CI / short-lived token:
361    export GOOGLE_OAUTH_TOKEN=$(gcloud auth print-access-token)
362    export GOOGLE_CLOUD_PROJECT=my-project
363
364  GKE / Cloud Run / GCE — metadata server used automatically.
365  Required IAM role: roles/secretmanager.secretAccessor on the project."#,
366    },
367    #[cfg(feature = "akv-pull")]
368    PullAuthSection {
369        heading: "AZURE KEY VAULT  (tsafe kv-pull)",
370        body: r#"  Service principal (CI):
371    export AZURE_TENANT_ID=...
372    export AZURE_CLIENT_ID=...
373    export AZURE_CLIENT_SECRET=...
374    export TSAFE_AKV_URL=https://myvault.vault.azure.net
375
376  Azure VM / ACI — IMDS managed identity used automatically.
377  Required role: Key Vault Secrets User on the vault."#,
378    },
379    #[cfg(feature = "cloud-pull-vault")]
380    PullAuthSection {
381        heading: "HASHICORP VAULT  (tsafe vault-pull)",
382        body: r#"    export VAULT_TOKEN=hvs....
383    export TSAFE_HCP_URL=http://vault:8200  # or use --addr"#,
384    },
385    #[cfg(feature = "cloud-pull-1password")]
386    PullAuthSection {
387        heading: "1PASSWORD  (tsafe op-pull)",
388        body: r#"  Install the op CLI and authenticate:
389    op signin
390    tsafe op-pull "Database Credentials""#,
391    },
392];
393
394#[cfg(any(
395    feature = "akv-pull",
396    feature = "cloud-pull-aws",
397    feature = "cloud-pull-gcp",
398    feature = "cloud-pull-vault",
399    feature = "cloud-pull-1password",
400    feature = "multi-pull"
401))]
402fn has_non_azure_pull_family() -> bool {
403    cfg!(feature = "cloud-pull-aws")
404        || cfg!(feature = "cloud-pull-gcp")
405        || cfg!(feature = "cloud-pull-vault")
406        || cfg!(feature = "cloud-pull-1password")
407        || cfg!(feature = "multi-pull")
408}
409
410#[cfg(any(
411    feature = "akv-pull",
412    feature = "cloud-pull-aws",
413    feature = "cloud-pull-gcp",
414    feature = "cloud-pull-vault",
415    feature = "cloud-pull-1password",
416    feature = "multi-pull"
417))]
418fn is_akv_only_pull_build() -> bool {
419    cfg!(feature = "akv-pull") && !has_non_azure_pull_family()
420}
421
422fn print_compiled_truth() {
423    println!("\nCompiled truth for this binary:\n");
424
425    #[cfg(feature = "agent")]
426    println!(
427        "  - `agent` is a core/default workflow here, but the background `tsafe-agent` runtime is still a separate companion binary."
428    );
429    #[cfg(not(feature = "agent"))]
430    println!("  - `agent` help is omitted because this build does not compile the core `agent` workflow.");
431
432    #[cfg(any(
433        feature = "akv-pull",
434        feature = "cloud-pull-aws",
435        feature = "cloud-pull-gcp",
436        feature = "cloud-pull-vault",
437        feature = "cloud-pull-1password",
438        feature = "multi-pull"
439    ))]
440    {
441        if is_akv_only_pull_build() {
442            println!(
443                "  - Cloud pull coverage is Azure Key Vault only (`tsafe kv-pull`); AWS/GCP/Vault/1Password pulls and multi-source `tsafe pull` are not compiled in."
444            );
445        } else {
446            println!(
447                "  - Cloud pull topics describe only the providers and orchestration commands compiled into this binary."
448            );
449        }
450    }
451    #[cfg(not(any(
452        feature = "akv-pull",
453        feature = "cloud-pull-aws",
454        feature = "cloud-pull-gcp",
455        feature = "cloud-pull-vault",
456        feature = "cloud-pull-1password",
457        feature = "multi-pull"
458    )))]
459    println!(
460        "  - Cloud pull help is omitted because this build has no pull providers compiled in."
461    );
462
463    #[cfg(not(feature = "nativehost"))]
464    println!(
465        "  - Browser/native-host flows are not compiled into this `tsafe` binary; they require browser/nativehost-enabled builds plus the separate `tsafe-nativehost` artifact."
466    );
467    #[cfg(not(feature = "ssh"))]
468    println!("  - SSH helper commands (`tsafe ssh-add`, `tsafe ssh-import`) are not compiled into this build.");
469    #[cfg(not(feature = "plugins"))]
470    println!("  - The plugin command surface (`tsafe plugin`) is not compiled into this build.");
471    #[cfg(not(feature = "git-helpers"))]
472    println!(
473        "  - Git helper flows such as `tsafe credential-helper` are not compiled into this build."
474    );
475}
476
477/// Print help for `tsafe explain` (list or detailed topic).
478pub fn run(topic: Option<ExplainTopic>) {
479    match topic {
480        None => {
481            println!("Usage: tsafe explain <topic>\n");
482            println!("Topics:\n");
483            for t in ALL_TOPICS {
484                println!(
485                    "  {:<16} [{:<13}] {}",
486                    t.as_str(),
487                    surface_label(t.surface()),
488                    t.blurb()
489                );
490            }
491            println!("\nExample: tsafe explain exec");
492            print_compiled_truth();
493        }
494        Some(ExplainTopic::Exec) => print_exec(),
495        Some(ExplainTopic::Export) => print_export(),
496        Some(ExplainTopic::Namespaces) => print_namespaces(),
497        #[cfg(feature = "agent")]
498        Some(ExplainTopic::Agent) => print_agent(),
499        Some(ExplainTopic::ExecSecurity) => print_exec_security(),
500        Some(ExplainTopic::Contracts) => print_contracts(),
501        Some(ExplainTopic::VaultRecovery) => print_vault_recovery(),
502        #[cfg(any(
503            feature = "akv-pull",
504            feature = "cloud-pull-aws",
505            feature = "cloud-pull-gcp",
506            feature = "cloud-pull-vault",
507            feature = "cloud-pull-1password",
508            feature = "multi-pull"
509        ))]
510        Some(ExplainTopic::Pull) => print_pull(),
511        #[cfg(any(
512            feature = "akv-pull",
513            feature = "cloud-pull-aws",
514            feature = "cloud-pull-gcp",
515            feature = "cloud-pull-vault",
516            feature = "cloud-pull-1password",
517            feature = "multi-pull"
518        ))]
519        Some(ExplainTopic::PullAuth) => print_pull_auth(),
520        #[cfg(any(
521            feature = "akv-pull",
522            feature = "cloud-pull-aws",
523            feature = "cloud-pull-gcp",
524            feature = "cloud-pull-vault",
525            feature = "cloud-pull-1password",
526            feature = "multi-pull"
527        ))]
528        Some(ExplainTopic::PullReliability) => print_pull_reliability(),
529    }
530}
531
532fn print_exec() {
533    print!(
534        r#"
535exec — run a program with vault secrets as normal environment variables
536━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
537
538This is the default way to use tsafe day-to-day. Your app keeps reading
539process.env / %ENV% like every tutorial; you do not need a plaintext .env
540on disk for that run.
541
542  tsafe exec -- <command> [args...]
543
544Default scope
545  Without --contract / --keys / --ns, exec injects every non-alias secret
546  in the active vault into the child environment. Parent ambient credentials
547  are still stripped via the SENSITIVE_VARS strip list and dangerous-name
548  vault entries (LD_PRELOAD, NODE_OPTIONS, ...) still abort the spawn — but
549  the *vault scope* is the whole vault until you adopt a contract or one of
550  the narrowing flags. For least-authority workflows, see `tsafe explain
551  contracts`.
552
553Preview which names would be set (sorted, values never printed):
554
555  tsafe exec --dry-run
556
557Fail before starting if required keys are missing (after --ns mapping):
558
559  tsafe exec --require API_KEY,DB_URL -- npm test
560
561Namespaces — only inject keys under a prefix; child sees names without it:
562
563  tsafe exec --ns myapp -- python app.py
564
565Shell: always put a bare "--" before the real command so flags are not parsed
566by tsafe. Quote carefully so the shell does not expand $VAR before the child.
567
568Layering: if you use direnv, Docker, or CI-injected env, you may have both
569parent env and vault keys. Parent tokens (GITHUB_TOKEN, ADO_PAT, …) are
570stripped from the child before injection so they do not leak accidentally.
571Use `tsafe explain exec-security` for the shipped inheritance presets and
572`--allow-dangerous-env` / `--no-inherit` / `--only` details.
573
574Docs: docs/features/export-and-exec.md · tsafe explain exec-security
575
576"#
577    );
578}
579
580fn print_export() {
581    print!(
582        r#"
583export / get — when not to use exec
584━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
585
586Use `tsafe export` or `tsafe get` when something must read secrets from
587stdout, a file, or the clipboard — not from a subprocess environment.
588
589Examples:
590  · CI: tsafe export --format github-actions >> $GITHUB_ENV
591  · Shell session: eval "$(tsafe export --format dotenv)"  (widens blast radius)
592  · One-off: tsafe get API_KEY --copy
593
594Prefer `exec` when the consumer is a single command: no plaintext hop through
595a file or your interactive shell.
596
597"#
598    );
599}
600
601fn print_namespaces() {
602    print!(
603        r#"
604namespaces — multiple logical apps in one vault
605━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
606
607Store keys as "ns/KEY" (e.g. web/DB_URL and batch/DB_URL). List and export
608can filter with --ns.
609
610With exec, --ns injects only matching keys and strips the prefix so the
611child sees plain DB_URL.
612
613  tsafe set web/API_KEY --tag env=dev
614  tsafe exec --ns web -- npm run dev
615
616Bulk copy or rename every key under a prefix (clone or migrate a namespace):
617
618  tsafe ns copy prod staging          # prod/* also exists under staging/*
619  tsafe ns move oldapp newapp         # oldapp/* → newapp/* (sources removed)
620  tsafe ns copy a b --force           # overwrite destination keys if present
621
622Single-key rename still uses: tsafe mv FROM_KEY TO_KEY
623
624"#
625    );
626}
627
628#[cfg(feature = "agent")]
629fn print_agent() {
630    print!(
631        r#"
632agent — fewer password prompts
633━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
634
635`tsafe agent unlock` starts a session (TTL, e.g. 30m). While active, CLI and
636tools that use the agent socket can open the vault without typing the master
637password each time.
638
639  tsafe agent unlock --ttl 30m
640  tsafe exec -- npm test
641  tsafe agent lock
642
643See: tsafe agent --help
644
645"#
646    );
647}
648
649fn print_exec_security() {
650    print!(
651        r#"
652exec-security — what exec does not guarantee
653━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
654
655Inheritance
656  `standard` starts from your current environment, strips sensitive parent
657  variables, then adds vault keys. `hardened` uses a minimal inherited env;
658  `--no-inherit` starts clean; `--only` whitelists explicit parent names.
659  Sensitive parent variables (TSAFE_PASSWORD, cloud tokens, PATs, etc.) are
660  removed before spawn — see SENSITIVE_VARS in crates/tsafe-core/src/env.rs.
661
662High-risk names
663  If a vault key would set NODE_OPTIONS, LD_PRELOAD, or macOS DYLD_* (among
664  others), tsafe aborts exec by default. Use `--allow-dangerous-env` to inject
665  them with a warning instead. `--deny-dangerous-env` is now just an explicit
666  compatibility spelling of the default.
667
668Same-user threat model
669  On Unix, environment is not a hard security boundary against malware or
670  debuggers running as you. exec scopes secrets to one process tree and
671  avoids argv and dotfiles; it does not replace OS-level isolation.
672
673Docs: docs/features/security.md · docs/research/exec-as-product-2026-04.md
674
675"#
676    );
677}
678
679fn print_contracts() {
680    print!(
681        r#"
682contracts — named, reusable exec authority in .tsafe.yml
683━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
684
685A contract replaces ad-hoc flag bundles with a named, auditable authority
686definition committed to your repository. Contracts answer in one command:
687"what exactly am I allowing this process to receive?"
688
689MANIFEST (.tsafe.yml in your project root)
690  contracts:
691    deploy:
692      profile: prod          # which vault to open (overridden by -p)
693      namespace: deploy      # --ns equivalent
694      allowed_secrets:       # injection ceiling — only these keys pass through
695        - ARM_SUBSCRIPTION_ID
696        - TF_BACKEND_KEY
697      required_secrets:      # fail before spawn if any are missing
698        - TF_BACKEND_KEY
699      allowed_targets:       # which binaries this contract may run
700        - terraform
701      trust_level: hardened  # standard | hardened | custom
702
703TRUST LEVELS
704  standard   — full inherited env minus strip list; dangerous names denied
705  hardened   — minimal env, dangerous names denied, child output redacted
706  custom     — contract-defined `inherit`, `deny_dangerous_env`, and
707               `redact_output` settings in the manifest
708
709USAGE
710  tsafe exec --contract deploy -- terraform apply
711
712  Without --contract, --keys, or --ns, exec injects EVERY non-alias secret
713  in the active vault (parent strip and dangerous-name guard still apply).
714  --contract is the recommended primary model and the only way to declare
715  the authorisation set up front; flags (--keys, --ns) are escape hatches.
716
717PREVIEW AUTHORITY (no secrets ever printed)
718  tsafe exec --contract deploy --plan
719
720WHAT GETS INJECTED
721  Intersection of: vault keys ∩ allowed_secrets (if set) ∩ --keys (if set)
722  Union of:        required_secrets + --require
723  If a --keys name is not in allowed_secrets, exec fails with a clear error.
724  Manifest validation also requires every `required_secret` to appear in
725  `allowed_secrets`.
726
727OVERRIDING CONTRACT VALUES
728  Explicit flags always win over contract values:
729    -p / --profile     overrides contract profile
730    --ns               overrides contract namespace
731    --keys             narrows injection (must be subset of allowed_secrets)
732    --require          adds to contract required_secrets
733    --mode / --minimal override trust_level
734  Those overrides can narrow or restyle execution, but they cannot widen the
735  contract ceiling for allowed secrets or allowed targets.
736
737ERROR MESSAGES
738  "command 'X' is not in allowed_targets"
739    → Add X (or a matching basename) to allowed_targets in .tsafe.yml, or run
740      without --contract.
741  "selected key(s) not allowed by contract"
742    → The --keys list includes a name not in allowed_secrets; fix either.
743  "required secret 'X' not found"
744    → tsafe set X <value>
745
746Docs: docs/features/export-and-exec.md (Authority contracts section)
747
748"#
749    );
750}
751
752fn print_vault_recovery() {
753    print!(
754        r#"
755vault-recovery — options when you forget the master password
756━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
757
758tsafe vaults use Argon2id + XChaCha20-Poly1305. There is no backdoor.
759Recovery depends on which of these you set up before forgetting.
760
7611. Quick unlock / biometric (most common — you may not need your password)
762   tsafe biometric status
763   If enabled, the vault opens without a password:
764     tsafe get any-key
765     tsafe unlock         # if the vault file is currently locked
766
7672. Main vault bridging
768   If another vault stores this profile's password under profile-passwords/<name>:
769     tsafe -p main get profile-passwords/<this-profile>
770
7713. Agent still running
772   If tsafe agent unlock is active in your session, the password is cached:
773     tsafe agent status
774     tsafe get any-key    # will use agent if socket is alive
775
7764. TSAFE_PASSWORD env var
777   If you set it in your shell or CI environment, read it from there.
778   (Remove it from dotfiles, scripts, and CI secrets after recovery.)
779
7805. Password manager
781   Check if you stored the vault password during tsafe init.
782
783If none of the above work, the vault is not recoverable. Encrypted data
784cannot be decrypted without the key. To start over:
785  tsafe -p <profile> init   # creates a new empty vault under that profile
786  re-pull or re-import from any source compiled into this build if configured
787
788"#
789    );
790}
791
792#[cfg(any(
793    feature = "akv-pull",
794    feature = "cloud-pull-aws",
795    feature = "cloud-pull-gcp",
796    feature = "cloud-pull-vault",
797    feature = "cloud-pull-1password",
798    feature = "multi-pull"
799))]
800fn print_pull() {
801    if is_akv_only_pull_build() {
802        print!(
803            r#"
804pull — sync secrets from Azure Key Vault into the local vault
805━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
806
807This build ships the core/default Azure Key Vault pull lane only. tsafe still
808acts as a local-first runtime authority: Azure is a read-only source, secrets
809are synced into the local vault, and `tsafe exec` scopes them to one process.
810Nothing is written back to Azure.
811
812Additional provider pulls (AWS/GCP/Vault/1Password) and multi-source `tsafe pull`
813orchestration are gated non-core surfaces and are not compiled into this binary.
814
815AVAILABLE SOURCES IN THIS BUILD
816
817"#
818        );
819    } else {
820        print!(
821            r#"
822pull — sync secrets from cloud providers into the local vault
823━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
824
825tsafe acts as a local-first runtime authority. Cloud providers are read-only
826sources — secrets are synced in, then tsafe exec scopes them to one process.
827Nothing is written back to the cloud.
828
829AVAILABLE SOURCES IN THIS BUILD
830
831"#
832        );
833    }
834    for source in PULL_SOURCES {
835        println!("  {:<26} {}", source.label, source.command);
836    }
837    if cfg!(feature = "multi-pull") {
838        print!(
839            r#"
840
841ONE COMMAND FOR ALL SOURCES (.tsafe.yml)
842
843  Define all your sources in .tsafe.yml at the repo root:
844
845    pulls:
846      - source: aws
847        region: us-east-1
848        prefix: myapp/
849      - source: gcp
850        project: my-project
851      - source: akv
852        vault_url: https://myvault.vault.azure.net
853
854  Then pull everything with one command:
855
856    tsafe pull
857
858KEY NORMALISATION (all sources)
859
860  Cloud names           →  Local key
861  my-secret             →  MY_SECRET
862  myapp/db-password     →  MYAPP_DB_PASSWORD   (AWS / SSM path)
863  /prod/myapp/api-key   →  PROD_MYAPP_API_KEY  (SSM absolute path)
864  db.password           →  DB_PASSWORD          (GCP dot separator)
865
866OVERWRITE BEHAVIOUR
867
868  By default, existing local secrets are not overwritten:
869    tsafe pull                    # safe: new keys only
870    tsafe pull --overwrite        # replace all matching keys
871
872AFTER PULLING
873
874  Preview what would be injected (no secret values printed):
875    tsafe exec --dry-run
876
877  Run your app:
878    tsafe exec -- npm start
879
880Credential setup: tsafe explain pull-auth
881
882"#
883        );
884    } else {
885        print!(
886            r#"
887
888KEY NORMALISATION
889
890  Provider names are normalised into local vault keys before storage.
891
892OVERWRITE BEHAVIOUR
893
894  By default, existing local secrets are not overwritten:
895    tsafe kv-pull                  # safe: new keys only
896    tsafe kv-pull --overwrite      # replace all matching keys
897
898AFTER PULLING
899
900  Preview what would be injected (no secret values printed):
901    tsafe exec --dry-run
902
903  Run your app:
904    tsafe exec -- npm start
905
906Credential setup: tsafe explain pull-auth
907
908This page is intentionally build-scoped: if you need AWS/GCP/Vault/1Password
909or multi-source `tsafe pull`, use a binary compiled with those gated features.
910
911"#
912        );
913    }
914}
915
916#[cfg(any(
917    feature = "akv-pull",
918    feature = "cloud-pull-aws",
919    feature = "cloud-pull-gcp",
920    feature = "cloud-pull-vault",
921    feature = "cloud-pull-1password",
922    feature = "multi-pull"
923))]
924fn print_pull_auth() {
925    if is_akv_only_pull_build() {
926        print!(
927            r#"
928pull-auth — credential setup for compiled-in pull sources
929━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
930
931This build ships Azure Key Vault pull only. The provider sections below are
932limited to commands that exist in this binary; broader cloud pulls are gated
933non-core surfaces and are omitted here.
934"#
935        );
936    } else {
937        print!(
938            r#"
939pull-auth — credential setup for cloud pull sources
940━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
941
942Each provider has its own credential model. Set the env vars before running
943the pull command compiled into this build.
944"#
945        );
946    }
947    for section in PULL_AUTH_SECTIONS {
948        println!("\n{}\n{}", section.heading, section.body);
949    }
950    if cfg!(feature = "multi-pull") {
951        print!(
952            r#"
953
954TESTING CREDENTIALS
955
956  Dry-run your pull config without writing to the vault:
957    tsafe pull --dry-run
958
959  Check what is in the vault after pulling:
960    tsafe list
961
962"#
963        );
964    } else {
965        print!(
966            r#"
967
968TESTING CREDENTIALS
969
970  Run the provider-specific pull command for this build, then inspect:
971    tsafe list
972
973"#
974        );
975    }
976}
977
978#[cfg(any(
979    feature = "akv-pull",
980    feature = "cloud-pull-aws",
981    feature = "cloud-pull-gcp",
982    feature = "cloud-pull-vault",
983    feature = "cloud-pull-1password",
984    feature = "multi-pull"
985))]
986fn print_pull_reliability() {
987    print!(
988        r#"
989pull-reliability — retries, transient errors, and continuation behavior
990━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
991
992PULL RETRIES
993
994Cloud pull clients retry two classes of failures:
995  · HTTP 429 (throttling): up to 3 retries, honors Retry-After header when present
996  · transient transport failures: up to 5 retries with jittered exponential backoff
997
998Transient transport examples:
999  timeout, connection refused, connection reset, temporary network faults
1000
1001JITTERED BACKOFF
1002
1003Retry delay uses exponential backoff with +0..25% jitter to reduce synchronized
1004retry storms when many jobs pull at once.
1005
1006PULL COMMAND ERROR MODES
1007
1008All pull commands now accept:
1009  --on-error fail-all     # default, abort on first provider/source error
1010  --on-error skip-failed  # continue remaining sources, skip failed source
1011  --on-error warn-only    # continue and warn; useful for best-effort sync
1012
1013Examples:
1014 "#
1015    );
1016    #[cfg(feature = "akv-pull")]
1017    println!("  tsafe kv-pull --on-error warn-only");
1018    #[cfg(feature = "cloud-pull-aws")]
1019    println!("  tsafe aws-pull --on-error warn-only");
1020    #[cfg(feature = "cloud-pull-gcp")]
1021    println!("  tsafe gcp-pull --on-error skip-failed");
1022    #[cfg(feature = "multi-pull")]
1023    println!("  tsafe pull --config .tsafe.yml --on-error skip-failed");
1024    if cfg!(feature = "multi-pull") {
1025        print!(
1026            r#"
1027
1028MULTI-SOURCE BEHAVIOR (`tsafe pull`)
1029
1030With skip-failed/warn-only, a failed source does not stop later sources.
1031The command prints per-source failures and a final failure count.
1032"#
1033        );
1034    }
1035    print!(
1036        r#"
1037
1038OPERATOR GUIDANCE
1039
1040Use fail-all for CI gates that require every source to succeed.
1041Use skip-failed for mixed environments where some sources are optional.
1042Use warn-only for local/dev best-effort hydration.
1043
1044"#
1045    );
1046    if is_akv_only_pull_build() {
1047        print!(
1048            r#"
1049This binary only exposes the Azure Key Vault pull lane. Multi-source continuation
1050semantics for `tsafe pull` are a gated non-core surface and are not available here.
1051
1052"#
1053        );
1054    }
1055}