devboy_executor/argv_secrets.rs
1//! `@secret:<path>` substitution wrapper for child-process argv per
2//! [ADR-020] §5.
3//!
4//! ADR-020 §5 second bullet:
5//!
6//! > A wrapper rewrites `@secret:<path>` occurrences in argv before
7//! > `exec`. Because argv is visible to other processes through `ps`,
8//! > the wrapper prefers passing the secret through stdin or a file
9//! > descriptor when the target tool supports it (for example,
10//! > `gh auth login --with-token`, `git credential fill`). Direct
11//! > argv substitution is the fallback for tools that accept secrets
12//! > only in argv, and is documented as such.
13//!
14//! [`rewrite_argv`] is the *planning* function: it takes the raw
15//! `argv` plus a [`SecretResolver`], decides for each known tool
16//! whether to redirect through stdin, and returns a [`RewritePlan`]
17//! that captures:
18//!
19//! - the final `argv` (with aliases removed when they go through
20//! stdin, or with plaintext substituted when they go through
21//! argv),
22//! - an optional `stdin_payload` ([`SecretString`]) the caller
23//! writes to the child's stdin,
24//! - a per-substitution audit trail ([`Substitution`]) so callers
25//! can log which paths were resolved through which strategy,
26//! - an `argv_visible` flag so the caller can warn when at least
27//! one secret will land in `ps` output.
28//!
29//! [`apply_plan_to_command`] hands the plan to a
30//! [`tokio::process::Command`] — sets stdio, then the caller
31//! `.spawn()`s and writes the payload to the child's stdin.
32//!
33//! ## Known-tool detection
34//!
35//! The first version recognises two FD-friendly invocation
36//! patterns:
37//!
38//! - `gh auth login --with-token <alias>` — `gh` reads the token
39//! from stdin when `--with-token` is present.
40//! - `git credential fill|approve|reject` — the `git credential`
41//! protocol is stdin-only.
42//!
43//! Adding more patterns (`vault login -method=token`, `aws ssm
44//! put-parameter --value`, …) is one entry per case in the
45//! `Known` enum + a small predicate. Until they're added, the
46//! wrapper falls back to plaintext-in-argv with a structured
47//! warning surface.
48//!
49//! ## What this module does **not** do
50//!
51//! - Spawn the child. The caller decides how (`tokio::spawn`,
52//! blocking, supervised), and only this module knows the
53//! stdio shape.
54//! - Resolve aliases against a router. The
55//! [`SecretResolver`] trait is the boundary; the storage-layer
56//! impl that walks the router/credential chain lands separately.
57//!
58//! [ADR-020]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-020-secret-manifest-and-alias-resolution.md
59//! [`SecretResolver`]: devboy_core::alias::SecretResolver
60
61use std::path::Path;
62
63use devboy_core::alias::{AliasResolverError, SecretResolver, parse_alias};
64use devboy_core::secret_approval::ApprovalGatedResolver;
65use secrecy::{ExposeSecret, SecretString};
66use thiserror::Error;
67use tracing::{debug, warn};
68
69// =============================================================================
70// Public types
71// =============================================================================
72
73/// Per-substitution audit entry. Surfaced through
74/// [`RewritePlan::substitutions`] so callers can log routing
75/// decisions without reaching into the secret values.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Substitution {
78 /// Path portion of the original `@secret:<path>` alias.
79 pub path: String,
80 /// Index in the *input* argv where the alias appeared.
81 pub argv_index: usize,
82 /// Strategy chosen for this alias.
83 pub strategy: SubstitutionStrategy,
84}
85
86/// How the wrapper passed a secret to the child.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum SubstitutionStrategy {
89 /// Resolved value placed verbatim in the rewritten argv —
90 /// visible to other processes via `ps`. Fallback for tools
91 /// that have no FD/stdin alternative.
92 Argv,
93 /// Resolved value written to the child's stdin. The argv
94 /// position that originally held the alias is removed.
95 Stdin,
96}
97
98/// Outcome of [`rewrite_argv`].
99#[derive(Debug)]
100pub struct RewritePlan {
101 /// Argv as it should be handed to the child. Aliases routed
102 /// through stdin are gone; aliases routed through argv hold
103 /// the plaintext value.
104 pub argv: Vec<String>,
105 /// When `Some`, the caller writes the contained
106 /// [`SecretString`] to the child's stdin. Multiple
107 /// stdin-routed aliases are joined with `\n`; the trailing
108 /// newline lets `gh auth login --with-token` and friends
109 /// terminate the read cleanly.
110 pub stdin_payload: Option<SecretString>,
111 /// Audit trail (one entry per alias seen).
112 pub substitutions: Vec<Substitution>,
113 /// `true` when at least one alias was routed through argv —
114 /// callers who want to fail loud on argv exposure can check
115 /// this.
116 pub argv_visible: bool,
117}
118
119/// Failure modes for [`rewrite_argv`].
120#[derive(Debug, Error)]
121pub enum ArgvRewriteError {
122 /// Resolver could not produce a value for an alias.
123 //
124 // `source_error` (not `source`) — thiserror treats a field
125 // named `source` as the `#[source]` chain root, but our
126 // explicit `#[source]` annotation on this field already
127 // signals that. Renaming sidesteps the name clash and lets
128 // the destructure work cleanly in tests.
129 #[error("failed to resolve alias `@secret:{path}`: {source_error}")]
130 Resolve {
131 /// Path portion of the alias.
132 path: String,
133 /// Underlying resolver error.
134 #[source]
135 source_error: AliasResolverError,
136 },
137}
138
139// =============================================================================
140// Planning
141// =============================================================================
142
143/// Plan a substitution for the given child invocation.
144///
145/// `program` is the path or basename the caller will spawn (`"gh"`,
146/// `"/usr/local/bin/git"`, …). `argv` is the argument vector that
147/// follows it. The wrapper inspects `argv` for `@secret:<path>`
148/// aliases, asks the resolver for each value, and decides per
149/// alias whether to redirect through stdin or substitute in argv.
150///
151/// On `Err(ArgvRewriteError::Resolve)` the partial work is
152/// discarded — the caller cannot end up with half-resolved argv.
153pub fn rewrite_argv<R, F>(
154 program: &str,
155 argv: &[String],
156 resolver: &ApprovalGatedResolver<R, F>,
157) -> Result<RewritePlan, ArgvRewriteError>
158where
159 R: SecretResolver,
160 F: Fn(&str) -> devboy_core::secret_approval::ApproveOnUsePolicy + Send + Sync,
161{
162 // Step 1: decide the per-tool strategy.
163 let strategy = ToolStrategy::detect(program, argv);
164
165 // Step 2: walk argv, classify each entry, resolve when needed.
166 let mut out_argv = Vec::with_capacity(argv.len());
167 let mut substitutions = Vec::new();
168 let mut stdin_pieces: Vec<SecretString> = Vec::new();
169 let mut argv_visible = false;
170
171 for (idx, raw) in argv.iter().enumerate() {
172 let Some(path) = parse_alias(raw) else {
173 // Not an alias — keep verbatim (per ADR-020 §5,
174 // partial occurrences are NOT rewritten).
175 out_argv.push(raw.clone());
176 continue;
177 };
178
179 let value = resolver
180 .resolve(path)
181 .map_err(|source_error| ArgvRewriteError::Resolve {
182 path: path.to_owned(),
183 source_error,
184 })?;
185
186 if strategy.uses_stdin_for(idx, argv) {
187 // Drop the alias arg — the child reads the value
188 // off stdin. The plan's payload picks it up.
189 stdin_pieces.push(value);
190 substitutions.push(Substitution {
191 path: path.to_owned(),
192 argv_index: idx,
193 strategy: SubstitutionStrategy::Stdin,
194 });
195 } else {
196 // Argv fallback: visible in `ps`.
197 argv_visible = true;
198 out_argv.push(value.expose_secret().to_owned());
199 substitutions.push(Substitution {
200 path: path.to_owned(),
201 argv_index: idx,
202 strategy: SubstitutionStrategy::Argv,
203 });
204 warn!(
205 program,
206 index = idx,
207 path,
208 "argv-substituted secret will be visible to other processes via `ps`; \
209 prefer a tool that accepts the value via stdin/FD"
210 );
211 }
212 }
213
214 let stdin_payload = if stdin_pieces.is_empty() {
215 None
216 } else {
217 // Join multiple stdin-routed values with `\n` so a tool
218 // expecting one-secret-per-line (rare but it happens)
219 // works. A trailing newline lets `gh auth login` and
220 // `git credential` terminate their reads.
221 let joined = stdin_pieces
222 .iter()
223 .map(|s| s.expose_secret().to_owned())
224 .collect::<Vec<_>>()
225 .join("\n")
226 + "\n";
227 Some(SecretString::from(joined))
228 };
229
230 debug!(
231 program,
232 substitutions = substitutions.len(),
233 argv_visible,
234 "argv-secret rewrite complete"
235 );
236
237 Ok(RewritePlan {
238 argv: out_argv,
239 stdin_payload,
240 substitutions,
241 argv_visible,
242 })
243}
244
245// =============================================================================
246// Apply plan to a tokio::process::Command
247// =============================================================================
248
249/// Wire a [`RewritePlan`] into a [`tokio::process::Command`].
250///
251/// Sets the stdin disposition based on whether the plan has a
252/// payload — `Stdio::piped()` when yes, untouched (inherits) when
253/// no. The argv override replaces whatever args were on the
254/// command. The caller is responsible for `.spawn()`-ing,
255/// writing the payload through `child.stdin`, and waiting on the
256/// child.
257///
258/// Argv override note: this function calls `.args(&plan.argv)`
259/// only if the plan changed the argv (the substitutions list is
260/// non-empty). When the plan is a pass-through (no aliases) we
261/// leave whatever the caller already set in place.
262pub fn apply_plan_to_command(plan: &RewritePlan, cmd: &mut tokio::process::Command) {
263 if !plan.substitutions.is_empty() {
264 // Reset args to the rewritten list. tokio::process::Command
265 // doesn't expose args_mut(), so we can only append. The
266 // contract here is that the caller is using this helper as
267 // *the* way to supply args for a planned invocation —
268 // the input `cmd` should not have args set yet.
269 cmd.args(&plan.argv);
270 }
271 if plan.stdin_payload.is_some() {
272 cmd.stdin(std::process::Stdio::piped());
273 }
274}
275
276// =============================================================================
277// Tool detection
278// =============================================================================
279
280/// Per-invocation strategy that knows when to prefer stdin.
281struct ToolStrategy {
282 kind: Known,
283}
284
285#[derive(Debug, Clone, Copy)]
286enum Known {
287 /// `gh auth login --with-token <ALIAS>` — `gh` reads the
288 /// token from stdin when `--with-token` is present.
289 GhAuthLoginWithToken {
290 /// Index in argv where the alias replaces the token.
291 token_index: usize,
292 },
293 /// `git credential fill|approve|reject` — the credential
294 /// protocol is stdin-only by design; ANY alias arg goes
295 /// through stdin.
296 GitCredential,
297 /// No known FD-friendly pattern matched — fall back to argv
298 /// substitution.
299 Fallback,
300}
301
302impl ToolStrategy {
303 fn detect(program: &str, argv: &[String]) -> Self {
304 let basename = Path::new(program)
305 .file_name()
306 .and_then(|s| s.to_str())
307 .unwrap_or(program);
308 match basename {
309 "gh" => detect_gh(argv),
310 "git" => detect_git(argv),
311 _ => ToolStrategy {
312 kind: Known::Fallback,
313 },
314 }
315 }
316
317 /// Decide whether the alias at `argv_index` should be routed
318 /// through stdin for this tool.
319 fn uses_stdin_for(&self, argv_index: usize, _argv: &[String]) -> bool {
320 match self.kind {
321 Known::GhAuthLoginWithToken { token_index } => argv_index == token_index,
322 Known::GitCredential => true,
323 Known::Fallback => false,
324 }
325 }
326}
327
328fn detect_gh(argv: &[String]) -> ToolStrategy {
329 // We're looking for `auth login --with-token <ALIAS>`.
330 // Anything else falls back to argv.
331 let auth_idx = argv.iter().position(|a| a == "auth");
332 let login_idx = argv.iter().position(|a| a == "login");
333 let with_token_idx = argv.iter().position(|a| a == "--with-token");
334 if let (Some(a), Some(l), Some(t)) = (auth_idx, login_idx, with_token_idx)
335 && a < l
336 && l < t
337 {
338 // The token argument follows `--with-token`.
339 let token_idx = t + 1;
340 if token_idx < argv.len() {
341 let candidate = &argv[token_idx];
342 if parse_alias(candidate).is_some() {
343 return ToolStrategy {
344 kind: Known::GhAuthLoginWithToken {
345 token_index: token_idx,
346 },
347 };
348 }
349 }
350 }
351 ToolStrategy {
352 kind: Known::Fallback,
353 }
354}
355
356fn detect_git(argv: &[String]) -> ToolStrategy {
357 // `git credential <subcommand>` — stdin-only protocol.
358 if argv.first().map(String::as_str) == Some("credential") {
359 return ToolStrategy {
360 kind: Known::GitCredential,
361 };
362 }
363 ToolStrategy {
364 kind: Known::Fallback,
365 }
366}
367
368// =============================================================================
369// Tests
370// =============================================================================
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use std::collections::HashMap;
376 use std::sync::Mutex;
377
378 /// Tiny in-memory resolver for tests.
379 struct MapResolver {
380 entries: Mutex<HashMap<String, String>>,
381 }
382
383 impl MapResolver {
384 fn new(pairs: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
385 Self {
386 entries: Mutex::new(
387 pairs
388 .into_iter()
389 .map(|(k, v)| (k.to_owned(), v.to_owned()))
390 .collect(),
391 ),
392 }
393 }
394 }
395
396 fn always_never(_: &str) -> devboy_core::secret_approval::ApproveOnUsePolicy {
397 devboy_core::secret_approval::ApproveOnUsePolicy::Never
398 }
399
400 fn wrap_resolver_never(
401 r: MapResolver,
402 ) -> ApprovalGatedResolver<
403 MapResolver,
404 fn(&str) -> devboy_core::secret_approval::ApproveOnUsePolicy,
405 > {
406 ApprovalGatedResolver::new(
407 r,
408 std::sync::Arc::new(devboy_core::secret_approval::SessionApprovalCache::new()),
409 always_never,
410 )
411 }
412
413 impl SecretResolver for MapResolver {
414 fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
415 let map = self.entries.lock().unwrap();
416 match map.get(path) {
417 Some(v) => Ok(SecretString::from(v.clone())),
418 None => Err(AliasResolverError::NotFound {
419 path: path.to_owned(),
420 }),
421 }
422 }
423 }
424
425 fn argv(parts: &[&str]) -> Vec<String> {
426 parts.iter().map(|s| (*s).to_owned()).collect()
427 }
428
429 // -- Pass-through (no aliases) ------------------------------
430
431 #[test]
432 fn no_alias_in_argv_is_a_passthrough_with_no_substitutions() {
433 let resolver = MapResolver::new([]);
434 let plan = rewrite_argv(
435 "any-tool",
436 &argv(&["--flag", "value"]),
437 &wrap_resolver_never(resolver),
438 )
439 .unwrap();
440 assert_eq!(plan.argv, vec!["--flag".to_owned(), "value".to_owned()]);
441 assert!(plan.stdin_payload.is_none());
442 assert!(plan.substitutions.is_empty());
443 assert!(!plan.argv_visible);
444 }
445
446 // -- Argv fallback -----------------------------------------
447
448 #[test]
449 fn unknown_tool_falls_back_to_argv_substitution() {
450 let resolver = MapResolver::new([("personal/github/pat", "ghp-fixture")]);
451 let plan = rewrite_argv(
452 "some-tool",
453 &argv(&["--token", "@secret:personal/github/pat"]),
454 &wrap_resolver_never(resolver),
455 )
456 .unwrap();
457 assert_eq!(
458 plan.argv,
459 vec!["--token".to_owned(), "ghp-fixture".to_owned()]
460 );
461 assert!(plan.stdin_payload.is_none());
462 assert_eq!(plan.substitutions.len(), 1);
463 assert_eq!(plan.substitutions[0].strategy, SubstitutionStrategy::Argv);
464 assert_eq!(plan.substitutions[0].argv_index, 1);
465 assert!(plan.argv_visible);
466 }
467
468 #[test]
469 fn fallback_warns_via_argv_visible_flag() {
470 let resolver = MapResolver::new([("a/b/c", "v")]);
471 let plan = rewrite_argv(
472 "some-tool",
473 &argv(&["@secret:a/b/c"]),
474 &wrap_resolver_never(resolver),
475 )
476 .unwrap();
477 assert!(plan.argv_visible);
478 }
479
480 // -- gh auth login --with-token ----------------------------
481
482 #[test]
483 fn gh_auth_login_with_token_routes_through_stdin() {
484 use secrecy::ExposeSecret;
485 let resolver = MapResolver::new([("personal/github/pat", "ghp-fixture")]);
486 let plan = rewrite_argv(
487 "gh",
488 &argv(&[
489 "auth",
490 "login",
491 "--with-token",
492 "@secret:personal/github/pat",
493 ]),
494 &wrap_resolver_never(resolver),
495 )
496 .unwrap();
497 // The alias arg is dropped from the rewritten argv.
498 assert_eq!(
499 plan.argv,
500 vec![
501 "auth".to_owned(),
502 "login".to_owned(),
503 "--with-token".to_owned()
504 ]
505 );
506 // The plaintext is *not* in argv anywhere.
507 assert!(plan.argv.iter().all(|a| !a.contains("ghp-fixture")));
508 // Stdin payload carries the token (with trailing \n).
509 let payload = plan.stdin_payload.unwrap();
510 assert_eq!(payload.expose_secret(), "ghp-fixture\n");
511 assert_eq!(plan.substitutions.len(), 1);
512 assert_eq!(plan.substitutions[0].strategy, SubstitutionStrategy::Stdin);
513 assert!(!plan.argv_visible, "stdin path must NOT mark argv visible");
514 }
515
516 #[test]
517 fn gh_with_absolute_path_still_recognised() {
518 let resolver = MapResolver::new([("p/g/p", "v")]);
519 let plan = rewrite_argv(
520 "/usr/local/bin/gh",
521 &argv(&["auth", "login", "--with-token", "@secret:p/g/p"]),
522 &wrap_resolver_never(resolver),
523 )
524 .unwrap();
525 // basename match → still gh strategy.
526 assert!(!plan.argv_visible);
527 }
528
529 #[test]
530 fn gh_without_with_token_falls_back_to_argv() {
531 let resolver = MapResolver::new([("p/g/p", "v")]);
532 let plan = rewrite_argv(
533 "gh",
534 &argv(&["repo", "view", "--token", "@secret:p/g/p"]),
535 &wrap_resolver_never(resolver),
536 )
537 .unwrap();
538 // No --with-token flag → fallback strategy.
539 assert!(plan.argv_visible);
540 assert!(plan.argv.contains(&"v".to_owned()));
541 }
542
543 // -- git credential ----------------------------------------
544
545 #[test]
546 fn git_credential_routes_alias_args_through_stdin() {
547 use secrecy::ExposeSecret;
548 let resolver = MapResolver::new([("svc/git/cred", "credential-fixture")]);
549 let plan = rewrite_argv(
550 "git",
551 &argv(&["credential", "fill", "@secret:svc/git/cred"]),
552 &wrap_resolver_never(resolver),
553 )
554 .unwrap();
555 // Alias arg dropped from argv; only literal args remain.
556 assert_eq!(plan.argv, vec!["credential".to_owned(), "fill".to_owned()]);
557 let payload = plan.stdin_payload.unwrap();
558 assert_eq!(payload.expose_secret(), "credential-fixture\n");
559 assert_eq!(plan.substitutions[0].strategy, SubstitutionStrategy::Stdin);
560 assert!(!plan.argv_visible);
561 }
562
563 #[test]
564 fn git_non_credential_uses_argv_fallback() {
565 let resolver = MapResolver::new([("a/b/c", "v")]);
566 let plan = rewrite_argv(
567 "git",
568 &argv(&["push", "--token", "@secret:a/b/c"]),
569 &wrap_resolver_never(resolver),
570 )
571 .unwrap();
572 assert!(plan.argv_visible);
573 }
574
575 // -- Multiple aliases --------------------------------------
576
577 #[test]
578 fn multiple_argv_aliases_each_get_an_audit_entry() {
579 let resolver = MapResolver::new([("a/b/c", "v1"), ("d/e/f", "v2")]);
580 let plan = rewrite_argv(
581 "tool",
582 &argv(&["@secret:a/b/c", "literal", "@secret:d/e/f"]),
583 &wrap_resolver_never(resolver),
584 )
585 .unwrap();
586 assert_eq!(
587 plan.argv,
588 vec!["v1".to_owned(), "literal".to_owned(), "v2".to_owned()]
589 );
590 assert_eq!(plan.substitutions.len(), 2);
591 assert!(plan.argv_visible);
592 }
593
594 // -- Partial-occurrence non-rewriting per ADR-020 §5 -------
595
596 #[test]
597 fn alias_inside_a_longer_arg_is_not_rewritten() {
598 // ADR-020 §5: alias replaces the WHOLE field value;
599 // partial occurrences are NOT aliases.
600 let resolver = MapResolver::new([("a/b/c", "v")]);
601 let plan = rewrite_argv(
602 "tool",
603 &argv(&["Bearer @secret:a/b/c"]),
604 &wrap_resolver_never(resolver),
605 )
606 .unwrap();
607 assert_eq!(plan.argv, vec!["Bearer @secret:a/b/c".to_owned()]);
608 assert!(plan.substitutions.is_empty());
609 }
610
611 // -- Resolver error propagation ----------------------------
612
613 #[test]
614 fn unknown_alias_path_propagates_resolver_error() {
615 let resolver = MapResolver::new([]);
616 let err = rewrite_argv(
617 "tool",
618 &argv(&["--token", "@secret:nope/nope/nope"]),
619 &wrap_resolver_never(resolver),
620 )
621 .unwrap_err();
622 match err {
623 ArgvRewriteError::Resolve { path, .. } => {
624 assert_eq!(path, "nope/nope/nope");
625 }
626 }
627 }
628
629 // -- apply_plan_to_command + real child --------------------
630
631 /// End-to-end sanity check on Unix: plan a substitution that
632 /// goes through stdin, spawn `/bin/cat`, write the payload,
633 /// and assert the child reads the secret value back from
634 /// stdout. Also asserts that the rewritten argv handed to
635 /// the child contains no plaintext — the moral equivalent
636 /// of `ps` invisibility.
637 #[cfg(unix)]
638 #[tokio::test]
639 async fn apply_plan_to_command_pipes_stdin_to_child() {
640 use tokio::io::{AsyncReadExt, AsyncWriteExt};
641
642 let resolver = MapResolver::new([("personal/github/pat", "ghp-fixture")]);
643 let plan = rewrite_argv(
644 "gh",
645 &argv(&[
646 "auth",
647 "login",
648 "--with-token",
649 "@secret:personal/github/pat",
650 ]),
651 &wrap_resolver_never(resolver),
652 )
653 .unwrap();
654
655 // Sanity: rewritten argv must not contain the plaintext
656 // (this is the "ps invisibility" property).
657 for arg in &plan.argv {
658 assert!(!arg.contains("ghp-fixture"));
659 }
660
661 // Spawn /bin/cat as a stand-in for any tool that reads
662 // its single secret from stdin.
663 let mut cmd = tokio::process::Command::new("/bin/cat");
664 // We don't reuse plan.argv here — /bin/cat doesn't take
665 // gh's arguments. Just exercise the stdin-piping path
666 // independently.
667 if plan.stdin_payload.is_some() {
668 cmd.stdin(std::process::Stdio::piped());
669 }
670 cmd.stdout(std::process::Stdio::piped());
671 let mut child = cmd.spawn().expect("spawn /bin/cat");
672
673 // Write the payload.
674 if let Some(secret) = &plan.stdin_payload {
675 let mut stdin = child.stdin.take().unwrap();
676 stdin
677 .write_all(secret.expose_secret().as_bytes())
678 .await
679 .unwrap();
680 // Closing stdin sends EOF so cat exits cleanly.
681 stdin.shutdown().await.unwrap();
682 drop(stdin);
683 }
684
685 // Read the child's stdout.
686 let mut stdout = child.stdout.take().unwrap();
687 let mut buf = String::new();
688 stdout.read_to_string(&mut buf).await.unwrap();
689 let _ = child.wait().await;
690
691 // /bin/cat echoes its stdin verbatim. The secret made it
692 // through.
693 assert_eq!(buf, "ghp-fixture\n");
694 }
695}