Skip to main content

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}