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/// `Serialize`/`Deserialize` since 0.19.0, so a team's commit identity can live
171/// in the project's config file rather than in each program that embeds the
172/// crate. Both fields are `#[serde(default)]`, and the validation below still
173/// runs when the identity is used — a deserialized identity is not a checked one.
174///
175/// ```
176/// use io_harness::Identity;
177///
178/// let team: Identity = serde_json::from_str(r#"{"name": "release bot"}"#).unwrap();
179/// assert_eq!(team.name, "release bot");
180/// assert_eq!(team.email, Identity::default().email);
181/// ```
182#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
183#[serde(default, deny_unknown_fields)]
184pub struct Identity {
185    /// The committer name.
186    pub name: String,
187    /// The committer email.
188    pub email: String,
189}
190
191impl Default for Identity {
192    /// An agent identity at a domain that can never exist. `.invalid` is
193    /// reserved by RFC 2606 precisely so that a synthetic address cannot
194    /// accidentally be someone's.
195    fn default() -> Self {
196        Self {
197            name: "io-harness agent".into(),
198            email: "agent@io-harness.invalid".into(),
199        }
200    }
201}
202
203impl Identity {
204    /// Refuse an identity that could split into more than one `-c` setting.
205    ///
206    /// `-c key=value` is a single argv element, so a value cannot introduce a
207    /// second setting — but a newline in a name reaches the commit object and
208    /// the reflog, and there is no reason to carry one.
209    fn check(&self) -> Result<()> {
210        for (what, v) in [("name", &self.name), ("email", &self.email)] {
211            if v.is_empty() || v.chars().any(char::is_control) {
212                return Err(Error::Config(format!(
213                    "commit identity {what} must be non-empty and free of control characters"
214                )));
215            }
216        }
217        Ok(())
218    }
219}
220
221impl GitCmd {
222    /// The subcommand and its fixed options — everything this module chose, with
223    /// nothing model-supplied in it.
224    fn options(&self) -> Vec<String> {
225        let mut v: Vec<String> = Vec::new();
226        match self {
227            Self::Status { .. } => {
228                v.push("status".into());
229                v.push("--porcelain=v1".into());
230                v.push("--untracked-files=normal".into());
231            }
232            Self::Diff { staged, .. } => {
233                v.push("diff".into());
234                v.push("--no-color".into());
235                v.push("--no-ext-diff".into());
236                if *staged {
237                    v.push("--cached".into());
238                }
239            }
240            Self::Log { max_count, .. } => {
241                v.push("log".into());
242                v.push("--no-color".into());
243                v.push("--oneline".into());
244                v.push("--no-decorate".into());
245                v.push(format!("--max-count={max_count}"));
246            }
247            Self::Add { .. } => v.push("add".into()),
248            Self::Commit { message, .. } => {
249                v.push("commit".into());
250                // Belt and braces with `core.hooksPath`: that stops the hook
251                // being found, this stops it being asked for.
252                v.push("--no-verify".into());
253                v.push("-m".into());
254                v.push(message.clone());
255            }
256        }
257        v
258    }
259
260    /// The model-supplied half.
261    fn paths(&self) -> &[String] {
262        match self {
263            Self::Status { paths }
264            | Self::Diff { paths, .. }
265            | Self::Log { paths, .. }
266            | Self::Add { paths } => paths,
267            // A commit takes the index, not a pathspec.
268            Self::Commit { .. } => &[],
269        }
270    }
271
272    /// The `-c` settings this command needs before its subcommand.
273    fn config(&self) -> Result<Vec<String>> {
274        match self {
275            Self::Commit { identity, .. } => {
276                identity.check()?;
277                Ok(vec![
278                    "-c".into(),
279                    format!("user.name={}", identity.name),
280                    "-c".into(),
281                    format!("user.email={}", identity.email),
282                ])
283            }
284            _ => Ok(Vec::new()),
285        }
286    }
287}
288
289/// What a spawn produced.
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub(crate) enum GitOutcome {
292    /// The child ran. `code` is `None` when a signal killed it.
293    Ran {
294        /// The exit status code.
295        code: Option<i32>,
296        /// Standard output, bounded by the run's per-observation cap.
297        stdout: String,
298        /// Standard error, bounded by the same cap.
299        stderr: String,
300    },
301    /// There is no `git` on this machine. A capability the host does not have is
302    /// not a failed run: the caller turns this into an observation and the agent
303    /// carries on without it, exactly as it would with a tool it was never given.
304    Unavailable {
305        /// What was looked for, for the observation text.
306        reason: String,
307    },
308}
309
310impl GitOutcome {
311    /// Whether git ran and reported success.
312    pub(crate) fn ok(&self) -> bool {
313        matches!(self, Self::Ran { code: Some(0), .. })
314    }
315}
316
317/// Spawns `GitCmd`s in one directory under one policy.
318pub(crate) struct Git<'a> {
319    policy: &'a Policy,
320    workdir: PathBuf,
321    program: String,
322    cap: usize,
323}
324
325impl<'a> Git<'a> {
326    /// Spawn in `workdir` under `policy`, bounding captured output at `cap`
327    /// chars — the run's per-observation cap from
328    /// [`entry_cap_chars`](crate::context::entry_cap_chars), so a git command
329    /// obeys the same ceiling as every other tool result rather than one of its
330    /// own.
331    pub(crate) fn new(policy: &'a Policy, workdir: impl Into<PathBuf>, cap: usize) -> Self {
332        Self {
333            policy,
334            workdir: workdir.into(),
335            program: GIT.into(),
336            cap,
337        }
338    }
339
340    /// Use a `git` that is not on `PATH` under that name.
341    ///
342    /// The policy check targets this exact string; `Act::Exec` patterns match by
343    /// full text or by basename, so a rule naming `git` still covers
344    /// `/usr/bin/git`.
345    // Only the tests need this today: production always runs the `git` on PATH,
346    // and a caller-configurable git binary is a capability nobody has asked for.
347    #[cfg(test)]
348    pub(crate) fn program(mut self, program: impl Into<String>) -> Self {
349        self.program = program.into();
350        self
351    }
352
353    /// The complete argv, program at index 0, or a refusal if any path is not
354    /// one.
355    ///
356    /// Order is load-bearing: the `-c` overrides and `--no-pager` must precede
357    /// the subcommand (git only accepts them there), and the `--` separator must
358    /// precede every model-supplied byte.
359    pub(crate) fn argv(&self, cmd: &GitCmd) -> Result<Vec<String>> {
360        let mut argv = vec![
361            self.program.clone(),
362            "--no-pager".into(),
363            "-c".into(),
364            format!("{NO_HOOKS}={NULL_DEVICE}"),
365        ];
366        argv.extend(cmd.config()?);
367        argv.extend(cmd.options());
368        argv.push("--".into());
369        for p in cmd.paths() {
370            check_path(p)?;
371            argv.push(p.clone());
372        }
373        Ok(argv)
374    }
375
376    /// Check the policy and run `cmd`, capturing a bounded result.
377    pub(crate) async fn run(&self, cmd: &GitCmd) -> Result<GitOutcome> {
378        // Build first: a refusable argument is refused before the policy is even
379        // consulted, so a permissive policy is not a way to smuggle one through.
380        let argv = self.argv(cmd)?;
381        let verdict = self.policy.check(Act::Exec, &self.program);
382        if verdict.effect != Effect::Allow {
383            return Err(Error::Refused {
384                act: "exec".into(),
385                target: self.program.clone(),
386                rule: verdict.rule,
387                layer: verdict.layer,
388            });
389        }
390        match self.command(&argv).output().await {
391            Ok(out) => Ok(GitOutcome::Ran {
392                code: out.status.code(),
393                stdout: cap_result(String::from_utf8_lossy(&out.stdout).into_owned(), self.cap).0,
394                stderr: cap_result(String::from_utf8_lossy(&out.stderr).into_owned(), self.cap).0,
395            }),
396            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(GitOutcome::Unavailable {
397                reason: format!("no `{}` on PATH", self.program),
398            }),
399            Err(e) => Err(Error::Io(e)),
400        }
401    }
402
403    /// The configured child. Separate from [`Git::run`] so the environment it
404    /// pins is testable without a `git` on the machine.
405    fn command(&self, argv: &[String]) -> Command {
406        let mut c = Command::new(&argv[0]);
407        c.args(&argv[1..])
408            .current_dir(&self.workdir)
409            // No inherited stdin: git must never be able to sit waiting on a
410            // terminal that will never answer.
411            .stdin(Stdio::null())
412            .stdout(Stdio::piped())
413            .stderr(Stdio::piped());
414        for (k, v) in FIXED_ENV {
415            c.env(k, v);
416        }
417        c
418    }
419}
420
421/// Accept one model-supplied path, or refuse it.
422///
423/// A leading `-` is refused outright rather than escaped or quoted. There is no
424/// escaping that helps: `git` parses its own argv, so the only two states are
425/// "this is an option" and "this is not present". The `--` separator already
426/// covers the well-behaved case; this covers the case where a future variant
427/// forgets it, and it is cheap. A path genuinely named `-foo` is still reachable
428/// as `./-foo`.
429fn check_path(p: &str) -> Result<()> {
430    let bad = if p.is_empty() {
431        Some("<empty path>")
432    } else if p.starts_with('-') {
433        Some("<path may not begin with `-`>")
434    } else {
435        None
436    };
437    match bad {
438        None => Ok(()),
439        Some(rule) => Err(Error::Refused {
440            act: "exec".into(),
441            target: p.to_string(),
442            rule: Some(rule.into()),
443            layer: None,
444        }),
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    /// A generous cap; the bound itself is [`cap_result`]'s and tested there.
453    const CAP: usize = 100_000;
454
455    fn git<'a>(policy: &'a Policy, dir: &std::path::Path) -> Git<'a> {
456        Git::new(policy, dir, CAP)
457    }
458
459    /// Every shape, with one benign path each, for the sweeps below.
460    fn every_shape() -> Vec<GitCmd> {
461        vec![
462            GitCmd::Status {
463                paths: vec!["src".into()],
464            },
465            GitCmd::Diff {
466                staged: false,
467                paths: vec!["src".into()],
468            },
469            GitCmd::Diff {
470                staged: true,
471                paths: vec![],
472            },
473            GitCmd::Log {
474                max_count: 20,
475                paths: vec!["src/main.rs".into()],
476            },
477            GitCmd::Add {
478                paths: vec!["src/main.rs".into()],
479            },
480            GitCmd::Commit {
481                message: "a message".into(),
482                identity: Identity::default(),
483            },
484        ]
485    }
486
487    /// Fails to compile if a variant is added without adding it to
488    /// [`every_shape`]. Without this the surface tests below would silently stop
489    /// covering the new capability while continuing to pass, which is the exact
490    /// way a closed surface quietly opens.
491    #[test]
492    fn every_shape_covers_every_variant() {
493        for cmd in every_shape() {
494            match cmd {
495                GitCmd::Status { .. }
496                | GitCmd::Diff { .. }
497                | GitCmd::Log { .. }
498                | GitCmd::Add { .. }
499                | GitCmd::Commit { .. } => {}
500            }
501        }
502        let mut kinds: Vec<_> = every_shape().iter().map(std::mem::discriminant).collect();
503        let before = kinds.len();
504        kinds.dedup_by(|a, b| a == b);
505        assert_eq!(before, 6, "every_shape lists six commands");
506        assert_eq!(
507            kinds.len(),
508            5,
509            "every_shape must contain one of each variant; add the new one"
510        );
511    }
512
513    /// The subcommand is the invariant, and it is checked directly rather than
514    /// by scanning the whole argv — the whole argv contains model-supplied data,
515    /// and a path named `push` is a path.
516    #[test]
517    fn the_subcommand_is_always_one_of_the_five_this_crate_ships() {
518        let p = Policy::permissive();
519        let dir = tempfile::tempdir().unwrap();
520        let g = git(&p, dir.path());
521        for cmd in every_shape() {
522            let argv = g.argv(&cmd).unwrap();
523            // The fixed prefix is program, --no-pager, -c, hooksPath, then any
524            // -c config pairs, then the subcommand.
525            let sub = argv
526                .iter()
527                .skip(1)
528                .find(|a| !a.starts_with('-') && !a.contains('='))
529                .expect("every argv has a subcommand");
530            assert!(
531                ["status", "diff", "log", "add", "commit"].contains(&sub.as_str()),
532                "{cmd:?} produced subcommand {sub:?}"
533            );
534        }
535    }
536
537    #[test]
538    fn a_path_beginning_with_a_dash_is_refused_and_the_same_path_without_it_is_not() {
539        let p = Policy::permissive();
540        let dir = tempfile::tempdir().unwrap();
541        let g = git(&p, dir.path());
542
543        let refused = g.argv(&GitCmd::Status {
544            paths: vec!["--exec=rm -rf /".into()],
545        });
546        assert!(matches!(refused, Err(Error::Refused { .. })), "{refused:?}");
547
548        // Negative control: the identical path with the leading `-` removed.
549        let argv = g
550            .argv(&GitCmd::Status {
551                paths: vec!["exec=rm -rf /".into()],
552            })
553            .unwrap();
554        assert_eq!(argv.last().unwrap(), "exec=rm -rf /");
555    }
556
557    #[test]
558    fn a_path_with_a_space_a_quote_and_a_newline_survives_as_one_argv_element() {
559        let p = Policy::permissive();
560        let dir = tempfile::tempdir().unwrap();
561        let nasty = "a dir/it's \"quoted\"\nand $(whoami) `id` ;rm -rf *";
562
563        let argv = git(&p, dir.path())
564            .argv(&GitCmd::Diff {
565                staged: false,
566                paths: vec![nasty.into()],
567            })
568            .unwrap();
569
570        // One element, byte-identical, and the last one — nothing split it.
571        assert_eq!(argv.iter().filter(|a| a.contains("whoami")).count(), 1);
572        assert_eq!(argv.last().unwrap(), nasty);
573    }
574
575    #[test]
576    fn a_path_named_like_a_flag_is_refused_and_its_relative_form_stays_data() {
577        let p = Policy::permissive();
578        let dir = tempfile::tempdir().unwrap();
579        let g = git(&p, dir.path());
580
581        // The classic git argument injection. Refused, not escaped.
582        let refused = g.argv(&GitCmd::Log {
583            max_count: 1,
584            paths: vec!["--upload-pack=x".into()],
585        });
586        assert!(matches!(refused, Err(Error::Refused { .. })), "{refused:?}");
587
588        // Negative control: a file genuinely called that, named the way a shell
589        // user names it. It is a path, it lands after `--`, and it is one element.
590        let argv = g
591            .argv(&GitCmd::Log {
592                max_count: 1,
593                paths: vec!["./--upload-pack=x".into()],
594            })
595            .unwrap();
596        let sep = argv.iter().position(|a| a == "--").unwrap();
597        assert_eq!(argv[sep + 1], "./--upload-pack=x");
598        assert_eq!(argv.len(), sep + 2);
599    }
600
601    #[test]
602    fn every_model_supplied_path_lands_after_the_separator() {
603        let p = Policy::permissive();
604        let dir = tempfile::tempdir().unwrap();
605        let g = git(&p, dir.path());
606        for cmd in every_shape() {
607            let argv = g.argv(&cmd).unwrap();
608            let sep = argv.iter().position(|a| a == "--").unwrap();
609            assert_eq!(&argv[sep + 1..], cmd.paths(), "{cmd:?}");
610        }
611    }
612
613    #[test]
614    fn no_shape_can_write_or_reach_the_network() {
615        let p = Policy::permissive();
616        let dir = tempfile::tempdir().unwrap();
617        let g = git(&p, dir.path());
618        const FORBIDDEN: &[&str] = &[
619            "push",
620            "fetch",
621            "clone",
622            "remote",
623            "reset",
624            "checkout",
625            "rebase",
626            "stash",
627            "filter-branch",
628            "--force",
629        ];
630        for cmd in every_shape() {
631            let argv = g.argv(&cmd).unwrap();
632            for word in FORBIDDEN {
633                assert!(
634                    !argv.iter().any(|a| a.contains(word)),
635                    "{cmd:?} produced {argv:?} containing {word}"
636                );
637            }
638        }
639    }
640
641    #[test]
642    fn the_child_pins_the_pager_the_locale_the_config_and_the_hooks() {
643        let p = Policy::permissive();
644        let dir = tempfile::tempdir().unwrap();
645        let g = git(&p, dir.path());
646        let argv = g.argv(&GitCmd::Status { paths: vec![] }).unwrap();
647
648        // Hooks are suppressed in the argv, because `-c` is the only place that
649        // beats the repository's own config.
650        assert_eq!(argv[1], "--no-pager");
651        assert_eq!(argv[2], "-c");
652        assert_eq!(argv[3], format!("core.hooksPath={NULL_DEVICE}"));
653
654        let cmd = g.command(&argv);
655        let env: Vec<(String, Option<String>)> = cmd
656            .as_std()
657            .get_envs()
658            .map(|(k, v)| {
659                (
660                    k.to_string_lossy().into_owned(),
661                    v.map(|v| v.to_string_lossy().into_owned()),
662                )
663            })
664            .collect();
665        for (k, v) in FIXED_ENV {
666            assert!(
667                env.contains(&(k.to_string(), Some(v.to_string()))),
668                "{k}={v} missing from {env:?}"
669            );
670        }
671        assert_eq!(cmd.as_std().get_current_dir(), Some(dir.path()));
672    }
673
674    #[tokio::test]
675    async fn a_missing_git_binary_is_an_unavailable_outcome_not_a_run_failure() {
676        let p = Policy::permissive();
677        let dir = tempfile::tempdir().unwrap();
678        let out = git(&p, dir.path())
679            .program("io-harness-no-such-git")
680            .run(&GitCmd::Status { paths: vec![] })
681            .await
682            .unwrap();
683        assert!(matches!(out, GitOutcome::Unavailable { .. }), "{out:?}");
684        assert!(!out.ok());
685    }
686
687    #[tokio::test]
688    async fn an_exec_deny_refuses_the_spawn_and_a_permissive_policy_does_not() {
689        let dir = tempfile::tempdir().unwrap();
690        let cmd = GitCmd::Status { paths: vec![] };
691
692        let denied = Policy::default()
693            .layer("l")
694            .deny_exec("io-harness-fake-git");
695        let err = git(&denied, dir.path())
696            .program("io-harness-fake-git")
697            .run(&cmd)
698            .await
699            .unwrap_err();
700        assert!(
701            matches!(&err, Error::Refused { act, target, .. }
702                if act == "exec" && target == "io-harness-fake-git"),
703            "{err:?}"
704        );
705
706        // Negative control: the same spawn under a policy that allows exec gets
707        // past the gate — it reaches the spawn and reports the binary missing.
708        let allowed = Policy::permissive();
709        let out = git(&allowed, dir.path())
710            .program("io-harness-fake-git")
711            .run(&cmd)
712            .await
713            .unwrap();
714        assert!(matches!(out, GitOutcome::Unavailable { .. }), "{out:?}");
715    }
716
717    /// End to end, when the machine has a git. Deliberately outside a repository:
718    /// it proves capture and exit status without needing one, and needs no
719    /// network. A machine without git skips, and the test above covers that path.
720    #[tokio::test]
721    async fn a_real_git_reports_its_own_failure_rather_than_erroring_the_run() {
722        let p = Policy::permissive();
723        let dir = tempfile::tempdir().unwrap();
724        let out = git(&p, dir.path())
725            .run(&GitCmd::Status { paths: vec![] })
726            .await
727            .unwrap();
728        let GitOutcome::Ran { code, stderr, .. } = out else {
729            return; // no git on this machine
730        };
731        // 128 outside a repository; 0 on the rare box whose TMPDIR sits inside
732        // one. Either way git ran, and its failure came back as a result.
733        match code {
734            Some(128) => assert!(stderr.contains("not a git repository"), "{stderr}"),
735            other => assert_eq!(other, Some(0), "{stderr}"),
736        }
737    }
738}