1use clap::ValueEnum;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum ExplainTopicSurface {
7 AlwaysAvailable,
8 CoreDefault,
9 GatedNonCore,
10}
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
14pub enum ExplainTopic {
15 Exec,
17 Export,
19 Namespaces,
21 #[cfg(feature = "agent")]
23 Agent,
24 #[value(name = "exec-security")]
26 ExecSecurity,
27 Contracts,
29 #[value(name = "vault-recovery")]
31 VaultRecovery,
32 #[value(name = "one-time-secret")]
34 #[cfg(feature = "ots-sharing")]
35 OneTimeSecret,
36 #[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 #[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 #[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
491pub 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}