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  Omitting allowed_secrets leaves the secret set unrestricted by the contract.
724  Setting allowed_secrets: [] is an explicit no-secret diagnostic contract.
725  If a --keys name is not in allowed_secrets, exec fails with a clear error.
726  When allowed_secrets is set, manifest validation also requires every
727  `required_secret` to appear in `allowed_secrets`.
728
729OVERRIDING CONTRACT VALUES
730  Explicit flags always win over contract values:
731    -p / --profile     overrides contract profile
732    --ns               overrides contract namespace
733    --keys             narrows injection (must be subset of allowed_secrets)
734    --require          adds to contract required_secrets
735    --mode / --minimal override trust_level
736  Those overrides can narrow or restyle execution, but they cannot widen the
737  contract ceiling for allowed secrets or allowed targets.
738
739ERROR MESSAGES
740  "command 'X' is not in allowed_targets"
741    → Add X (or a matching basename) to allowed_targets in .tsafe.yml, or run
742      without --contract.
743  "selected key(s) not allowed by contract"
744    → The --keys list includes a name not in allowed_secrets; fix either.
745  "required secret 'X' not found"
746    → tsafe set X <value>
747
748Docs: docs/features/export-and-exec.md (Authority contracts section)
749
750"#
751    );
752}
753
754fn print_vault_recovery() {
755    print!(
756        r#"
757vault-recovery — options when you forget the master password
758━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
759
760tsafe vaults use Argon2id + XChaCha20-Poly1305. There is no backdoor.
761Recovery depends on which of these you set up before forgetting.
762
7631. Quick unlock / biometric (most common — you may not need your password)
764   tsafe biometric status
765   If enabled, the vault opens without a password:
766     tsafe get any-key
767     tsafe unlock         # if the vault file is currently locked
768
7692. Main vault bridging
770   If another vault stores this profile's password under profile-passwords/<name>:
771     tsafe -p main get profile-passwords/<this-profile>
772
7733. Agent still running
774   If tsafe agent unlock is active in your session, the password is cached:
775     tsafe agent status
776     tsafe get any-key    # will use agent if socket is alive
777
7784. TSAFE_PASSWORD env var
779   If you set it in your shell or CI environment, read it from there.
780   (Remove it from dotfiles, scripts, and CI secrets after recovery.)
781
7825. Password manager
783   Check if you stored the vault password during tsafe init.
784
785If none of the above work, the vault is not recoverable. Encrypted data
786cannot be decrypted without the key. To start over:
787  tsafe -p <profile> init   # creates a new empty vault under that profile
788  re-pull or re-import from any source compiled into this build if configured
789
790"#
791    );
792}
793
794#[cfg(any(
795    feature = "akv-pull",
796    feature = "cloud-pull-aws",
797    feature = "cloud-pull-gcp",
798    feature = "cloud-pull-vault",
799    feature = "cloud-pull-1password",
800    feature = "multi-pull"
801))]
802fn print_pull() {
803    if is_akv_only_pull_build() {
804        print!(
805            r#"
806pull — sync secrets from Azure Key Vault into the local vault
807━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
808
809This build ships the core/default Azure Key Vault pull lane only. tsafe still
810acts as a local-first runtime authority: Azure is a read-only source, secrets
811are synced into the local vault, and `tsafe exec` scopes them to one process.
812Nothing is written back to Azure.
813
814Additional provider pulls (AWS/GCP/Vault/1Password) and multi-source `tsafe pull`
815orchestration are gated non-core surfaces and are not compiled into this binary.
816
817AVAILABLE SOURCES IN THIS BUILD
818
819"#
820        );
821    } else {
822        print!(
823            r#"
824pull — sync secrets from cloud providers into the local vault
825━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
826
827tsafe acts as a local-first runtime authority. Cloud providers are read-only
828sources — secrets are synced in, then tsafe exec scopes them to one process.
829Nothing is written back to the cloud.
830
831AVAILABLE SOURCES IN THIS BUILD
832
833"#
834        );
835    }
836    for source in PULL_SOURCES {
837        println!("  {:<26} {}", source.label, source.command);
838    }
839    if cfg!(feature = "multi-pull") {
840        print!(
841            r#"
842
843ONE COMMAND FOR ALL SOURCES (.tsafe.yml)
844
845  Define all your sources in .tsafe.yml at the repo root:
846
847    pulls:
848      - source: aws
849        region: us-east-1
850        prefix: myapp/
851      - source: gcp
852        project: my-project
853      - source: akv
854        vault_url: https://myvault.vault.azure.net
855
856  Then pull everything with one command:
857
858    tsafe pull
859
860KEY NORMALISATION (all sources)
861
862  Cloud names           →  Local key
863  my-secret             →  MY_SECRET
864  myapp/db-password     →  MYAPP_DB_PASSWORD   (AWS / SSM path)
865  /prod/myapp/api-key   →  PROD_MYAPP_API_KEY  (SSM absolute path)
866  db.password           →  DB_PASSWORD          (GCP dot separator)
867
868OVERWRITE BEHAVIOUR
869
870  By default, existing local secrets are not overwritten:
871    tsafe pull                    # safe: new keys only
872    tsafe pull --overwrite        # replace all matching keys
873
874AFTER PULLING
875
876  Preview what would be injected (no secret values printed):
877    tsafe exec --dry-run
878
879  Run your app:
880    tsafe exec -- npm start
881
882Credential setup: tsafe explain pull-auth
883
884"#
885        );
886    } else {
887        print!(
888            r#"
889
890KEY NORMALISATION
891
892  Provider names are normalised into local vault keys before storage.
893
894OVERWRITE BEHAVIOUR
895
896  By default, existing local secrets are not overwritten:
897    tsafe kv-pull                  # safe: new keys only
898    tsafe kv-pull --overwrite      # replace all matching keys
899
900AFTER PULLING
901
902  Preview what would be injected (no secret values printed):
903    tsafe exec --dry-run
904
905  Run your app:
906    tsafe exec -- npm start
907
908Credential setup: tsafe explain pull-auth
909
910This page is intentionally build-scoped: if you need AWS/GCP/Vault/1Password
911or multi-source `tsafe pull`, use a binary compiled with those gated features.
912
913"#
914        );
915    }
916}
917
918#[cfg(any(
919    feature = "akv-pull",
920    feature = "cloud-pull-aws",
921    feature = "cloud-pull-gcp",
922    feature = "cloud-pull-vault",
923    feature = "cloud-pull-1password",
924    feature = "multi-pull"
925))]
926fn print_pull_auth() {
927    if is_akv_only_pull_build() {
928        print!(
929            r#"
930pull-auth — credential setup for compiled-in pull sources
931━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
932
933This build ships Azure Key Vault pull only. The provider sections below are
934limited to commands that exist in this binary; broader cloud pulls are gated
935non-core surfaces and are omitted here.
936"#
937        );
938    } else {
939        print!(
940            r#"
941pull-auth — credential setup for cloud pull sources
942━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
943
944Each provider has its own credential model. Set the env vars before running
945the pull command compiled into this build.
946"#
947        );
948    }
949    for section in PULL_AUTH_SECTIONS {
950        println!("\n{}\n{}", section.heading, section.body);
951    }
952    if cfg!(feature = "multi-pull") {
953        print!(
954            r#"
955
956TESTING CREDENTIALS
957
958  Dry-run your pull config without writing to the vault:
959    tsafe pull --dry-run
960
961  Check what is in the vault after pulling:
962    tsafe list
963
964"#
965        );
966    } else {
967        print!(
968            r#"
969
970TESTING CREDENTIALS
971
972  Run the provider-specific pull command for this build, then inspect:
973    tsafe list
974
975"#
976        );
977    }
978}
979
980#[cfg(any(
981    feature = "akv-pull",
982    feature = "cloud-pull-aws",
983    feature = "cloud-pull-gcp",
984    feature = "cloud-pull-vault",
985    feature = "cloud-pull-1password",
986    feature = "multi-pull"
987))]
988fn print_pull_reliability() {
989    print!(
990        r#"
991pull-reliability — retries, transient errors, and continuation behavior
992━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
993
994PULL RETRIES
995
996Cloud pull clients retry two classes of failures:
997  · HTTP 429 (throttling): up to 3 retries, honors Retry-After header when present
998  · transient transport failures: up to 5 retries with jittered exponential backoff
999
1000Transient transport examples:
1001  timeout, connection refused, connection reset, temporary network faults
1002
1003JITTERED BACKOFF
1004
1005Retry delay uses exponential backoff with +0..25% jitter to reduce synchronized
1006retry storms when many jobs pull at once.
1007
1008PULL COMMAND ERROR MODES
1009
1010All pull commands now accept:
1011  --on-error fail-all     # default, abort on first provider/source error
1012  --on-error skip-failed  # continue remaining sources, skip failed source
1013  --on-error warn-only    # continue and warn; useful for best-effort sync
1014
1015Examples:
1016 "#
1017    );
1018    #[cfg(feature = "akv-pull")]
1019    println!("  tsafe kv-pull --on-error warn-only");
1020    #[cfg(feature = "cloud-pull-aws")]
1021    println!("  tsafe aws-pull --on-error warn-only");
1022    #[cfg(feature = "cloud-pull-gcp")]
1023    println!("  tsafe gcp-pull --on-error skip-failed");
1024    #[cfg(feature = "multi-pull")]
1025    println!("  tsafe pull --config .tsafe.yml --on-error skip-failed");
1026    if cfg!(feature = "multi-pull") {
1027        print!(
1028            r#"
1029
1030MULTI-SOURCE BEHAVIOR (`tsafe pull`)
1031
1032With skip-failed/warn-only, a failed source does not stop later sources.
1033The command prints per-source failures and a final failure count.
1034"#
1035        );
1036    }
1037    print!(
1038        r#"
1039
1040OPERATOR GUIDANCE
1041
1042Use fail-all for CI gates that require every source to succeed.
1043Use skip-failed for mixed environments where some sources are optional.
1044Use warn-only for local/dev best-effort hydration.
1045
1046"#
1047    );
1048    if is_akv_only_pull_build() {
1049        print!(
1050            r#"
1051This binary only exposes the Azure Key Vault pull lane. Multi-source continuation
1052semantics for `tsafe pull` are a gated non-core surface and are not available here.
1053
1054"#
1055        );
1056    }
1057}