Skip to content
209 changes: 209 additions & 0 deletions agent-shell/src/git_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//! HGW-2: git state that would run a program later, outside the sandbox.
//!
//! The sandbox lets a run write `.git/` inside its write roots, so `git init`, `git config` and
//! `git commit` work. A run can then leave git set up to start a program the next time the
//! member uses git in a terminal, where no sandbox applies: a hook, `core.hooksPath`,
//! `core.fsmonitor`, an alias that starts a shell, a diff or filter driver, an include. The
//! Seatbelt profile keeps hook files out of `.git/hooks`, but configuration cannot be filtered by
//! path. So the runner looks at the repositories a run could have touched before and after it,
//! and reports what is new ([`crate::RunReport::git_warnings`]) for the member to see. It reports,
//! it does not change anything.
//!
//! Honest scope: the repositories checked are the run's folder, its parents up to the write root
//! that holds it, and the folders directly inside the run's folder. A repository created deeper
//! than that is not checked.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

/// Most entries of the run's folder looked at for nested repositories.
const MAX_CHILDREN: usize = 256;
/// Largest `.git/config` read.
const MAX_CONFIG_BYTES: u64 = 256 * 1024;

/// The repositories (their `.git` folders) a run in `cwd` with `write_roots` could touch.
pub(crate) fn repo_dirs(cwd: &Path, write_roots: &[PathBuf]) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let mut push = |p: PathBuf| {
if !out.contains(&p) {
out.push(p);
}
};
if let Some(root) = write_roots
.iter()
.filter(|r| cwd.starts_with(r))
.max_by_key(|r| r.components().count())
{
for dir in cwd.ancestors() {
push(dir.join(".git"));
if dir == root.as_path() {
break;
}
}
}
if let Ok(rd) = std::fs::read_dir(cwd) {
for e in rd.flatten().take(MAX_CHILDREN) {
if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
push(e.path().join(".git"));
}
}
}
out
}

/// One line per risky item across `git_dirs` (sorted, without repeats).
pub(crate) fn risks(git_dirs: &[PathBuf]) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for g in git_dirs {
let Ok(meta) = std::fs::symlink_metadata(g) else {
continue;
};
if !meta.is_dir() {
continue;
}
let hooks = g.join("hooks");
match std::fs::symlink_metadata(&hooks) {
Ok(m) if m.file_type().is_symlink() => {
out.insert(format!("{} is a symbolic link", hooks.display()));
}
Ok(m) if m.is_dir() => {
if let Ok(rd) = std::fs::read_dir(&hooks) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.ends_with(".sample") {
out.insert(format!(
"git hook {name} in {} (git runs it outside the sandbox)",
hooks.display()
));
}
}
}
}
_ => {}
}
let config = g.join("config");
if let Ok(m) = std::fs::symlink_metadata(&config) {
if m.file_type().is_symlink() {
out.insert(format!("{} is a symbolic link", config.display()));
} else if m.len() <= MAX_CONFIG_BYTES {
if let Ok(text) = std::fs::read_to_string(&config) {
for (key, value) in risky_config(&text) {
out.insert(format!(
"git config {key} = {value} in {} (git may run a program from it)",
config.display()
));
}
}
} else {
out.insert(format!("{} is too large to check", config.display()));
}
}
}
out
}

/// `(section.key, value)` pairs in a git config text that make git start a program. Keys are
/// lowercased (git's own rule for section and key names; a subsection keeps its case).
pub(crate) fn risky_config(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut section = String::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if let Some(rest) = line.strip_prefix('[') {
let inner = rest.split(']').next().unwrap_or("").trim();
section = match inner.split_once(char::is_whitespace) {
Some((name, sub)) => {
format!(
"{}.{}",
name.to_ascii_lowercase(),
sub.trim().trim_matches('"')
)
}
None => inner.to_ascii_lowercase(),
};
continue;
}
let (k, v) = match line.split_once('=') {
Some((k, v)) => (k.trim(), v.trim().trim_matches('"')),
None => (line, "true"),
};
let key = format!("{section}.{}", k.to_ascii_lowercase());
if is_risky(&key, v) {
out.push((key, v.to_string()));
}
}
out
}

fn is_risky(key: &str, value: &str) -> bool {
let lower = key.to_ascii_lowercase();
let (first, rest) = lower.split_once('.').unwrap_or((lower.as_str(), ""));
let last = rest.rsplit('.').next().unwrap_or(rest);
let off = matches!(
value.to_ascii_lowercase().as_str(),
"" | "false" | "0" | "no" | "off"
);
match first {
"core" => match last {
"fsmonitor" => !off,
"hookspath" | "sshcommand" | "gitproxy" | "editor" | "pager" | "askpass" => true,
_ => false,
},
"alias" => value.trim_start().starts_with('!'),
"sequence" => last == "editor",
"credential" => last == "helper",
"gpg" => last == "program",
"diff" => matches!(last, "external" | "command" | "textconv"),
"filter" => matches!(last, "clean" | "smudge" | "process"),
"merge" => last == "driver",
"include" | "includeif" => last == "path",
"uploadpack" => last == "packobjectshook",
"remote" => matches!(last, "uploadpack" | "receivepack"),
"pager" | "browser" | "man" | "difftool" | "mergetool" => {
matches!(last, "cmd" | "path") || first == "pager"
}
_ => false,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn risky_keys_are_found_and_harmless_ones_are_not() {
let text = "[core]\n\trepositoryformatversion = 0\n\tfsmonitor = false\n\thooksPath = .h\n[alias]\n\tst = status\n\tx = !sh -c id\n[filter \"lfs\"]\n\tsmudge = git-lfs smudge %f\n[remote \"origin\"]\n\turl = https://example.invalid/r.git\n[includeIf \"gitdir:~/\"]\n\tpath = ~/.evil\n";
let keys: Vec<String> = risky_config(text).into_iter().map(|(k, _)| k).collect();
assert_eq!(
keys,
vec![
"core.hookspath",
"alias.x",
"filter.lfs.smudge",
"includeif.gitdir:~/.path"
]
);
assert!(risky_config("[core]\n\tfsmonitor = true\n")
.iter()
.any(|(k, _)| k == "core.fsmonitor"));
}

#[test]
fn repo_dirs_cover_the_cwd_its_parents_to_the_root_and_its_children() {
let d = tempfile::tempdir().expect("tmp");
let root = d.path().join("root");
std::fs::create_dir_all(root.join("a/b/c")).expect("dirs");
let got = repo_dirs(&root.join("a/b"), std::slice::from_ref(&root));
assert!(got.contains(&root.join("a/b/.git")));
assert!(got.contains(&root.join("a/.git")));
assert!(got.contains(&root.join(".git")));
assert!(got.contains(&root.join("a/b/c/.git")));
assert!(
!got.contains(&d.path().join(".git")),
"never above the write root"
);
}
}
16 changes: 16 additions & 0 deletions agent-shell/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
//! That crate is async and tied to the legacy tool trait, so the logic is re-homed here as a
//! synchronous, dependency-light library.

mod git_state;
pub mod sandbox;
#[cfg(windows)]
mod win;
Expand Down Expand Up @@ -847,6 +848,12 @@ pub struct RunReport {
pub cleanup_error: Option<String>,
/// Whether (and how) the run was wrapped in the OS sandbox.
pub sandbox: SandboxSummary,
/// HGW-2: git state the run left behind that would start a program the next time the member
/// uses git outside the sandbox (a hook, `core.hooksPath`, an alias that runs a shell, …), and
/// that was not there before the run. Reported, never changed. Empty (and not serialized)
/// when there is nothing new.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub git_warnings: Vec<String>,
}

impl RunReport {
Expand Down Expand Up @@ -1141,6 +1148,9 @@ impl ShellRunner {
cmd.env(k, v);
}

// HGW-2: what git would run later, before the run (only what is new is reported).
let repos = git_state::repo_dirs(&plan.cwd, &plan.write_roots);
let git_before = git_state::risks(&repos);
let tree = ProcessTree::prepare().map_err(|e| ShellError::Spawn {
program: req.program.clone(),
reason: format!("cannot create the process tree container: {e}"),
Expand Down Expand Up @@ -1199,6 +1209,10 @@ impl ShellRunner {
);
}

let git_warnings: Vec<String> = git_state::risks(&repos)
.difference(&git_before)
.cloned()
.collect();
Ok(RunReport {
program: req.program.clone(),
resolved_path: plan.resolved_path,
Expand All @@ -1218,6 +1232,7 @@ impl ShellRunner {
output_incomplete,
cleanup_error,
sandbox: plan.sandbox,
git_warnings,
})
}
}
Expand Down Expand Up @@ -1794,6 +1809,7 @@ mod tests {
output_incomplete: false,
cleanup_error: Some("scratch removal failed".into()),
sandbox: SandboxSummary::not_enforced("test", &[]),
git_warnings: Vec::new(),
};

assert!(!report.passed());
Expand Down
48 changes: 34 additions & 14 deletions agent-shell/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@
//! - **macOS: Seatbelt** through `/usr/bin/sandbox-exec`. The profile is generated per run
//! ([`seatbelt_profile`]) and starts from `(deny default)`. It never allows a network
//! operation. Every path is passed as a `-D` parameter, never spliced into the profile text,
//! so a folder name cannot change the policy. Writes to `.git/hooks`, `.git/config` and
//! `.env*` files are denied even inside a granted folder (a hook planted there would run later
//! outside the sandbox).
//! so a folder name cannot change the policy. Inside a granted folder `.git/` is writable
//! (HGW-2: `git init`, `git config` and `git commit` work), but a hook file other than git's own
//! `*.sample` files cannot be written, the hooks folder cannot be removed or moved, and a
//! symbolic link cannot be put in its place (a hook planted there would run later outside the
//! sandbox). `.env*` files are neither readable nor writable, except `.env.example` and
//! `.env.sample`. Configuration that makes git run
//! a program cannot be filtered by path, so the runner reports any it finds new after a run
//! (`RunReport::git_warnings`).
//! - **Linux: bubblewrap** (`bwrap`, found only in fixed system directories). The run gets fresh
//! namespaces with networking unshared (`--unshare-all`), read-only binds of the system and
//! toolchain directories, writable binds of the granted folders and the scratch HOME, and
//! read-only masks over `.git/hooks`, `.git/config` and the `.env*` files that exist at the top
//! of a granted folder ([`bwrap_command`]). A Landlock fallback is not implemented: with no
//! read-only masks over `.git/hooks` and the `.env*` files that exist at the top of a granted
//! folder ([`bwrap_command`]). `.git/config` is writable (HGW-2), as on macOS. A Landlock fallback is not implemented: with no
//! working `bwrap` the backend is unavailable.
//! - **Windows**: the runner builds and uses a Job Object for bounded process cleanup, but has
//! no OS sandbox backend. A Job Object is cleanup containment, not a filesystem, network, or
Expand Down Expand Up @@ -364,9 +369,21 @@ pub fn seatbelt_profile(n_read: usize, n_write: usize) -> String {
"(allow file-write-data (literal \"/dev/null\") (literal \"/dev/zero\") (literal \"/dev/dtracehelper\"))\n",
);
p.push_str("(allow file-ioctl (literal \"/dev/dtracehelper\"))\n");
// Later rules win: these hold even inside a granted folder.
p.push_str("(deny file-write* (regex #\"/\\.git/hooks(/|$)\") (regex #\"/\\.git/config$\"))\n");
p.push_str("(deny file-read* file-write* (regex #\"/\\.env[^/]*$\"))\n");
// Later rules win: these hold even inside a granted folder. HGW-2: `.git/` is writable (git
// init/config/commit), except a hook that is not one of git's `*.sample` files, removing or
// moving the hooks folder, and a symbolic link in its place.
p.push_str(
"(deny file-write* (require-all (regex #\"/\\.git/hooks/[^/]+$\") (require-not (regex #\"\\.sample$\"))))\n",
);
p.push_str("(deny file-write-unlink (regex #\"/\\.git/hooks$\"))\n");
p.push_str(
"(deny file-write-create (require-all (regex #\"/\\.git/hooks$\") (vnode-type SYMLINK)))\n",
);
// `.env*` files are neither readable nor writable, except the templates' `.env.example` and
// `.env.sample` (HGW-W2: not secrets; agent-guard exempts them the same way).
p.push_str(
"(deny file-read* file-write* (require-all (regex #\"/\\.env[^/]*$\") (require-not (regex #\"/\\.env\\.(example|sample)$\"))))\n",
);
p
}

Expand Down Expand Up @@ -436,10 +453,8 @@ pub fn bwrap_command(spec: &SandboxSpec, program: &Path, args: &[String]) -> Vec
]);
// Masks go after the writable binds, so they sit on top of them.
for w in &spec.write_roots {
for sub in [".git/hooks", ".git/config"] {
let p = w.join(sub);
push(&["--ro-bind-try".as_ref(), p.as_os_str(), p.as_os_str()]);
}
let p = w.join(".git/hooks");
push(&["--ro-bind-try".as_ref(), p.as_os_str(), p.as_os_str()]);
}
for f in &spec.masked_files {
push(&["--ro-bind".as_ref(), "/dev/null".as_ref(), f.as_os_str()]);
Expand All @@ -450,7 +465,8 @@ pub fn bwrap_command(spec: &SandboxSpec, program: &Path, args: &[String]) -> Vec
a
}

/// `.env*` files at the top of each write root (bwrap masks them; Seatbelt denies by pattern).
/// `.env*` files at the top of each write root (bwrap masks them; Seatbelt denies by pattern),
/// except the templates' `.env.example` and `.env.sample`.
pub(crate) fn env_files(write_roots: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
for w in write_roots {
Expand All @@ -460,7 +476,8 @@ pub(crate) fn env_files(write_roots: &[PathBuf]) -> Vec<PathBuf> {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_ascii_lowercase();
let is_file = e.file_type().map(|t| t.is_file()).unwrap_or(false);
if is_file && (name == ".env" || name.starts_with(".env.")) {
let template = name == ".env.example" || name == ".env.sample";
if is_file && !template && (name == ".env" || name.starts_with(".env.")) {
out.push(e.path());
}
}
Expand Down Expand Up @@ -557,6 +574,9 @@ mod tests {
std::fs::write(root.join(".env"), "x").expect("w");
std::fs::write(root.join(".env.local"), "x").expect("w");
std::fs::write(root.join("env.txt"), "x").expect("w");
// HGW-W2 (#75): templates' example files are not secrets (agent-guard exempts them too).
std::fs::write(root.join(".env.example"), "x").expect("w");
std::fs::write(root.join(".env.sample"), "x").expect("w");
std::fs::create_dir(root.join(".env.d")).expect("d");
let got = env_files(std::slice::from_ref(&root));
assert_eq!(got, vec![root.join(".env"), root.join(".env.local")]);
Expand Down
Loading
Loading