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 #[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 #[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 #[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
477pub fn run(topic: Option<ExplainTopic>) {
479 match topic {
480 None => {
481 println!("Usage: tsafe explain <topic>\n");
482 println!("Topics:\n");
483 for t in ALL_TOPICS {
484 println!(
485 " {:<16} [{:<13}] {}",
486 t.as_str(),
487 surface_label(t.surface()),
488 t.blurb()
489 );
490 }
491 println!("\nExample: tsafe explain exec");
492 print_compiled_truth();
493 }
494 Some(ExplainTopic::Exec) => print_exec(),
495 Some(ExplainTopic::Export) => print_export(),
496 Some(ExplainTopic::Namespaces) => print_namespaces(),
497 #[cfg(feature = "agent")]
498 Some(ExplainTopic::Agent) => print_agent(),
499 Some(ExplainTopic::ExecSecurity) => print_exec_security(),
500 Some(ExplainTopic::Contracts) => print_contracts(),
501 Some(ExplainTopic::VaultRecovery) => print_vault_recovery(),
502 #[cfg(any(
503 feature = "akv-pull",
504 feature = "cloud-pull-aws",
505 feature = "cloud-pull-gcp",
506 feature = "cloud-pull-vault",
507 feature = "cloud-pull-1password",
508 feature = "multi-pull"
509 ))]
510 Some(ExplainTopic::Pull) => print_pull(),
511 #[cfg(any(
512 feature = "akv-pull",
513 feature = "cloud-pull-aws",
514 feature = "cloud-pull-gcp",
515 feature = "cloud-pull-vault",
516 feature = "cloud-pull-1password",
517 feature = "multi-pull"
518 ))]
519 Some(ExplainTopic::PullAuth) => print_pull_auth(),
520 #[cfg(any(
521 feature = "akv-pull",
522 feature = "cloud-pull-aws",
523 feature = "cloud-pull-gcp",
524 feature = "cloud-pull-vault",
525 feature = "cloud-pull-1password",
526 feature = "multi-pull"
527 ))]
528 Some(ExplainTopic::PullReliability) => print_pull_reliability(),
529 }
530}
531
532fn print_exec() {
533 print!(
534 r#"
535exec — run a program with vault secrets as normal environment variables
536━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
537
538This is the default way to use tsafe day-to-day. Your app keeps reading
539process.env / %ENV% like every tutorial; you do not need a plaintext .env
540on disk for that run.
541
542 tsafe exec -- <command> [args...]
543
544Default scope
545 Without --contract / --keys / --ns, exec injects every non-alias secret
546 in the active vault into the child environment. Parent ambient credentials
547 are still stripped via the SENSITIVE_VARS strip list and dangerous-name
548 vault entries (LD_PRELOAD, NODE_OPTIONS, ...) still abort the spawn — but
549 the *vault scope* is the whole vault until you adopt a contract or one of
550 the narrowing flags. For least-authority workflows, see `tsafe explain
551 contracts`.
552
553Preview which names would be set (sorted, values never printed):
554
555 tsafe exec --dry-run
556
557Fail before starting if required keys are missing (after --ns mapping):
558
559 tsafe exec --require API_KEY,DB_URL -- npm test
560
561Namespaces — only inject keys under a prefix; child sees names without it:
562
563 tsafe exec --ns myapp -- python app.py
564
565Shell: always put a bare "--" before the real command so flags are not parsed
566by tsafe. Quote carefully so the shell does not expand $VAR before the child.
567
568Layering: if you use direnv, Docker, or CI-injected env, you may have both
569parent env and vault keys. Parent tokens (GITHUB_TOKEN, ADO_PAT, …) are
570stripped from the child before injection so they do not leak accidentally.
571Use `tsafe explain exec-security` for the shipped inheritance presets and
572`--allow-dangerous-env` / `--no-inherit` / `--only` details.
573
574Docs: docs/features/export-and-exec.md · tsafe explain exec-security
575
576"#
577 );
578}
579
580fn print_export() {
581 print!(
582 r#"
583export / get — when not to use exec
584━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
585
586Use `tsafe export` or `tsafe get` when something must read secrets from
587stdout, a file, or the clipboard — not from a subprocess environment.
588
589Examples:
590 · CI: tsafe export --format github-actions >> $GITHUB_ENV
591 · Shell session: eval "$(tsafe export --format dotenv)" (widens blast radius)
592 · One-off: tsafe get API_KEY --copy
593
594Prefer `exec` when the consumer is a single command: no plaintext hop through
595a file or your interactive shell.
596
597"#
598 );
599}
600
601fn print_namespaces() {
602 print!(
603 r#"
604namespaces — multiple logical apps in one vault
605━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
606
607Store keys as "ns/KEY" (e.g. web/DB_URL and batch/DB_URL). List and export
608can filter with --ns.
609
610With exec, --ns injects only matching keys and strips the prefix so the
611child sees plain DB_URL.
612
613 tsafe set web/API_KEY --tag env=dev
614 tsafe exec --ns web -- npm run dev
615
616Bulk copy or rename every key under a prefix (clone or migrate a namespace):
617
618 tsafe ns copy prod staging # prod/* also exists under staging/*
619 tsafe ns move oldapp newapp # oldapp/* → newapp/* (sources removed)
620 tsafe ns copy a b --force # overwrite destination keys if present
621
622Single-key rename still uses: tsafe mv FROM_KEY TO_KEY
623
624"#
625 );
626}
627
628#[cfg(feature = "agent")]
629fn print_agent() {
630 print!(
631 r#"
632agent — fewer password prompts
633━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
634
635`tsafe agent unlock` starts a session (TTL, e.g. 30m). While active, CLI and
636tools that use the agent socket can open the vault without typing the master
637password each time.
638
639 tsafe agent unlock --ttl 30m
640 tsafe exec -- npm test
641 tsafe agent lock
642
643See: tsafe agent --help
644
645"#
646 );
647}
648
649fn print_exec_security() {
650 print!(
651 r#"
652exec-security — what exec does not guarantee
653━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
654
655Inheritance
656 `standard` starts from your current environment, strips sensitive parent
657 variables, then adds vault keys. `hardened` uses a minimal inherited env;
658 `--no-inherit` starts clean; `--only` whitelists explicit parent names.
659 Sensitive parent variables (TSAFE_PASSWORD, cloud tokens, PATs, etc.) are
660 removed before spawn — see SENSITIVE_VARS in crates/tsafe-core/src/env.rs.
661
662High-risk names
663 If a vault key would set NODE_OPTIONS, LD_PRELOAD, or macOS DYLD_* (among
664 others), tsafe aborts exec by default. Use `--allow-dangerous-env` to inject
665 them with a warning instead. `--deny-dangerous-env` is now just an explicit
666 compatibility spelling of the default.
667
668Same-user threat model
669 On Unix, environment is not a hard security boundary against malware or
670 debuggers running as you. exec scopes secrets to one process tree and
671 avoids argv and dotfiles; it does not replace OS-level isolation.
672
673Docs: docs/features/security.md · docs/research/exec-as-product-2026-04.md
674
675"#
676 );
677}
678
679fn print_contracts() {
680 print!(
681 r#"
682contracts — named, reusable exec authority in .tsafe.yml
683━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
684
685A contract replaces ad-hoc flag bundles with a named, auditable authority
686definition committed to your repository. Contracts answer in one command:
687"what exactly am I allowing this process to receive?"
688
689MANIFEST (.tsafe.yml in your project root)
690 contracts:
691 deploy:
692 profile: prod # which vault to open (overridden by -p)
693 namespace: deploy # --ns equivalent
694 allowed_secrets: # injection ceiling — only these keys pass through
695 - ARM_SUBSCRIPTION_ID
696 - TF_BACKEND_KEY
697 required_secrets: # fail before spawn if any are missing
698 - TF_BACKEND_KEY
699 allowed_targets: # which binaries this contract may run
700 - terraform
701 trust_level: hardened # standard | hardened | custom
702
703TRUST LEVELS
704 standard — full inherited env minus strip list; dangerous names denied
705 hardened — minimal env, dangerous names denied, child output redacted
706 custom — contract-defined `inherit`, `deny_dangerous_env`, and
707 `redact_output` settings in the manifest
708
709USAGE
710 tsafe exec --contract deploy -- terraform apply
711
712 Without --contract, --keys, or --ns, exec injects EVERY non-alias secret
713 in the active vault (parent strip and dangerous-name guard still apply).
714 --contract is the recommended primary model and the only way to declare
715 the authorisation set up front; flags (--keys, --ns) are escape hatches.
716
717PREVIEW AUTHORITY (no secrets ever printed)
718 tsafe exec --contract deploy --plan
719
720WHAT GETS INJECTED
721 Intersection of: vault keys ∩ allowed_secrets (if set) ∩ --keys (if set)
722 Union of: required_secrets + --require
723 If a --keys name is not in allowed_secrets, exec fails with a clear error.
724 Manifest validation also requires every `required_secret` to appear in
725 `allowed_secrets`.
726
727OVERRIDING CONTRACT VALUES
728 Explicit flags always win over contract values:
729 -p / --profile overrides contract profile
730 --ns overrides contract namespace
731 --keys narrows injection (must be subset of allowed_secrets)
732 --require adds to contract required_secrets
733 --mode / --minimal override trust_level
734 Those overrides can narrow or restyle execution, but they cannot widen the
735 contract ceiling for allowed secrets or allowed targets.
736
737ERROR MESSAGES
738 "command 'X' is not in allowed_targets"
739 → Add X (or a matching basename) to allowed_targets in .tsafe.yml, or run
740 without --contract.
741 "selected key(s) not allowed by contract"
742 → The --keys list includes a name not in allowed_secrets; fix either.
743 "required secret 'X' not found"
744 → tsafe set X <value>
745
746Docs: docs/features/export-and-exec.md (Authority contracts section)
747
748"#
749 );
750}
751
752fn print_vault_recovery() {
753 print!(
754 r#"
755vault-recovery — options when you forget the master password
756━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
757
758tsafe vaults use Argon2id + XChaCha20-Poly1305. There is no backdoor.
759Recovery depends on which of these you set up before forgetting.
760
7611. Quick unlock / biometric (most common — you may not need your password)
762 tsafe biometric status
763 If enabled, the vault opens without a password:
764 tsafe get any-key
765 tsafe unlock # if the vault file is currently locked
766
7672. Main vault bridging
768 If another vault stores this profile's password under profile-passwords/<name>:
769 tsafe -p main get profile-passwords/<this-profile>
770
7713. Agent still running
772 If tsafe agent unlock is active in your session, the password is cached:
773 tsafe agent status
774 tsafe get any-key # will use agent if socket is alive
775
7764. TSAFE_PASSWORD env var
777 If you set it in your shell or CI environment, read it from there.
778 (Remove it from dotfiles, scripts, and CI secrets after recovery.)
779
7805. Password manager
781 Check if you stored the vault password during tsafe init.
782
783If none of the above work, the vault is not recoverable. Encrypted data
784cannot be decrypted without the key. To start over:
785 tsafe -p <profile> init # creates a new empty vault under that profile
786 re-pull or re-import from any source compiled into this build if configured
787
788"#
789 );
790}
791
792#[cfg(any(
793 feature = "akv-pull",
794 feature = "cloud-pull-aws",
795 feature = "cloud-pull-gcp",
796 feature = "cloud-pull-vault",
797 feature = "cloud-pull-1password",
798 feature = "multi-pull"
799))]
800fn print_pull() {
801 if is_akv_only_pull_build() {
802 print!(
803 r#"
804pull — sync secrets from Azure Key Vault into the local vault
805━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
806
807This build ships the core/default Azure Key Vault pull lane only. tsafe still
808acts as a local-first runtime authority: Azure is a read-only source, secrets
809are synced into the local vault, and `tsafe exec` scopes them to one process.
810Nothing is written back to Azure.
811
812Additional provider pulls (AWS/GCP/Vault/1Password) and multi-source `tsafe pull`
813orchestration are gated non-core surfaces and are not compiled into this binary.
814
815AVAILABLE SOURCES IN THIS BUILD
816
817"#
818 );
819 } else {
820 print!(
821 r#"
822pull — sync secrets from cloud providers into the local vault
823━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
824
825tsafe acts as a local-first runtime authority. Cloud providers are read-only
826sources — secrets are synced in, then tsafe exec scopes them to one process.
827Nothing is written back to the cloud.
828
829AVAILABLE SOURCES IN THIS BUILD
830
831"#
832 );
833 }
834 for source in PULL_SOURCES {
835 println!(" {:<26} {}", source.label, source.command);
836 }
837 if cfg!(feature = "multi-pull") {
838 print!(
839 r#"
840
841ONE COMMAND FOR ALL SOURCES (.tsafe.yml)
842
843 Define all your sources in .tsafe.yml at the repo root:
844
845 pulls:
846 - source: aws
847 region: us-east-1
848 prefix: myapp/
849 - source: gcp
850 project: my-project
851 - source: akv
852 vault_url: https://myvault.vault.azure.net
853
854 Then pull everything with one command:
855
856 tsafe pull
857
858KEY NORMALISATION (all sources)
859
860 Cloud names → Local key
861 my-secret → MY_SECRET
862 myapp/db-password → MYAPP_DB_PASSWORD (AWS / SSM path)
863 /prod/myapp/api-key → PROD_MYAPP_API_KEY (SSM absolute path)
864 db.password → DB_PASSWORD (GCP dot separator)
865
866OVERWRITE BEHAVIOUR
867
868 By default, existing local secrets are not overwritten:
869 tsafe pull # safe: new keys only
870 tsafe pull --overwrite # replace all matching keys
871
872AFTER PULLING
873
874 Preview what would be injected (no secret values printed):
875 tsafe exec --dry-run
876
877 Run your app:
878 tsafe exec -- npm start
879
880Credential setup: tsafe explain pull-auth
881
882"#
883 );
884 } else {
885 print!(
886 r#"
887
888KEY NORMALISATION
889
890 Provider names are normalised into local vault keys before storage.
891
892OVERWRITE BEHAVIOUR
893
894 By default, existing local secrets are not overwritten:
895 tsafe kv-pull # safe: new keys only
896 tsafe kv-pull --overwrite # replace all matching keys
897
898AFTER PULLING
899
900 Preview what would be injected (no secret values printed):
901 tsafe exec --dry-run
902
903 Run your app:
904 tsafe exec -- npm start
905
906Credential setup: tsafe explain pull-auth
907
908This page is intentionally build-scoped: if you need AWS/GCP/Vault/1Password
909or multi-source `tsafe pull`, use a binary compiled with those gated features.
910
911"#
912 );
913 }
914}
915
916#[cfg(any(
917 feature = "akv-pull",
918 feature = "cloud-pull-aws",
919 feature = "cloud-pull-gcp",
920 feature = "cloud-pull-vault",
921 feature = "cloud-pull-1password",
922 feature = "multi-pull"
923))]
924fn print_pull_auth() {
925 if is_akv_only_pull_build() {
926 print!(
927 r#"
928pull-auth — credential setup for compiled-in pull sources
929━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
930
931This build ships Azure Key Vault pull only. The provider sections below are
932limited to commands that exist in this binary; broader cloud pulls are gated
933non-core surfaces and are omitted here.
934"#
935 );
936 } else {
937 print!(
938 r#"
939pull-auth — credential setup for cloud pull sources
940━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
941
942Each provider has its own credential model. Set the env vars before running
943the pull command compiled into this build.
944"#
945 );
946 }
947 for section in PULL_AUTH_SECTIONS {
948 println!("\n{}\n{}", section.heading, section.body);
949 }
950 if cfg!(feature = "multi-pull") {
951 print!(
952 r#"
953
954TESTING CREDENTIALS
955
956 Dry-run your pull config without writing to the vault:
957 tsafe pull --dry-run
958
959 Check what is in the vault after pulling:
960 tsafe list
961
962"#
963 );
964 } else {
965 print!(
966 r#"
967
968TESTING CREDENTIALS
969
970 Run the provider-specific pull command for this build, then inspect:
971 tsafe list
972
973"#
974 );
975 }
976}
977
978#[cfg(any(
979 feature = "akv-pull",
980 feature = "cloud-pull-aws",
981 feature = "cloud-pull-gcp",
982 feature = "cloud-pull-vault",
983 feature = "cloud-pull-1password",
984 feature = "multi-pull"
985))]
986fn print_pull_reliability() {
987 print!(
988 r#"
989pull-reliability — retries, transient errors, and continuation behavior
990━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
991
992PULL RETRIES
993
994Cloud pull clients retry two classes of failures:
995 · HTTP 429 (throttling): up to 3 retries, honors Retry-After header when present
996 · transient transport failures: up to 5 retries with jittered exponential backoff
997
998Transient transport examples:
999 timeout, connection refused, connection reset, temporary network faults
1000
1001JITTERED BACKOFF
1002
1003Retry delay uses exponential backoff with +0..25% jitter to reduce synchronized
1004retry storms when many jobs pull at once.
1005
1006PULL COMMAND ERROR MODES
1007
1008All pull commands now accept:
1009 --on-error fail-all # default, abort on first provider/source error
1010 --on-error skip-failed # continue remaining sources, skip failed source
1011 --on-error warn-only # continue and warn; useful for best-effort sync
1012
1013Examples:
1014 "#
1015 );
1016 #[cfg(feature = "akv-pull")]
1017 println!(" tsafe kv-pull --on-error warn-only");
1018 #[cfg(feature = "cloud-pull-aws")]
1019 println!(" tsafe aws-pull --on-error warn-only");
1020 #[cfg(feature = "cloud-pull-gcp")]
1021 println!(" tsafe gcp-pull --on-error skip-failed");
1022 #[cfg(feature = "multi-pull")]
1023 println!(" tsafe pull --config .tsafe.yml --on-error skip-failed");
1024 if cfg!(feature = "multi-pull") {
1025 print!(
1026 r#"
1027
1028MULTI-SOURCE BEHAVIOR (`tsafe pull`)
1029
1030With skip-failed/warn-only, a failed source does not stop later sources.
1031The command prints per-source failures and a final failure count.
1032"#
1033 );
1034 }
1035 print!(
1036 r#"
1037
1038OPERATOR GUIDANCE
1039
1040Use fail-all for CI gates that require every source to succeed.
1041Use skip-failed for mixed environments where some sources are optional.
1042Use warn-only for local/dev best-effort hydration.
1043
1044"#
1045 );
1046 if is_akv_only_pull_build() {
1047 print!(
1048 r#"
1049This binary only exposes the Azure Key Vault pull lane. Multi-source continuation
1050semantics for `tsafe pull` are a gated non-core surface and are not available here.
1051
1052"#
1053 );
1054 }
1055}