io_harness/tools/git.rs
1//! Governed `git` spawns: a fixed argv, built here, run under the policy.
2//!
3//! This is the foundation the git built-ins sit on. It deliberately does *not*
4//! repeat the weakness of [`ExecGuard`](crate::verify::ExecGuard), which checks
5//! only the program name and formats the rest of the argv into a trace string:
6//! for `rustc` that is survivable, because the argv is the gate's own; for
7//! `git` it is not, because the argv would carry model-supplied text and `git`
8//! has subcommands that fetch code and options that execute it.
9//!
10//! ## Why a caller cannot pass a free-form argument
11//!
12//! There is no `&str`, no `Vec<String>`, and no `impl IntoIterator<Item = ...>`
13//! anywhere on the way in that reaches the argv as an *option*. A caller names a
14//! `GitCmd` variant, and every field of every variant is one of two things:
15//!
16//! * a typed non-string value (`bool`, `u32`) that this module renders into a
17//! flag itself — a `bool` cannot spell `--upload-pack=…`; or
18//! * a `paths` vector, which is model-supplied data and is therefore emitted
19//! *only* after the `--` separator, and only after each element passes
20//! `check_path`.
21//!
22//! Adding a git capability means adding a variant here and getting it reviewed.
23//! That is the whole security property: the set of argvs this module can emit is
24//! finite, enumerable, and enumerated by the tests below.
25//!
26//! No shell is ever involved — no `sh -c`, no joining. Each path is one argv
27//! element, so a path containing spaces, quotes, newlines or shell
28//! metacharacters reaches `git` as that exact path and nothing interprets it.
29
30// The git built-ins that call this land in a follow-up task; until they do, the
31// default lib build sees the module as unused. Remove this once they exist.
32
33use std::path::PathBuf;
34use std::process::Stdio;
35
36use tokio::process::Command;
37
38use super::cap_result;
39use crate::error::{Error, Result};
40use crate::policy::{Act, Effect, Policy};
41
42/// The binary. A constant rather than a parameter so a policy rule has a stable
43/// `Act::Exec` target to name.
44const GIT: &str = "git";
45
46/// The platform's bit bucket, used both as the "global config" file and as the
47/// hooks directory.
48const NULL_DEVICE: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
49
50/// The child's environment, fixed on every spawn:
51///
52/// * `GIT_PAGER`/`PAGER` — a pager would block forever on a pipe that never
53/// becomes a tty, and its output is terminal escapes, not data.
54/// * `LC_ALL`/`LANG` — porcelain and error text must not change with the
55/// operator's locale, or parsing and tests drift per machine.
56/// * `GIT_CONFIG_NOSYSTEM`/`GIT_CONFIG_GLOBAL` — `/etc/gitconfig` and
57/// `~/.gitconfig` can define aliases and `core.*` settings that change what a
58/// subcommand does. Nothing in this crate's permission model covers them.
59const FIXED_ENV: [(&str, &str); 6] = [
60 ("GIT_PAGER", "cat"),
61 ("PAGER", "cat"),
62 ("LC_ALL", "C"),
63 ("LANG", "C"),
64 ("GIT_CONFIG_NOSYSTEM", "1"),
65 ("GIT_CONFIG_GLOBAL", NULL_DEVICE),
66];
67
68/// Repository hooks, disabled by pointing `core.hooksPath` at a path that cannot
69/// be a directory.
70///
71/// This matters more than the rest of the environment put together. `.git/hooks/*`
72/// is arbitrary executable code carried by the repository the agent was pointed
73/// at — cloned, or supplied by whoever opened the pull request — and `git`
74/// runs it on ordinary commands. Nothing in this crate's permission model covers
75/// it: the policy authorises spawning `git`, not spawning whatever a checked-out
76/// tree left in its hooks directory. `-c` is the reliable way to suppress it,
77/// because it wins over every config file, including the repository's own.
78const NO_HOOKS: &str = "core.hooksPath";
79
80/// One git invocation, as a closed set of shapes.
81///
82/// Every variant renders to a *read-only* subcommand. See the module docs for
83/// why the fields are typed the way they are, and
84/// [`no_shape_can_write_or_reach_the_network`] for the test that holds the line.
85//
86// ponytail: three shapes, the ones the first built-ins need. More git
87// capability = one more variant plus its case in `render`, not a new
88// mechanism.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub(crate) enum GitCmd {
91 /// `git status --porcelain=v1 --untracked-files=normal -- <paths>`
92 Status {
93 /// Model-supplied paths to limit the report to. Data, never options.
94 paths: Vec<String>,
95 },
96 /// `git diff --no-color --no-ext-diff [--cached] -- <paths>`
97 Diff {
98 /// Diff the index rather than the working tree (`--cached`).
99 staged: bool,
100 /// Model-supplied paths to limit the diff to.
101 paths: Vec<String>,
102 },
103 /// `git log --no-color --oneline --no-decorate --max-count=N -- <paths>`
104 Log {
105 /// How many commits at most. A number, so it cannot spell a flag.
106 max_count: u32,
107 /// Model-supplied paths to limit the history to.
108 paths: Vec<String>,
109 },
110 /// `git add -- <paths>`
111 ///
112 /// No `-f`: staging honours `.gitignore`, and the flag that overrides it is
113 /// not reachable from here. An ignored path comes back as git's own message,
114 /// which is what lets the model tell "ignored" from "no such path".
115 Add {
116 /// Model-supplied paths to stage.
117 paths: Vec<String>,
118 },
119 /// `git -c user.name=… -c user.email=… commit --no-verify -m <message>`
120 ///
121 /// Commits what is staged, on the branch that is checked out. No paths: a
122 /// commit is of the index, and `git_add` is what decides the index.
123 Commit {
124 /// The model's commit message. Safe as data because it is the operand of
125 /// `-m`: git takes the next argv element literally, so a message
126 /// beginning with `-` is a message and not an option.
127 message: String,
128 /// Who the commit is attributed to.
129 identity: Identity,
130 },
131}
132
133/// Who a commit is attributed to.
134///
135/// `git commit` fails outright with no `user.email` configured, so this cannot
136/// be left to the machine. Inheriting the repository's identity would attribute
137/// the agent's commit to whichever human configured that checkout, which is the
138/// wrong default; requiring configuration would fail on a fresh machine, which
139/// is the wrong other one. So the harness supplies one and the caller may
140/// replace it.
141///
142/// This is what `git log` shows for every commit a run makes, and it is passed
143/// as `-c user.name=… -c user.email=…` on the commit itself rather than written
144/// into the repository's config, so a run leaves the checkout's own identity
145/// untouched.
146///
147/// ```
148/// use io_harness::{Identity, TaskContract, Verification};
149///
150/// // The default: a name at a domain that can never exist. RFC 2606 reserves
151/// // `.invalid` precisely so a synthetic address cannot accidentally be
152/// // someone's real one.
153/// assert_eq!(Identity::default().email, "agent@io-harness.invalid");
154///
155/// // Replace it when the commits should be attributable to your service, so
156/// // a human reading `git log` a month later can tell which system made
157/// // them and where to ask about it.
158/// let contract = TaskContract::workspace(
159/// "fix the failing test and commit the fix",
160/// "/path/to/repo",
161/// Verification::WorkspaceFileContains { file: "src/lib.rs".into(), needle: "fn".into() },
162/// )
163/// .with_commit_identity("nightly-agent", "nightly-agent@example.com");
164/// # let _ = contract;
165/// ```
166///
167/// Neither field may be empty or hold a control character: both reach the
168/// commit object and the reflog, and a newline in a name has nowhere useful to
169/// go. A bad one is an [`Error::Config`].
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct Identity {
172 /// The committer name.
173 pub name: String,
174 /// The committer email.
175 pub email: String,
176}
177
178impl Default for Identity {
179 /// An agent identity at a domain that can never exist. `.invalid` is
180 /// reserved by RFC 2606 precisely so that a synthetic address cannot
181 /// accidentally be someone's.
182 fn default() -> Self {
183 Self {
184 name: "io-harness agent".into(),
185 email: "agent@io-harness.invalid".into(),
186 }
187 }
188}
189
190impl Identity {
191 /// Refuse an identity that could split into more than one `-c` setting.
192 ///
193 /// `-c key=value` is a single argv element, so a value cannot introduce a
194 /// second setting — but a newline in a name reaches the commit object and
195 /// the reflog, and there is no reason to carry one.
196 fn check(&self) -> Result<()> {
197 for (what, v) in [("name", &self.name), ("email", &self.email)] {
198 if v.is_empty() || v.chars().any(char::is_control) {
199 return Err(Error::Config(format!(
200 "commit identity {what} must be non-empty and free of control characters"
201 )));
202 }
203 }
204 Ok(())
205 }
206}
207
208impl GitCmd {
209 /// The subcommand and its fixed options — everything this module chose, with
210 /// nothing model-supplied in it.
211 fn options(&self) -> Vec<String> {
212 let mut v: Vec<String> = Vec::new();
213 match self {
214 Self::Status { .. } => {
215 v.push("status".into());
216 v.push("--porcelain=v1".into());
217 v.push("--untracked-files=normal".into());
218 }
219 Self::Diff { staged, .. } => {
220 v.push("diff".into());
221 v.push("--no-color".into());
222 v.push("--no-ext-diff".into());
223 if *staged {
224 v.push("--cached".into());
225 }
226 }
227 Self::Log { max_count, .. } => {
228 v.push("log".into());
229 v.push("--no-color".into());
230 v.push("--oneline".into());
231 v.push("--no-decorate".into());
232 v.push(format!("--max-count={max_count}"));
233 }
234 Self::Add { .. } => v.push("add".into()),
235 Self::Commit { message, .. } => {
236 v.push("commit".into());
237 // Belt and braces with `core.hooksPath`: that stops the hook
238 // being found, this stops it being asked for.
239 v.push("--no-verify".into());
240 v.push("-m".into());
241 v.push(message.clone());
242 }
243 }
244 v
245 }
246
247 /// The model-supplied half.
248 fn paths(&self) -> &[String] {
249 match self {
250 Self::Status { paths }
251 | Self::Diff { paths, .. }
252 | Self::Log { paths, .. }
253 | Self::Add { paths } => paths,
254 // A commit takes the index, not a pathspec.
255 Self::Commit { .. } => &[],
256 }
257 }
258
259 /// The `-c` settings this command needs before its subcommand.
260 fn config(&self) -> Result<Vec<String>> {
261 match self {
262 Self::Commit { identity, .. } => {
263 identity.check()?;
264 Ok(vec![
265 "-c".into(),
266 format!("user.name={}", identity.name),
267 "-c".into(),
268 format!("user.email={}", identity.email),
269 ])
270 }
271 _ => Ok(Vec::new()),
272 }
273 }
274}
275
276/// What a spawn produced.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub(crate) enum GitOutcome {
279 /// The child ran. `code` is `None` when a signal killed it.
280 Ran {
281 /// The exit status code.
282 code: Option<i32>,
283 /// Standard output, bounded by the run's per-observation cap.
284 stdout: String,
285 /// Standard error, bounded by the same cap.
286 stderr: String,
287 },
288 /// There is no `git` on this machine. A capability the host does not have is
289 /// not a failed run: the caller turns this into an observation and the agent
290 /// carries on without it, exactly as it would with a tool it was never given.
291 Unavailable {
292 /// What was looked for, for the observation text.
293 reason: String,
294 },
295}
296
297impl GitOutcome {
298 /// Whether git ran and reported success.
299 pub(crate) fn ok(&self) -> bool {
300 matches!(self, Self::Ran { code: Some(0), .. })
301 }
302}
303
304/// Spawns `GitCmd`s in one directory under one policy.
305pub(crate) struct Git<'a> {
306 policy: &'a Policy,
307 workdir: PathBuf,
308 program: String,
309 cap: usize,
310}
311
312impl<'a> Git<'a> {
313 /// Spawn in `workdir` under `policy`, bounding captured output at `cap`
314 /// chars — the run's per-observation cap from
315 /// [`entry_cap_chars`](crate::context::entry_cap_chars), so a git command
316 /// obeys the same ceiling as every other tool result rather than one of its
317 /// own.
318 pub(crate) fn new(policy: &'a Policy, workdir: impl Into<PathBuf>, cap: usize) -> Self {
319 Self {
320 policy,
321 workdir: workdir.into(),
322 program: GIT.into(),
323 cap,
324 }
325 }
326
327 /// Use a `git` that is not on `PATH` under that name.
328 ///
329 /// The policy check targets this exact string; `Act::Exec` patterns match by
330 /// full text or by basename, so a rule naming `git` still covers
331 /// `/usr/bin/git`.
332 // Only the tests need this today: production always runs the `git` on PATH,
333 // and a caller-configurable git binary is a capability nobody has asked for.
334 #[cfg(test)]
335 pub(crate) fn program(mut self, program: impl Into<String>) -> Self {
336 self.program = program.into();
337 self
338 }
339
340 /// The complete argv, program at index 0, or a refusal if any path is not
341 /// one.
342 ///
343 /// Order is load-bearing: the `-c` overrides and `--no-pager` must precede
344 /// the subcommand (git only accepts them there), and the `--` separator must
345 /// precede every model-supplied byte.
346 pub(crate) fn argv(&self, cmd: &GitCmd) -> Result<Vec<String>> {
347 let mut argv = vec![
348 self.program.clone(),
349 "--no-pager".into(),
350 "-c".into(),
351 format!("{NO_HOOKS}={NULL_DEVICE}"),
352 ];
353 argv.extend(cmd.config()?);
354 argv.extend(cmd.options());
355 argv.push("--".into());
356 for p in cmd.paths() {
357 check_path(p)?;
358 argv.push(p.clone());
359 }
360 Ok(argv)
361 }
362
363 /// Check the policy and run `cmd`, capturing a bounded result.
364 pub(crate) async fn run(&self, cmd: &GitCmd) -> Result<GitOutcome> {
365 // Build first: a refusable argument is refused before the policy is even
366 // consulted, so a permissive policy is not a way to smuggle one through.
367 let argv = self.argv(cmd)?;
368 let verdict = self.policy.check(Act::Exec, &self.program);
369 if verdict.effect != Effect::Allow {
370 return Err(Error::Refused {
371 act: "exec".into(),
372 target: self.program.clone(),
373 rule: verdict.rule,
374 layer: verdict.layer,
375 });
376 }
377 match self.command(&argv).output().await {
378 Ok(out) => Ok(GitOutcome::Ran {
379 code: out.status.code(),
380 stdout: cap_result(String::from_utf8_lossy(&out.stdout).into_owned(), self.cap).0,
381 stderr: cap_result(String::from_utf8_lossy(&out.stderr).into_owned(), self.cap).0,
382 }),
383 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(GitOutcome::Unavailable {
384 reason: format!("no `{}` on PATH", self.program),
385 }),
386 Err(e) => Err(Error::Io(e)),
387 }
388 }
389
390 /// The configured child. Separate from [`Git::run`] so the environment it
391 /// pins is testable without a `git` on the machine.
392 fn command(&self, argv: &[String]) -> Command {
393 let mut c = Command::new(&argv[0]);
394 c.args(&argv[1..])
395 .current_dir(&self.workdir)
396 // No inherited stdin: git must never be able to sit waiting on a
397 // terminal that will never answer.
398 .stdin(Stdio::null())
399 .stdout(Stdio::piped())
400 .stderr(Stdio::piped());
401 for (k, v) in FIXED_ENV {
402 c.env(k, v);
403 }
404 c
405 }
406}
407
408/// Accept one model-supplied path, or refuse it.
409///
410/// A leading `-` is refused outright rather than escaped or quoted. There is no
411/// escaping that helps: `git` parses its own argv, so the only two states are
412/// "this is an option" and "this is not present". The `--` separator already
413/// covers the well-behaved case; this covers the case where a future variant
414/// forgets it, and it is cheap. A path genuinely named `-foo` is still reachable
415/// as `./-foo`.
416fn check_path(p: &str) -> Result<()> {
417 let bad = if p.is_empty() {
418 Some("<empty path>")
419 } else if p.starts_with('-') {
420 Some("<path may not begin with `-`>")
421 } else {
422 None
423 };
424 match bad {
425 None => Ok(()),
426 Some(rule) => Err(Error::Refused {
427 act: "exec".into(),
428 target: p.to_string(),
429 rule: Some(rule.into()),
430 layer: None,
431 }),
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 /// A generous cap; the bound itself is [`cap_result`]'s and tested there.
440 const CAP: usize = 100_000;
441
442 fn git<'a>(policy: &'a Policy, dir: &std::path::Path) -> Git<'a> {
443 Git::new(policy, dir, CAP)
444 }
445
446 /// Every shape, with one benign path each, for the sweeps below.
447 fn every_shape() -> Vec<GitCmd> {
448 vec![
449 GitCmd::Status {
450 paths: vec!["src".into()],
451 },
452 GitCmd::Diff {
453 staged: false,
454 paths: vec!["src".into()],
455 },
456 GitCmd::Diff {
457 staged: true,
458 paths: vec![],
459 },
460 GitCmd::Log {
461 max_count: 20,
462 paths: vec!["src/main.rs".into()],
463 },
464 GitCmd::Add {
465 paths: vec!["src/main.rs".into()],
466 },
467 GitCmd::Commit {
468 message: "a message".into(),
469 identity: Identity::default(),
470 },
471 ]
472 }
473
474 /// Fails to compile if a variant is added without adding it to
475 /// [`every_shape`]. Without this the surface tests below would silently stop
476 /// covering the new capability while continuing to pass, which is the exact
477 /// way a closed surface quietly opens.
478 #[test]
479 fn every_shape_covers_every_variant() {
480 for cmd in every_shape() {
481 match cmd {
482 GitCmd::Status { .. }
483 | GitCmd::Diff { .. }
484 | GitCmd::Log { .. }
485 | GitCmd::Add { .. }
486 | GitCmd::Commit { .. } => {}
487 }
488 }
489 let mut kinds: Vec<_> = every_shape().iter().map(std::mem::discriminant).collect();
490 let before = kinds.len();
491 kinds.dedup_by(|a, b| a == b);
492 assert_eq!(before, 6, "every_shape lists six commands");
493 assert_eq!(
494 kinds.len(),
495 5,
496 "every_shape must contain one of each variant; add the new one"
497 );
498 }
499
500 /// The subcommand is the invariant, and it is checked directly rather than
501 /// by scanning the whole argv — the whole argv contains model-supplied data,
502 /// and a path named `push` is a path.
503 #[test]
504 fn the_subcommand_is_always_one_of_the_five_this_crate_ships() {
505 let p = Policy::permissive();
506 let dir = tempfile::tempdir().unwrap();
507 let g = git(&p, dir.path());
508 for cmd in every_shape() {
509 let argv = g.argv(&cmd).unwrap();
510 // The fixed prefix is program, --no-pager, -c, hooksPath, then any
511 // -c config pairs, then the subcommand.
512 let sub = argv
513 .iter()
514 .skip(1)
515 .find(|a| !a.starts_with('-') && !a.contains('='))
516 .expect("every argv has a subcommand");
517 assert!(
518 ["status", "diff", "log", "add", "commit"].contains(&sub.as_str()),
519 "{cmd:?} produced subcommand {sub:?}"
520 );
521 }
522 }
523
524 #[test]
525 fn a_path_beginning_with_a_dash_is_refused_and_the_same_path_without_it_is_not() {
526 let p = Policy::permissive();
527 let dir = tempfile::tempdir().unwrap();
528 let g = git(&p, dir.path());
529
530 let refused = g.argv(&GitCmd::Status {
531 paths: vec!["--exec=rm -rf /".into()],
532 });
533 assert!(matches!(refused, Err(Error::Refused { .. })), "{refused:?}");
534
535 // Negative control: the identical path with the leading `-` removed.
536 let argv = g
537 .argv(&GitCmd::Status {
538 paths: vec!["exec=rm -rf /".into()],
539 })
540 .unwrap();
541 assert_eq!(argv.last().unwrap(), "exec=rm -rf /");
542 }
543
544 #[test]
545 fn a_path_with_a_space_a_quote_and_a_newline_survives_as_one_argv_element() {
546 let p = Policy::permissive();
547 let dir = tempfile::tempdir().unwrap();
548 let nasty = "a dir/it's \"quoted\"\nand $(whoami) `id` ;rm -rf *";
549
550 let argv = git(&p, dir.path())
551 .argv(&GitCmd::Diff {
552 staged: false,
553 paths: vec![nasty.into()],
554 })
555 .unwrap();
556
557 // One element, byte-identical, and the last one — nothing split it.
558 assert_eq!(argv.iter().filter(|a| a.contains("whoami")).count(), 1);
559 assert_eq!(argv.last().unwrap(), nasty);
560 }
561
562 #[test]
563 fn a_path_named_like_a_flag_is_refused_and_its_relative_form_stays_data() {
564 let p = Policy::permissive();
565 let dir = tempfile::tempdir().unwrap();
566 let g = git(&p, dir.path());
567
568 // The classic git argument injection. Refused, not escaped.
569 let refused = g.argv(&GitCmd::Log {
570 max_count: 1,
571 paths: vec!["--upload-pack=x".into()],
572 });
573 assert!(matches!(refused, Err(Error::Refused { .. })), "{refused:?}");
574
575 // Negative control: a file genuinely called that, named the way a shell
576 // user names it. It is a path, it lands after `--`, and it is one element.
577 let argv = g
578 .argv(&GitCmd::Log {
579 max_count: 1,
580 paths: vec!["./--upload-pack=x".into()],
581 })
582 .unwrap();
583 let sep = argv.iter().position(|a| a == "--").unwrap();
584 assert_eq!(argv[sep + 1], "./--upload-pack=x");
585 assert_eq!(argv.len(), sep + 2);
586 }
587
588 #[test]
589 fn every_model_supplied_path_lands_after_the_separator() {
590 let p = Policy::permissive();
591 let dir = tempfile::tempdir().unwrap();
592 let g = git(&p, dir.path());
593 for cmd in every_shape() {
594 let argv = g.argv(&cmd).unwrap();
595 let sep = argv.iter().position(|a| a == "--").unwrap();
596 assert_eq!(&argv[sep + 1..], cmd.paths(), "{cmd:?}");
597 }
598 }
599
600 #[test]
601 fn no_shape_can_write_or_reach_the_network() {
602 let p = Policy::permissive();
603 let dir = tempfile::tempdir().unwrap();
604 let g = git(&p, dir.path());
605 const FORBIDDEN: &[&str] = &[
606 "push",
607 "fetch",
608 "clone",
609 "remote",
610 "reset",
611 "checkout",
612 "rebase",
613 "stash",
614 "filter-branch",
615 "--force",
616 ];
617 for cmd in every_shape() {
618 let argv = g.argv(&cmd).unwrap();
619 for word in FORBIDDEN {
620 assert!(
621 !argv.iter().any(|a| a.contains(word)),
622 "{cmd:?} produced {argv:?} containing {word}"
623 );
624 }
625 }
626 }
627
628 #[test]
629 fn the_child_pins_the_pager_the_locale_the_config_and_the_hooks() {
630 let p = Policy::permissive();
631 let dir = tempfile::tempdir().unwrap();
632 let g = git(&p, dir.path());
633 let argv = g.argv(&GitCmd::Status { paths: vec![] }).unwrap();
634
635 // Hooks are suppressed in the argv, because `-c` is the only place that
636 // beats the repository's own config.
637 assert_eq!(argv[1], "--no-pager");
638 assert_eq!(argv[2], "-c");
639 assert_eq!(argv[3], format!("core.hooksPath={NULL_DEVICE}"));
640
641 let cmd = g.command(&argv);
642 let env: Vec<(String, Option<String>)> = cmd
643 .as_std()
644 .get_envs()
645 .map(|(k, v)| {
646 (
647 k.to_string_lossy().into_owned(),
648 v.map(|v| v.to_string_lossy().into_owned()),
649 )
650 })
651 .collect();
652 for (k, v) in FIXED_ENV {
653 assert!(
654 env.contains(&(k.to_string(), Some(v.to_string()))),
655 "{k}={v} missing from {env:?}"
656 );
657 }
658 assert_eq!(cmd.as_std().get_current_dir(), Some(dir.path()));
659 }
660
661 #[tokio::test]
662 async fn a_missing_git_binary_is_an_unavailable_outcome_not_a_run_failure() {
663 let p = Policy::permissive();
664 let dir = tempfile::tempdir().unwrap();
665 let out = git(&p, dir.path())
666 .program("io-harness-no-such-git")
667 .run(&GitCmd::Status { paths: vec![] })
668 .await
669 .unwrap();
670 assert!(matches!(out, GitOutcome::Unavailable { .. }), "{out:?}");
671 assert!(!out.ok());
672 }
673
674 #[tokio::test]
675 async fn an_exec_deny_refuses_the_spawn_and_a_permissive_policy_does_not() {
676 let dir = tempfile::tempdir().unwrap();
677 let cmd = GitCmd::Status { paths: vec![] };
678
679 let denied = Policy::default()
680 .layer("l")
681 .deny_exec("io-harness-fake-git");
682 let err = git(&denied, dir.path())
683 .program("io-harness-fake-git")
684 .run(&cmd)
685 .await
686 .unwrap_err();
687 assert!(
688 matches!(&err, Error::Refused { act, target, .. }
689 if act == "exec" && target == "io-harness-fake-git"),
690 "{err:?}"
691 );
692
693 // Negative control: the same spawn under a policy that allows exec gets
694 // past the gate — it reaches the spawn and reports the binary missing.
695 let allowed = Policy::permissive();
696 let out = git(&allowed, dir.path())
697 .program("io-harness-fake-git")
698 .run(&cmd)
699 .await
700 .unwrap();
701 assert!(matches!(out, GitOutcome::Unavailable { .. }), "{out:?}");
702 }
703
704 /// End to end, when the machine has a git. Deliberately outside a repository:
705 /// it proves capture and exit status without needing one, and needs no
706 /// network. A machine without git skips, and the test above covers that path.
707 #[tokio::test]
708 async fn a_real_git_reports_its_own_failure_rather_than_erroring_the_run() {
709 let p = Policy::permissive();
710 let dir = tempfile::tempdir().unwrap();
711 let out = git(&p, dir.path())
712 .run(&GitCmd::Status { paths: vec![] })
713 .await
714 .unwrap();
715 let GitOutcome::Ran { code, stderr, .. } = out else {
716 return; // no git on this machine
717 };
718 // 128 outside a repository; 0 on the rare box whose TMPDIR sits inside
719 // one. Either way git ran, and its failure came back as a result.
720 match code {
721 Some(128) => assert!(stderr.contains("not a git repository"), "{stderr}"),
722 other => assert_eq!(other, Some(0), "{stderr}"),
723 }
724 }
725}