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