pub(super) mod publication;
pub(super) mod push_command;
mod push_failure;
use push_failure::classify_failure;
use super::{DenialKind, PermissionDecision, PermissionGate, PermissionRequest};
use crate::caveats::{Caveats, CaveatsExt};
use crate::git_caveats::GitCaveats;
use agent_bridle::{inspect_shell, ShellInspection};
use std::path::{Path, PathBuf};
pub(super) fn preflight(
source: &str,
cwd: &Path,
caveats: &Caveats,
gate: &mut Option<&mut dyn PermissionGate>,
native_commit_broker: bool,
) -> Result<(), String> {
let inspection = match inspect_shell(source) {
Ok(inspection) => inspection,
Err(_) => return Ok(()),
};
let caps = GitCaveats::from_session(caveats);
inspect_commands(
&inspection,
true,
cwd,
caveats,
&caps,
gate,
native_commit_broker,
)
}
pub(super) fn needs_commit_broker(source: &str) -> bool {
fn contains(inspection: &ShellInspection) -> bool {
inspection.commands.iter().any(|command| {
command.program.as_deref().is_some_and(is_git)
&& invocation(&command.argv)
.ok()
.and_then(|(verb, _, _)| literal(verb))
.as_deref()
== Some("commit")
}) || inspection
.constructs
.iter()
.any(|construct| construct.inspection.as_deref().is_some_and(contains))
}
inspect_shell(source).is_ok_and(|inspection| contains(&inspection))
}
fn unresolved(detail: &str) -> String {
format!("refused: {detail}; native Git ref mutation is not yet supported by the repository authority adapter")
}
fn is_gh(program: &str) -> bool {
let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
name.eq_ignore_ascii_case("gh") || name.eq_ignore_ascii_case("gh.exe")
}
pub(super) fn needs_push_broker(source: &str) -> bool {
fn contains(inspection: &ShellInspection) -> bool {
inspection.commands.iter().any(|command| {
command.program.as_deref().is_some_and(is_git)
&& invocation(&command.argv)
.ok()
.and_then(|(verb, _, _)| literal(verb))
.as_deref()
== Some("push")
}) || inspection
.constructs
.iter()
.any(|construct| construct.inspection.as_deref().is_some_and(contains))
}
inspect_shell(source).is_ok_and(|inspection| contains(&inspection))
}
pub(super) fn needs_pr_create_broker(source: &str) -> bool {
fn contains(inspection: &ShellInspection) -> bool {
inspection.commands.iter().any(|command| {
command.program.as_deref().is_some_and(is_gh)
&& command.argv.get(1).and_then(|a| literal(a)).as_deref() == Some("pr")
&& command.argv.get(2).and_then(|a| literal(a)).as_deref() == Some("create")
}) || inspection
.constructs
.iter()
.any(|construct| construct.inspection.as_deref().is_some_and(contains))
}
inspect_shell(source).is_ok_and(|inspection| contains(&inspection))
}
struct GovernedPush {
dry_run: bool,
remote: String,
requested_branch: Option<String>,
}
fn parse_governed_push(argv: &[String]) -> Result<GovernedPush, String> {
let (verb, args, same_repository) = invocation(argv)?;
let dry_run = args.iter().any(|a| a == "--dry-run");
let args: Vec<_> = args.iter().filter(|a| *a != "--dry-run").cloned().collect();
if !argv.first().is_some_and(|a| is_git(a)) || verb != "push" {
return Err(unresolved("not a `git push` invocation"));
}
if !same_repository {
return Err(unresolved(
"`-C`/`--git-dir`/`--work-tree` on a governed push",
));
}
if args.iter().any(|a| a.starts_with('-')) {
return Err(unresolved(
"a governed push accepts no flags except --dry-run (no --force, --all, --mirror, --tags, -u, …)",
));
}
match args.len() {
0 => Ok(GovernedPush {
dry_run,
remote: "origin".to_string(),
requested_branch: None,
}),
1 => Ok(GovernedPush {
dry_run,
remote: args[0].clone(),
requested_branch: None,
}),
2 => {
let remote = args[0].clone();
let refspec = &args[1];
let (src, dst) = refspec.split_once(':').unwrap_or((refspec, refspec));
if src != dst && src != "HEAD" {
return Err(unresolved(
"a governed push only pushes a branch to its own name",
));
}
Ok(GovernedPush {
dry_run,
remote,
requested_branch: Some(dst.strip_prefix("refs/heads/").unwrap_or(dst).to_string()),
})
}
_ => Err(unresolved("unsupported governed push form")),
}
}
pub(super) fn execute_governed_push(
source: &str,
cwd: &Path,
caveats: &Caveats,
gate: &mut Option<&mut dyn PermissionGate>,
observed: Option<&std::sync::OnceLock<crate::git_staging::Outcome>>,
) -> String {
if let Err(unavailable) = broker_available(caveats) {
return format!("{unavailable}. {}", push_command::RETRY);
}
let command = match push_command::GovernedCommand::parse(source) {
Ok(command) => command,
Err(reason) => return format!("refused: {reason}. {}", push_command::RETRY),
};
if let Err(reason) = parse_governed_push(&command.argv) {
return format!("{reason}. {}", push_command::RETRY);
}
let result = (|| {
let held = crate::git_staging::HeldRoots::bind(&caveats.fs_read)?;
let plan = plan_governed_push(source, cwd, caveats, gate, &held)?;
run_staged_push(&plan, caveats, &held)
})();
if let (Some(slot), Ok(outcome)) = (observed, &result) {
let _ = slot.set(outcome.clone());
}
render_governed_result(&command, result, push_command::RETRY)
}
fn render_governed_result(
command: &push_command::GovernedCommand,
result: Result<crate::git_staging::Outcome, crate::git_staging::Refusal>,
retry: &str,
) -> String {
let success = result
.as_ref()
.is_ok_and(|outcome| !matches!(outcome, crate::git_staging::Outcome::Failed { .. }));
let reason = result.as_ref().err().map(|error| error.safe_reason.unwrap_or(
"trusted staging checks failed; the operator must check tool/config ownership and repository read authority"));
let mut output = fixed_form_with_notice(result, std::io::stdout());
if let Some(reason) = reason {
output.push_str(&format!(": {reason}. {retry}"));
}
command.render(output, success)
}
fn broker_available(caveats: &Caveats) -> Result<(), crate::git_staging::Unavailable> {
crate::git_staging::preflight_availability(caveats)?;
if !crate::confined_exec::kernel_fs_fence_available() {
return Err(crate::git_staging::Unavailable::NoConfinement);
}
Ok(())
}
fn fixed_form(result: Result<crate::git_staging::Outcome, crate::git_staging::Refusal>) -> String {
use crate::git_staging::{FailureCategory, Outcome};
result
.unwrap_or(Outcome::Failed {
category: FailureCategory::RefusedByHarness,
})
.to_string()
}
fn fixed_form_with_notice(
result: Result<crate::git_staging::Outcome, crate::git_staging::Refusal>,
mut notice: impl std::io::Write,
) -> String {
if let Err(refusal) = &result {
for hint in refusal.hints() {
crate::agentic::display::write_harness_notice(&mut notice, &hint.render(), false);
}
if !refusal.hints().is_empty() {
crate::agentic::display::write_harness_notice(
&mut notice,
"Run `newt doctor` to check all push tool paths.",
false,
);
}
}
fixed_form(result)
}
fn plan_governed_push(
source: &str,
cwd: &Path,
caveats: &Caveats,
gate: &mut Option<&mut dyn PermissionGate>,
held: &crate::git_staging::HeldRoots,
) -> Result<GovernedPushPlan, crate::git_staging::Refusal> {
use crate::git_staging as staging;
staging::preflight_availability(caveats).map_err(|e| e.to_string())?;
let command = push_command::GovernedCommand::parse(source).map_err(str::to_owned)?;
let request = parse_governed_push(&command.argv)?;
let cwd_str = cwd.to_string_lossy();
if !crate::caveats::permits_path(&caveats.fs_read, &cwd_str) {
return Err(staging::Refusal::from(format!(
"refused: '{cwd_str}' is outside this session's filesystem read authority"
))
.with_reason("worktree read permission is missing; grant read access before retrying"));
}
if !caveats.permits_exec("git") {
return Err(
staging::Refusal::from("refused: no exec authority for 'git'".to_string()).with_reason(
"git execution permission is missing; grant git execution before retrying",
),
);
}
let tools =
staging::TrustedTools::authenticate(staging::TrustContext::bind(&caveats.fs_write)?)?;
let (common_dir, git_dir) = staging::discover_git_dirs(cwd, held)?;
let branch = staging::read_head_branch(&git_dir, held)?;
if staging::is_default_branch(&common_dir, &branch, held) {
return Err(staging::Refusal::from(format!(
"refused: cannot push the default branch '{branch}' (open a feature branch instead)"
))
.with_reason("pushing the default branch is prohibited; switch to a feature branch"));
}
if let Some(requested) = &request.requested_branch {
if requested != &branch {
return Err(staging::Refusal::from(format!(
"refused: requested branch '{requested}' does not match the checked-out \
branch '{branch}' — a governed push always pushes the workspace's own branch"
))
.with_reason("the destination must match the checked-out branch"));
}
}
let url = staging::literal_remote_url(&tools, &common_dir, &request.remote, held)?;
let host = crate::git_hardening::push_url_host(&url)?;
let Some((owner, name)) = crate::git_hardening::github_owner_repo(&url) else {
return Err(format!(
"refused: a governed push is only supported for a github.com remote (got {url})"
)
.into());
};
let oid = staging::read_branch_oid(&git_dir, &branch, held)
.or_else(|_| staging::read_branch_oid(&common_dir, &branch, held))?;
staging::resolve_alternates_chain(&common_dir.join("objects"), held)?;
ensure_net_granted(
caveats,
gate,
&host,
&format!(
"{}push commit {oid} as branch '{branch}' to {host} ({url})",
if request.dry_run {
"dry-run (no publication): "
} else {
""
}
),
)
.map_err(|detail| {
staging::Refusal::from(detail).with_reason(
"destination network permission was not granted; an operator must approve the push",
)
})?;
Ok(GovernedPushPlan {
dry_run: request.dry_run,
remote: request.remote,
url,
owner,
name,
branch,
oid,
source_repo: common_dir,
tools,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GovernedPushPlan {
dry_run: bool,
remote: String,
url: String,
owner: String,
name: String,
branch: String,
oid: String,
source_repo: PathBuf,
tools: crate::git_staging::TrustedTools,
}
fn run_staged_push(
plan: &GovernedPushPlan,
caveats: &Caveats,
held: &crate::git_staging::HeldRoots,
) -> Result<crate::git_staging::Outcome, crate::git_staging::Refusal> {
use crate::git_staging::{self, Outcome};
let state_dir = crate::config::Config::user_config_dir()
.ok_or_else(|| "refused: no resolvable state directory for a staging repo".to_string())?
.join("staging");
let staging = git_staging::StagingRepo::create(&state_dir, plan.tools.ctx())?;
git_staging::import_credentials(&staging, &plan.tools)?;
git_staging::confined_fetch(
&plan.tools,
&staging,
&plan.source_repo,
&plan.oid,
caveats,
held,
)?;
git_staging::remove_alternates(&staging)?;
git_staging::verify_commit_in_staging(&plan.tools, &staging, &plan.oid)?;
let fsck = plan
.tools
.git(["fsck", "--connectivity-only", plan.oid.as_str()])
.current_dir(staging.path())
.output()
.map_err(|e| e.to_string())?;
if !fsck.status.success() {
return Err(
"refused: staging repo failed connectivity check after the confined fetch"
.to_string()
.into(),
);
}
let refspec = format!("{}:refs/heads/{}", plan.oid, plan.branch);
let mut push_args = vec!["-c", "http.followRedirects=false", "push", "--porcelain"];
if plan.dry_run {
push_args.push("--dry-run");
}
push_args.extend([plan.url.as_str(), refspec.as_str()]);
let output = plan
.tools
.git(push_args)
.env("LC_ALL", "C")
.current_dir(staging.path())
.output()
.map_err(|e| e.to_string())?;
Ok(if output.status.success() {
if plan.dry_run {
Outcome::DryRunChecked
} else {
Outcome::Pushed {
oid: plan.oid.clone(),
owner: plan.owner.clone(),
name: plan.name.clone(),
branch: plan.branch.clone(),
}
}
} else {
Outcome::Failed {
category: push_failure::classify_push(
&output.stdout,
&output.stderr,
&refspec,
&plan.branch,
&plan.remote,
),
}
})
}
struct GovernedPrCreate {
title: String,
body: String,
requested_base: Option<String>,
requested_head: Option<String>,
}
const PR_CREATE_RETRY: &str = "Retry with run_command using the worktree as cwd and command `gh pr create --base <default-branch> --head <current-branch> --title '<title>' --body '<body>'`.";
fn parse_governed_pr_create(argv: &[String]) -> Result<GovernedPrCreate, &'static str> {
if !argv.first().is_some_and(|arg| is_gh(arg))
|| argv.get(1).map(String::as_str) != Some("pr")
|| argv.get(2).map(String::as_str) != Some("create")
{
return Err("expected a literal gh pr create command");
}
let (mut title, mut body, mut requested_base, mut requested_head) = (None, None, None, None);
let mut i = 3;
while i < argv.len() {
let word = argv[i].as_str();
let (flag, inline_value) = match word.split_once('=') {
Some((f, v)) if f.starts_with("--") => (f, Some(v)),
_ => (word, None),
};
let (slot, requirement) = match flag {
"--title" | "-t" => (&mut title, "--title is required with a value"),
"--body" | "-b" => (&mut body, "--body is required with a value"),
"--base" => (&mut requested_base, "--base requires a branch value"),
"--head" => (&mut requested_head, "--head requires a branch value"),
_ => return Err("unsupported gh pr create flag; only --title, --body, --base and --head are supported"),
};
if slot.is_some() {
return Err("duplicate gh pr create flags are unsupported");
}
let value = match inline_value {
Some(value) => value,
None => {
i += 1;
argv.get(i)
.map(String::as_str)
.filter(|value| {
!matches!(
*value,
"--title" | "-t" | "--body" | "-b" | "--base" | "--head"
)
})
.ok_or(requirement)?
}
};
*slot = Some(value.to_string());
i += 1;
}
let title = title
.filter(|value| !value.trim().is_empty())
.ok_or("--title is required with a nonempty value")?;
let body = body.ok_or("--body is required with a value")?;
Ok(GovernedPrCreate {
title,
body,
requested_base,
requested_head,
})
}
pub(super) fn execute_governed_pr_create(
source: &str,
cwd: &Path,
caveats: &Caveats,
gate: &mut Option<&mut dyn PermissionGate>,
observed: Option<&std::sync::OnceLock<crate::git_staging::Outcome>>,
) -> String {
if let Err(unavailable) = broker_available(caveats) {
return format!("{unavailable}. {PR_CREATE_RETRY}");
}
let command = match push_command::GovernedCommand::parse(source) {
Ok(command) => command,
Err(reason) => return format!("refused: {reason}. {PR_CREATE_RETRY}"),
};
if let Err(reason) = parse_governed_pr_create(&command.argv) {
return format!("refused: {reason}. {PR_CREATE_RETRY}");
}
let result = (|| {
let held = crate::git_staging::HeldRoots::bind(&caveats.fs_read)?;
let plan = plan_governed_pr_create(source, cwd, caveats, gate, &held)?;
run_staged_pr_create(&plan)
})();
if let (Some(slot), Ok(outcome)) = (observed, &result) {
let _ = slot.set(outcome.clone());
}
render_governed_result(&command, result, PR_CREATE_RETRY)
}
fn run_staged_pr_create(
plan: &GovernedPrCreatePlan,
) -> Result<crate::git_staging::Outcome, crate::git_staging::Refusal> {
use crate::git_staging::{self, FailureCategory, Outcome};
let state_dir = crate::config::Config::user_config_dir()
.ok_or_else(|| "refused: no resolvable state directory for a staging repo".to_string())?
.join("staging");
let staging = git_staging::StagingRepo::create(&state_dir, plan.tools.ctx())?;
git_staging::import_credentials(&staging, &plan.tools)?;
let repo = format!("github.com/{}", plan.repo);
let output = plan
.tools
.gh([
"pr",
"create",
"--repo",
repo.as_str(),
"--base",
plan.base.as_str(),
"--head",
plan.head.as_str(),
"--title",
plan.title.as_str(),
"--body",
plan.body.as_str(),
])?
.current_dir(staging.path())
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Ok(Outcome::Failed {
category: classify_failure(&output.stderr),
});
}
Ok(
match git_staging::validate_pr_url(&String::from_utf8_lossy(&output.stdout)) {
Some(url) => Outcome::PrCreated { url },
None => Outcome::Failed {
category: FailureCategory::Parse,
},
},
)
}
#[derive(Debug)]
struct GovernedPrCreatePlan {
repo: String,
base: String,
head: String,
title: String,
body: String,
tools: crate::git_staging::TrustedTools,
}
fn plan_governed_pr_create(
source: &str,
cwd: &Path,
caveats: &Caveats,
gate: &mut Option<&mut dyn PermissionGate>,
held: &crate::git_staging::HeldRoots,
) -> Result<GovernedPrCreatePlan, crate::git_staging::Refusal> {
use crate::git_staging as staging;
staging::preflight_availability(caveats).map_err(|e| e.to_string())?;
let syntax_refusal =
|reason: &'static str| staging::Refusal::from(reason.to_string()).with_reason(reason);
let command = push_command::GovernedCommand::parse(source).map_err(syntax_refusal)?;
let request = parse_governed_pr_create(&command.argv).map_err(syntax_refusal)?;
let cwd_str = cwd.to_string_lossy();
if !crate::caveats::permits_path(&caveats.fs_read, &cwd_str) {
return Err(syntax_refusal(
"worktree read authority is missing; grant read access before retrying",
));
}
if !caveats.permits_exec("gh") {
return Err(syntax_refusal(
"no exec authority for gh; grant gh execution before retrying",
));
}
let tools =
staging::TrustedTools::authenticate(staging::TrustContext::bind(&caveats.fs_write)?)?;
if tools.gh_path().is_none() {
return Err(syntax_refusal("no trusted gh executable is installed"));
}
let (common_dir, git_dir) = staging::discover_git_dirs(cwd, held)?;
let url = staging::literal_remote_url(&tools, &common_dir, "origin", held)?;
let Some((owner, name)) = crate::git_hardening::github_owner_repo(&url) else {
return Err(syntax_refusal(
"gh pr create requires a github.com origin remote",
));
};
let host = "github.com".to_string();
let head = staging::read_head_branch(&git_dir, held).map_err(|error| {
error.with_reason(
"a verified checked-out branch is required; detached HEAD cannot open a PR",
)
})?;
let base = staging::origin_default_branch(&common_dir, held)
.ok_or_else(|| syntax_refusal("repository default branch is unknown; the operator must establish a valid origin/HEAD before retrying"))?;
if request
.requested_base
.as_ref()
.is_some_and(|requested| requested != &base)
{
return Err(syntax_refusal(
"--base must match the repository default branch",
));
}
if request
.requested_head
.as_ref()
.is_some_and(|requested| requested != &head)
{
return Err(syntax_refusal("--head must match the checked-out branch"));
}
if head == base {
return Err(syntax_refusal(
"cannot open a PR from the default branch to itself; switch to a feature branch",
));
}
ensure_net_granted(
caveats,
gate,
&host,
&format!("open a PR on {owner}/{name}"),
)
.map_err(|detail| {
staging::Refusal::from(detail).with_reason(
"destination network permission was not granted; an operator must approve PR creation",
)
})?;
Ok(GovernedPrCreatePlan {
repo: format!("{owner}/{name}"),
base,
head,
title: request.title,
body: request.body,
tools,
})
}
fn ensure_net_granted(
caveats: &Caveats,
gate: &mut Option<&mut dyn PermissionGate>,
host: &str,
action: &str,
) -> Result<(), String> {
if caveats.permits_net(host) {
return Ok(());
}
let Some(gate) = gate.as_deref_mut() else {
return Err(format!(
"refused: no network authority for '{host}' and no operator to ask"
));
};
let request = PermissionRequest {
tool: "run_command".to_string(),
kind: DenialKind::Net,
target: host.to_string(),
reason: format!("{action} — net does not permit '{host}'"),
harness_bound: false,
};
match gate.ask(std::slice::from_ref(&request)) {
PermissionDecision::Allow(granted) if granted.permits_net(host) => Ok(()),
PermissionDecision::Allow(_) => Err(format!(
"refused: granted authority does not cover network access to '{host}'"
)),
PermissionDecision::Deny => Err(format!(
"refused: operator did not grant network access to '{host}'"
)),
}
}
pub(super) fn is_git(program: &str) -> bool {
let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
name.eq_ignore_ascii_case("git") || name.eq_ignore_ascii_case("git.exe")
}
#[cfg(any(target_os = "windows", test))]
pub(super) const WINDOWS_APPCONTAINER_GIT_UNAVAILABLE: &str =
"native Git is unavailable under Windows AppContainer confinement: Git for Windows requires current-directory ancestry traversal outside the granted roots; no command ran";
#[cfg(any(target_os = "windows", test))]
fn literal_native_git_program(source: &str) -> Option<String> {
let inspection = inspect_shell(source).ok()?;
let command = inspection.commands.first()?;
if inspection.commands.len() == 1
&& inspection.constructs.is_empty()
&& command.redirects.is_empty()
&& command.descendant_execs.is_empty()
{
command
.program
.as_deref()
.filter(|program| is_git(program))
.map(str::to_owned)
} else {
None
}
}
#[cfg(any(target_os = "windows", test))]
fn appcontainer_native_git_refusal_for(
program: Option<&str>,
effective_sandbox: agent_bridle::SandboxKind,
exec_allowed: bool,
) -> Option<&'static str> {
(program.is_some_and(is_git)
&& exec_allowed
&& effective_sandbox == agent_bridle::SandboxKind::AppContainer)
.then_some(WINDOWS_APPCONTAINER_GIT_UNAVAILABLE)
}
#[cfg(target_os = "windows")]
fn exec_scope_allows_program(caveats: &Caveats, program: &str) -> bool {
caveats.permits_exec(program)
|| Path::new(program)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| caveats.permits_exec(name))
}
pub(super) fn windows_appcontainer_native_git_refusal(
source: &str,
caveats: &Caveats,
) -> Option<&'static str> {
#[cfg(target_os = "windows")]
{
let program = literal_native_git_program(source);
let exec_allowed = program
.as_deref()
.is_some_and(|program| exec_scope_allows_program(caveats, program));
let policy = std::sync::Arc::new(crate::confined_exec::runtime_sandbox_policy());
let effective_sandbox = agent_bridle::effective_sandbox_kind(
agent_bridle::best_available_sandbox(&policy).kind(),
caveats,
);
appcontainer_native_git_refusal_for(program.as_deref(), effective_sandbox, exec_allowed)
}
#[cfg(not(target_os = "windows"))]
{
let _ = (source, caveats);
None
}
}
pub(super) fn literal(word: &str) -> Option<String> {
let parsed = inspect_shell(word).ok()?;
let command = parsed.commands.first()?;
(parsed.commands.len() == 1
&& parsed.constructs.is_empty()
&& command.argv.len() == 1
&& command.redirects.is_empty()
&& command.descendant_execs.is_empty())
.then(|| command.program.clone())
.flatten()
}
pub(super) fn invocation(argv: &[String]) -> Result<(&str, &[String], bool), String> {
let mut index = 1;
let mut same_repository = true;
while let Some(word) = argv.get(index) {
match word.as_str() {
"--no-pager" | "--paginate" | "--no-optional-locks" => index += 1,
"-C" | "-c" | "--git-dir" | "--work-tree" | "--namespace" | "--config-env" => {
same_repository = false;
index += 2;
}
option
if option.starts_with("--git-dir=")
|| option.starts_with("--work-tree=")
|| option.starts_with("--namespace=")
|| option.starts_with("--config-env=")
|| option.starts_with("--exec-path=")
|| option == "--bare" =>
{
same_repository = false;
index += 1;
}
"--literal-pathspecs"
| "--glob-pathspecs"
| "--noglob-pathspecs"
| "--icase-pathspecs"
| "--no-replace-objects" => index += 1,
"--version" | "--help" | "--html-path" | "--man-path" | "--info-path" => {
return Ok(("help", &[], true));
}
verb if !verb.starts_with('-') => {
return Ok((verb, &argv[index + 1..], same_repository));
}
_ => return Err(unresolved("unresolved Git global options")),
}
}
Ok(("help", &[], true))
}
fn inspect_commands(
inspection: &ShellInspection,
top_level: bool,
cwd: &Path,
caveats: &Caveats,
caps: &GitCaveats,
gate: &mut Option<&mut dyn PermissionGate>,
native_commit_broker: bool,
) -> Result<(), String> {
for command in &inspection.commands {
for descendant in &command.descendant_execs {
if is_git(&descendant.program) {
let mut delegated = command.clone();
delegated.source = descendant.source.clone();
delegated.program = Some(descendant.program.clone());
delegated.argv = descendant.argv.clone();
delegated.redirects.clear();
delegated.descendant_execs.clear();
let nested = ShellInspection {
schema_version: inspection.schema_version,
source: descendant.source.clone(),
commands: vec![delegated],
constructs: vec![],
warnings: vec![],
};
inspect_commands(
&nested, false, cwd, caveats, caps, gate,
false,
)?;
}
}
if !command.program.as_deref().is_some_and(is_git) {
continue;
}
let Ok((raw_verb, args, same_repository)) = invocation(&command.argv) else {
continue;
};
let Some(verb) = literal(raw_verb) else {
continue;
};
match verb.as_str() {
"commit" if native_commit_broker => continue,
"commit" | "merge" | "rebase" | "cherry-pick" | "revert" => {
let aborting = verb != "commit"
&& args
.iter()
.filter_map(|arg| literal(arg))
.any(|arg| matches!(arg.as_str(), "--abort" | "--quit"));
if !aborting {
return Err("refused: native commit creation requires harness-managed attribution and signing integration".into());
}
continue;
}
"checkout" | "switch" => {
let create_option = if verb == "checkout" { "-b" } else { "-c" };
let words: Vec<String> = args
.iter()
.filter_map(|arg| literal(arg))
.filter(|word| !word.starts_with(create_option))
.collect();
if has_option(
&words,
"BCf",
&["--force", "--force-create", "--discard-changes"],
) {
return Err(unresolved("forced branch replacement or checkout"));
}
continue;
}
"update-ref" => return Err(unresolved("direct ref update")),
"push" => return Err(unresolved(
"`git push` must be a single, standalone command handled by the governed push broker",
)),
"symbolic-ref" => {
let words = literal_arguments(args)?;
let query_flags = ["-q", "--quiet", "--short", "--recurse", "--no-recurse"];
let operands: Vec<_> = words
.iter()
.filter(|word| !query_flags.contains(&word.as_str()))
.collect();
if operands.len() != 1 || operands[0].starts_with('-') {
return Err(unresolved("symbolic ref mutation"));
}
continue;
}
"reflog" => {
if let Some(first) = args.first() {
let action =
literal(first).ok_or_else(|| unresolved("dynamic reflog operation"))?;
if matches!(action.as_str(), "expire" | "delete" | "drop" | "write") {
return Err(unresolved("reflog mutation"));
}
}
continue;
}
"stash" => {
let action = args
.first()
.map(|word| literal(word).ok_or_else(|| unresolved("dynamic stash operation")))
.transpose()?;
if !matches!(action.as_deref(), Some("drop" | "clear")) {
continue;
}
}
"branch" => {}
_ => continue,
}
let words = literal_arguments(args)?;
if verb == "branch" {
if has_option(&words, "fmMcC", &["--force", "--move", "--copy"]) {
return Err(unresolved("branch overwrite, rename, or copy"));
}
if !has_option(&words, "dD", &["--delete"]) {
continue;
}
}
let standalone = top_level
&& inspection.commands.len() == 1
&& inspection.constructs.is_empty()
&& command.redirects.is_empty()
&& command.descendant_execs.is_empty()
&& command.source.trim_start().starts_with(&command.argv[0]);
if !standalone || !same_repository {
return Err(unresolved("composed or redirected Git mutation"));
}
let op = if verb == "branch" {
let branches = deleted_branches(&words)?;
for branch in branches {
if !caps.permits_ref(&format!("refs/heads/{branch}")) {
return Err("refused: native branch deletion requires git-ref authority".into());
}
refuse_protected_branch(cwd, branch, caveats)?;
}
"branch-delete"
} else {
if !caps.permits_stage() {
return Err("refused: native stash deletion requires git-write authority".into());
}
"stash-drop"
};
if !gate
.as_deref_mut()
.is_some_and(|gate| super::git_data_loss_confirmed(gate, op))
{
return Err(format!(
"refused: git {op} requires explicit destructive-operation confirmation"
));
}
}
for construct in &inspection.constructs {
if let Some(nested) = &construct.inspection {
inspect_commands(
nested,
false,
cwd,
caveats,
caps,
gate,
native_commit_broker,
)?;
}
}
Ok(())
}
fn literal_arguments(args: &[String]) -> Result<Vec<String>, String> {
args.iter()
.map(|arg| literal(arg).ok_or_else(|| unresolved("dynamic Git ref or stash arguments")))
.collect()
}
fn has_option(words: &[String], short: &str, long: &[&str]) -> bool {
words
.iter()
.take_while(|word| word.as_str() != "--")
.any(|word| {
long.contains(&word.as_str())
|| (word.starts_with('-')
&& !word.starts_with("--")
&& word[1..].chars().any(|flag| short.contains(flag)))
})
}
fn deleted_branches(words: &[String]) -> Result<Vec<&str>, String> {
let mut deleting = false;
let mut positional = false;
let mut branches = vec![];
for word in words {
match word.as_str() {
"--" if !positional => positional = true,
"-d" | "-D" | "--delete" if !positional => deleting = true,
"-q" | "--quiet" if !positional => {}
option if !positional && option.starts_with('-') => {
return Err(unresolved("unsupported branch mutation options"))
}
branch => branches.push(branch),
}
}
if !deleting || branches.is_empty() {
return Err(unresolved("unclassified branch mutation"));
}
Ok(branches)
}
fn refuse_protected_branch(cwd: &Path, branch: &str, caveats: &Caveats) -> Result<(), String> {
if matches!(branch, "main" | "master") {
return Err(format!(
"refused: cannot delete protected default branch '{branch}'"
));
}
let output = crate::git_hardening::metadata_git(
cwd,
&["symbolic-ref", "-q", "refs/remotes/origin/HEAD"],
&caveats.fs_read,
)
.and_then(|mut command| command.output())
.map_err(|_| {
unresolved("cannot establish the protected default branch under current read authority")
})?;
if output.status.success() {
if String::from_utf8_lossy(&output.stdout)
.trim()
.strip_prefix("refs/remotes/origin/")
== Some(branch)
{
return Err(format!(
"refused: cannot delete protected default branch '{branch}'"
));
}
} else if output.status.code() != Some(1) {
return Err(unresolved("cannot establish the repository default branch"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::super::{
disable_ocap_tests::{env_lock, EnvVar},
execute_tool_with_collaborators, PermissionDecision, PermissionRequest, ToolCollaborators,
};
use super::*;
use crate::agentic::{NoMcp, PromptDisposition};
#[test]
fn python_text_mentioning_git_keeps_the_existing_execution_path() {
for source in [
"python -c 'print(\"git branch -D example\")'",
"git status && python -m pytest",
] {
assert!(
preflight(source, Path::new("."), &Caveats::top(), &mut None, false).is_ok(),
"{source}"
);
}
}
#[test]
fn native_broker_selection_preserves_compound_and_quoted_commit_forms() {
for source in [
"git add .gitignore && git commit -m 'Ignore bytecode'",
"'git' 'commit' -m message",
"git -C directory -c user.email=author@example.invalid commit -F message.txt",
"printf 'message' | git commit -F -",
] {
assert!(needs_commit_broker(source), "{source}");
assert!(
preflight(source, Path::new("."), &Caveats::top(), &mut None, true).is_ok(),
"{source}"
);
}
assert!(!needs_commit_broker("python -c 'print(\"git commit\")'"));
assert!(preflight(
"git rebase HEAD~1",
Path::new("."),
&Caveats::top(),
&mut None,
true
)
.is_err());
}
#[test]
fn appcontainer_refuses_recognized_native_git_without_matching_text() {
assert_eq!(
literal_native_git_program("git status").as_deref(),
Some("git")
);
assert_eq!(literal_native_git_program("echo git status"), None);
assert_eq!(literal_native_git_program("git status && echo done"), None);
assert_eq!(
appcontainer_native_git_refusal_for(
Some("git"),
agent_bridle::SandboxKind::AppContainer,
true,
),
Some(WINDOWS_APPCONTAINER_GIT_UNAVAILABLE),
);
assert_eq!(
appcontainer_native_git_refusal_for(
Some("git"),
agent_bridle::SandboxKind::AppContainer,
false,
),
None,
);
assert_eq!(
appcontainer_native_git_refusal_for(
Some("git"),
agent_bridle::SandboxKind::Landlock,
true,
),
None,
);
assert_eq!(
appcontainer_native_git_refusal_for(
Some("git.exe"),
agent_bridle::SandboxKind::None,
true,
),
None,
);
}
#[cfg(target_os = "windows")]
#[test]
fn appcontainer_git_refusal_mirrors_bare_name_exec_grants() {
let caveats = Caveats {
exec: crate::Scope::only(["git.exe".to_owned()]),
..Caveats::top()
};
assert!(exec_scope_allows_program(
&caveats,
r"C:\Program Files\Git\cmd\git.exe"
));
assert!(!exec_scope_allows_program(
&caveats,
r"C:\Program Files\Git\cmd\not-git.exe"
));
}
struct Gate {
allow: bool,
requests: Vec<PermissionRequest>,
}
#[test]
fn native_descendants_cannot_borrow_a_direct_commits_broker() {
for source in [
"git commit -m direct; find . -exec git commit -m delegated \\;",
"git commit -m direct; timeout 30 git commit -m delegated",
] {
assert!(
preflight(source, Path::new("."), &Caveats::top(), &mut None, true).is_err(),
"a native descendant has no Brush spawn registration: {source}"
);
}
assert!(
preflight(
"echo \"$(git commit -m direct)\"",
Path::new("."),
&Caveats::top(),
&mut None,
true,
)
.is_ok(),
"actual nested Brush commands keep broker coverage"
);
}
impl PermissionGate for Gate {
fn ask(&mut self, requests: &[PermissionRequest]) -> PermissionDecision {
self.requests.extend_from_slice(requests);
if self.allow {
PermissionDecision::Allow(Caveats::top())
} else {
PermissionDecision::Deny
}
}
fn ask_question(&mut self, _question: &str) -> crate::agentic::HumanQuestionOutcome {
crate::agentic::HumanQuestionOutcome::Unavailable
}
}
fn git(cwd: &Path, args: &[&str]) -> String {
let mut cmd = crate::git_hardening::hardened_git(cwd, args).unwrap();
#[cfg(windows)]
if let Ok(canonical) = cwd.canonicalize() {
if let Some(dir) = canonical.to_str() {
cmd.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "safe.directory")
.env("GIT_CONFIG_VALUE_0", dir);
}
}
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"{args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
fn repository() -> tempfile::TempDir {
let repo = tempfile::tempdir().unwrap();
git(repo.path(), &["init", "-q", "-b", "main"]);
std::fs::write(repo.path().join("tracked.txt"), "original\n").unwrap();
git(repo.path(), &["add", "tracked.txt"]);
git(
repo.path(),
&[
"-c",
"user.name=Native fixture",
"-c",
"user.email=native@example.invalid",
"commit",
"--no-gpg-sign",
"-qm",
"fixture",
],
);
for branch in ["master", "stable", "victim", "task"] {
git(repo.path(), &["branch", branch]);
}
git(
repo.path(),
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/stable",
],
);
git(repo.path(), &["checkout", "-q", "task"]);
std::fs::write(repo.path().join("tracked.txt"), "saved work\n").unwrap();
git(
repo.path(),
&[
"-c",
"user.name=Native fixture",
"-c",
"user.email=native@example.invalid",
"stash",
"push",
"-qm",
"saved work",
],
);
repo
}
async fn run(source: &str, repo: &Path, gate: Option<&mut dyn PermissionGate>) -> String {
execute_tool_with_collaborators(
"run_command",
&serde_json::json!({"command": source}),
&repo.to_string_lossy(),
false,
40,
&Caveats::top(),
&mut NoMcp,
ToolCollaborators {
permission_gate: gate,
..Default::default()
},
false,
PromptDisposition::Act,
None,
)
.await
.unwrap()
.unwrap()
}
#[tokio::test]
async fn native_staging_uses_current_workspace_grants() {
let _lock = env_lock().await;
let _ocap = EnvVar::unset("NEWT_DISABLE_OCAP");
let _full = EnvVar::unset("NEWT_FULL_ACCESS");
let _engine = EnvVar::set("NEWT_SHELL_ENGINE", "safe-subset");
let repo = repository();
let workspace = repo.path().canonicalize().unwrap();
let workspace = workspace.to_string_lossy().into_owned();
std::fs::write(repo.path().join("pending.txt"), "pending work\n").unwrap();
let baseline = Caveats {
fs_write: crate::Scope::none(),
..Caveats::top()
};
struct RecalledWorkspaceGrant {
workspace: Option<String>,
refreshes: usize,
requests: Vec<PermissionRequest>,
}
impl PermissionGate for RecalledWorkspaceGrant {
fn refresh_caveats(&mut self, baseline: &Caveats) -> PermissionDecision {
self.refreshes += 1;
let mut current = baseline.clone();
if let Some(workspace) = &self.workspace {
current.fs_write = crate::Scope::only([workspace.clone()]);
}
PermissionDecision::Allow(current)
}
fn ask(&mut self, requests: &[PermissionRequest]) -> PermissionDecision {
self.requests.extend_from_slice(requests);
PermissionDecision::Deny
}
fn ask_question(&mut self, _: &str) -> crate::agentic::HumanQuestionOutcome {
crate::agentic::HumanQuestionOutcome::Unavailable
}
}
for allowed in [true, false] {
let mut gate = RecalledWorkspaceGrant {
workspace: allowed.then(|| workspace.clone()),
refreshes: 0,
requests: vec![],
};
let command = if allowed {
"git add -- pending.txt"
} else {
"git rm --cached -- pending.txt"
};
let out = execute_tool_with_collaborators(
"run_command",
&serde_json::json!({
"command": command,
"fs_write": [workspace],
}),
&workspace,
false,
40,
&baseline,
&mut NoMcp,
ToolCollaborators {
permission_gate: Some(&mut gate),
..Default::default()
},
false,
PromptDisposition::Act,
None,
)
.await
.unwrap()
.unwrap();
let staged = git(repo.path(), &["diff", "--cached", "--name-only"]);
#[cfg(target_os = "windows")]
{
if allowed {
assert!(
out.contains(WINDOWS_APPCONTAINER_GIT_UNAVAILABLE),
"the recalled filesystem grant must reach the pre-spawn Windows guard: {out}"
);
} else {
assert!(out.contains("capability denied"), "{out}");
assert!(
!out.contains(WINDOWS_APPCONTAINER_GIT_UNAVAILABLE),
"a denied filesystem declaration must win before the Windows guard: {out}"
);
}
assert_eq!(staged.trim(), "", "{out}");
}
#[cfg(not(target_os = "windows"))]
assert_eq!(staged.trim(), "pending.txt", "{out}");
assert_eq!(gate.refreshes, 1, "allowed={allowed}: {out}");
if allowed {
assert!(gate.requests.is_empty(), "recalled grant must be reused");
} else {
assert!(out.contains("capability denied"), "{out}");
assert_eq!(gate.requests.len(), 1);
assert_eq!(gate.requests[0].kind, super::super::DenialKind::FsWrite);
assert_eq!(gate.requests[0].target, workspace);
}
}
}
#[tokio::test]
async fn denied_native_deletion_preserves_real_branches_and_stash() {
let _lock = env_lock().await;
let _ocap = EnvVar::unset("NEWT_DISABLE_OCAP");
let _engine = EnvVar::set("NEWT_SHELL_ENGINE", "safe-subset");
let repo = repository();
let before = git(repo.path(), &["show-ref"]);
let stash = git(repo.path(), &["stash", "list"]);
for source in [
"git branch -D victim",
"git branch -d victim",
"git stash drop 'stash@{0}'",
"git stash clear",
] {
let mut gate = Gate {
allow: false,
requests: vec![],
};
let out = run(source, repo.path(), Some(&mut gate)).await;
assert!(out.starts_with("refused:"), "{source}: {out}");
assert_eq!(gate.requests.len(), 1, "{source}: {out}");
assert!(gate.requests[0].reason.contains("DESTROYS"));
assert_eq!(git(repo.path(), &["show-ref"]), before, "{source}");
assert_eq!(git(repo.path(), &["stash", "list"]), stash, "{source}");
}
let out = run("git branch -D victim", repo.path(), None).await;
assert!(out.starts_with("refused:"), "{out}");
assert_eq!(git(repo.path(), &["show-ref"]), before);
}
#[tokio::test]
async fn protected_or_unresolved_native_ref_mutations_preserve_real_refs() {
let _lock = env_lock().await;
let _ocap = EnvVar::unset("NEWT_DISABLE_OCAP");
let repo = repository();
let before = git(repo.path(), &["show-ref"]);
for source in [
"git branch -D main",
"git branch -D master",
"git branch -D stable",
"git update-ref -d refs/heads/main",
"git symbolic-ref HEAD refs/heads/main",
"git branch -f main HEAD",
"git branch -M main",
"git switch -C main HEAD",
"git checkout -B main HEAD",
"git checkout --force main",
"git reflog delete 'HEAD@{0}'",
"git -C . branch -D victim",
"git branch -D \"$TARGET\"",
"git stash \"$ACTION\"",
"echo before && git branch -D victim",
"git branch --list -D victim",
] {
let mut gate = Gate {
allow: true,
requests: vec![],
};
let out = run(source, repo.path(), Some(&mut gate)).await;
assert!(out.starts_with("refused:"), "{source}: {out}");
assert!(
gate.requests.is_empty(),
"unresolved policy must not be approvable: {source}"
);
assert_eq!(git(repo.path(), &["show-ref"]), before, "{source}");
assert_eq!(
git(repo.path(), &["symbolic-ref", "--short", "HEAD"]).trim(),
"task"
);
}
}
#[tokio::test]
async fn ordinary_native_branch_creation_switching_and_queries_keep_git_behavior() {
let _lock = env_lock().await;
let _ocap = EnvVar::unset("NEWT_DISABLE_OCAP");
let _engine = EnvVar::set("NEWT_SHELL_ENGINE", "safe-subset");
let repo = repository();
git(
repo.path(),
&[
"remote",
"add",
"origin",
"https://example.invalid/native-fixture.git",
],
);
git(repo.path(), &["config", "native.fixture", "configured"]);
let main_before = git(repo.path(), &["rev-parse", "refs/heads/main"]);
let mut gate = Gate {
allow: false,
requests: vec![],
};
for source in [
"git branch 'native-created' HEAD",
"git switch native-created",
"git switch -c native-switched",
"git checkout -b native-checkout",
"git checkout -bfeature-attached-checkout",
"git switch -cfeature-attached-switch",
] {
let out = run(source, repo.path(), Some(&mut gate)).await;
assert!(!out.contains("refused:"), "{source}: {out}");
}
for branch in [
"native-created",
"native-switched",
"native-checkout",
"feature-attached-checkout",
"feature-attached-switch",
] {
assert_eq!(
git(repo.path(), &["rev-parse", &format!("refs/heads/{branch}")]),
main_before
);
}
assert_eq!(
git(repo.path(), &["symbolic-ref", "--short", "HEAD"]).trim(),
"feature-attached-switch"
);
for (source, expected) in [
(
"git remote -v",
"https://example.invalid/native-fixture.git",
),
("git config --get native.fixture", "configured"),
("git status --short --branch", "feature-attached-switch"),
("git reflog show -1 --format=%gs", "moving from"),
("git symbolic-ref --short HEAD", "feature-attached-switch"),
] {
let out = run(source, repo.path(), Some(&mut gate)).await;
assert!(out.contains(expected), "{source}: {out}");
assert!(!out.contains("refused:"), "{source}: {out}");
}
assert!(
gate.requests.is_empty(),
"ordinary commands must not trigger destructive confirmation"
);
assert_eq!(
git(repo.path(), &["rev-parse", "refs/heads/main"]),
main_before
);
}
#[tokio::test]
async fn quoted_native_commit_preserves_head_and_staged_work() {
let _lock = env_lock().await;
let _ocap = EnvVar::unset("NEWT_DISABLE_OCAP");
let repo = repository();
std::fs::write(repo.path().join("pending.txt"), "pending work\n").unwrap();
git(repo.path(), &["add", "pending.txt"]);
let before = git(repo.path(), &["rev-parse", "HEAD"]);
for source in [
"git 'commit' --no-gpg-sign -m 'must not publish'",
"'git' commit --no-gpg-sign -m 'must not publish'",
] {
let out = run(source, repo.path(), None).await;
assert!(out.contains("attribution"), "{source}: {out}");
assert_eq!(git(repo.path(), &["rev-parse", "HEAD"]), before);
assert_eq!(
git(repo.path(), &["diff", "--cached", "--name-only"]).trim(),
"pending.txt"
);
}
for verb in ["merge", "rebase", "cherry-pick", "revert"] {
assert!(preflight(
&format!("git '{verb}' HEAD"),
repo.path(),
&Caveats::top(),
&mut None,
false
)
.unwrap_err()
.contains("attribution"));
for flag in ["--abort", "--quit"] {
assert!(preflight(
&format!("git '{verb}' '{flag}'"),
repo.path(),
&Caveats::top(),
&mut None,
false
)
.is_ok());
}
}
}
#[tokio::test]
async fn approved_native_deletion_changes_only_requested_branch_or_stash() {
let _lock = env_lock().await;
let _ocap = EnvVar::unset("NEWT_DISABLE_OCAP");
let _engine = EnvVar::set("NEWT_SHELL_ENGINE", "safe-subset");
let repo = repository();
let mut gate = Gate {
allow: true,
requests: vec![],
};
let out = run("git branch -D 'victim'", repo.path(), Some(&mut gate)).await;
assert!(out.contains("Deleted branch victim"), "{out}");
assert!(!git(repo.path(), &["branch", "--list", "victim"]).contains("victim"));
assert_eq!(gate.requests.len(), 1);
assert_eq!(gate.requests[0].target, "branch-delete");
assert!(
git(repo.path(), &["show-ref", "--verify", "refs/heads/main"])
.contains("refs/heads/main")
);
let out = run("git stash drop 'stash@{0}'", repo.path(), Some(&mut gate)).await;
assert!(out.contains("Dropped stash@{0}"), "{out}");
assert!(git(repo.path(), &["stash", "list"]).is_empty());
assert_eq!(gate.requests.len(), 2);
assert_eq!(gate.requests[1].target, "stash-drop");
}
}
#[cfg(all(test, unix))]
mod governed_push_tests {
use super::*;
use crate::caveats::Scope;
use std::io::{Read as _, Write as _};
use std::net::{TcpListener, TcpStream};
use std::os::unix::fs::PermissionsExt;
use std::sync::{Arc, Mutex};
const NO_WRITE_ROOT: &str = "/nonexistent/newt-2641-write-root";
const CANARY: &str = "NEWT-2641-CANARY-4f9e1c";
fn scoped_caveats() -> Caveats {
Caveats {
fs_write: Scope::only([NO_WRITE_ROOT.to_string()]),
net: Scope::only(["github.com".to_string()]),
..Caveats::top()
}
}
fn with_net(net: Scope<String>) -> Caveats {
Caveats {
net,
..scoped_caveats()
}
}
fn read_only(roots: &[&Path]) -> Caveats {
Caveats {
fs_read: Scope::only(roots.iter().map(|p| p.to_string_lossy().into_owned())),
..scoped_caveats()
}
}
fn fence() -> bool {
let ok = crate::confined_exec::kernel_fs_fence_available();
if !ok {
eprintln!("skip: no kernel fs fence on this host");
}
ok
}
fn tempdir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
dir
}
fn git(cwd: &Path, args: &[&str]) -> String {
let output = crate::git_hardening::hardened_git(cwd, args)
.unwrap()
.output()
.unwrap();
assert!(
output.status.success(),
"{args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn commit(cwd: &Path, file: &str, text: &str) -> String {
std::fs::write(cwd.join(file), text).unwrap();
git(cwd, &["add", file]);
git(
cwd,
&[
"-c",
"user.name=t",
"-c",
"user.email=t@example.invalid",
"commit",
"-qm",
text,
],
);
git(cwd, &["rev-parse", "HEAD"])
}
struct BrokerEnv {
saved: Vec<(&'static str, Option<String>)>,
home: tempfile::TempDir,
bin: tempfile::TempDir,
_state: tempfile::TempDir,
_lock: crate::process_env::EnvGuard,
}
impl BrokerEnv {
fn new() -> Self {
let lock = crate::process_env::lock();
let home = tempdir();
let bin = tempdir();
let state = tempdir();
let newt_dir = state.path().join("newt");
std::fs::create_dir(&newt_dir).unwrap();
std::fs::set_permissions(&newt_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let gh = bin.path().join("gh");
std::fs::write(
&gh,
format!(
"#!/bin/sh\necho '{CANARY}' >&2\n\
case \"$1 $2\" in\n\
'auth git-credential') [ \"$3\" = get ] && printf 'username=u\\npassword=p\\n'; exit 0;;\n\
'pr create') echo 'https://github.com/o/r/pull/7'; exit 0;;\n\
esac\nexit 1\n"
),
)
.unwrap();
std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o700)).unwrap();
let keys = [
"HOME",
"PATH",
"NEWT_CONFIG_DIR",
"GH_CONFIG_DIR",
"GIT_DIR",
"GIT_CONFIG_PARAMETERS",
];
let saved = keys.map(|k| (k, std::env::var(k).ok())).to_vec();
crate::process_env::set_var("HOME", &home.path().to_string_lossy());
crate::process_env::set_var("PATH", &format!("{}:/usr/bin:/bin", bin.path().display()));
crate::process_env::set_var("NEWT_CONFIG_DIR", &newt_dir.to_string_lossy());
for k in ["GH_CONFIG_DIR", "GIT_DIR", "GIT_CONFIG_PARAMETERS"] {
crate::process_env::remove_var(k);
}
Self {
saved,
home,
bin,
_state: state,
_lock: lock,
}
}
fn gh(&self) -> PathBuf {
self.bin.path().join("gh")
}
fn global(&self, args: &[&str]) {
let out = std::process::Command::new("/usr/bin/git")
.args(["config", "--global"])
.args(args)
.env_clear()
.env("HOME", self.home.path())
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
std::fs::set_permissions(
self.home.path().join(".gitconfig"),
std::fs::Permissions::from_mode(0o600),
)
.unwrap();
}
}
impl Drop for BrokerEnv {
fn drop(&mut self) {
for (k, v) in &self.saved {
crate::process_env::set_or_remove(k, v.as_deref());
}
}
}
struct Gate {
allow: bool,
requests: Vec<PermissionRequest>,
}
impl PermissionGate for Gate {
fn ask(&mut self, requests: &[PermissionRequest]) -> PermissionDecision {
self.requests.extend_from_slice(requests);
if self.allow {
PermissionDecision::Allow(Caveats::top())
} else {
PermissionDecision::Deny
}
}
fn ask_question(&mut self, _question: &str) -> crate::agentic::HumanQuestionOutcome {
crate::agentic::HumanQuestionOutcome::Unavailable
}
}
fn repo_on_feature_branch() -> tempfile::TempDir {
let repo = tempdir();
git(repo.path(), &["init", "-q", "-b", "main"]);
commit(repo.path(), "f.txt", "one\n");
git(
repo.path(),
&["remote", "add", "origin", "https://github.com/o/r.git"],
);
git(
repo.path(),
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/main",
],
);
git(repo.path(), &["checkout", "-q", "-b", "task"]);
commit(repo.path(), "f.txt", "two\n");
repo
}
fn pkt(data: &[u8]) -> Vec<u8> {
let mut out = format!("{:04x}", data.len() + 4).into_bytes();
out.extend_from_slice(data);
out
}
#[derive(Debug, Default)]
struct Seen {
requests: Vec<String>,
update: Option<String>,
}
fn read_request(stream: &mut TcpStream) -> (String, Vec<u8>) {
let mut buf = Vec::new();
let mut byte = [0u8; 1];
while !buf.ends_with(b"\r\n\r\n") {
match stream.read(&mut byte) {
Ok(1) => buf.push(byte[0]),
_ => break,
}
}
let head = String::from_utf8_lossy(&buf).into_owned();
let lower = head.to_ascii_lowercase();
if lower.contains("\r\nexpect: 100-continue") {
let _ = stream.write_all(b"HTTP/1.1 100 Continue\r\n\r\n");
}
let len = lower
.lines()
.find_map(|l| l.strip_prefix("content-length:"))
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
let mut body = vec![0u8; len];
let _ = stream.read_exact(&mut body);
(head, body)
}
fn respond(stream: &mut TcpStream, status: &str, extra: &str, kind: &str, body: &[u8]) {
let head = format!(
"HTTP/1.1 {status}\r\n{extra}Content-Type: {kind}\r\nContent-Length: {}\r\n\
Cache-Control: no-cache\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body);
}
fn receive_pack_endpoint(require_auth: bool) -> (u16, std::thread::JoinHandle<Seen>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
let mut seen = Seen::default();
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let (head, body) = read_request(&mut stream);
let line = head.lines().next().unwrap_or("").to_string();
if line.starts_with("QUIT") {
break;
}
let authed = head.to_ascii_lowercase().contains("\r\nauthorization:");
seen.requests.push(format!("{line} auth={authed}"));
let path = line.split(' ').nth(1).unwrap_or("").to_string();
if require_auth && !authed {
respond(
&mut stream,
"401 Unauthorized",
"WWW-Authenticate: Basic realm=\"t\"\r\n",
"text/plain",
b"",
);
} else if path.starts_with("/o/r.git/info/refs?service=git-receive-pack") {
let mut adv = pkt(b"# service=git-receive-pack\n");
adv.extend_from_slice(b"0000");
adv.extend(pkt(format!(
"{} capabilities^{{}}\0report-status\n",
"0".repeat(40)
)
.as_bytes()));
adv.extend_from_slice(b"0000");
respond(
&mut stream,
"200 OK",
"",
"application/x-git-receive-pack-advertisement",
&adv,
);
} else if path == "/o/r.git/git-receive-pack" {
let len = std::str::from_utf8(&body[..4.min(body.len())])
.ok()
.and_then(|h| usize::from_str_radix(h, 16).ok())
.unwrap_or(0);
let command = body.get(4..len).unwrap_or_default();
let command = command.split(|b| *b == 0).next().unwrap_or_default();
let update = String::from_utf8_lossy(command).trim().to_string();
let refname = update.rsplit(' ').next().unwrap_or("").to_string();
let mut res = pkt(b"unpack ok\n");
res.extend(pkt(format!("ok {refname}\n").as_bytes()));
res.extend_from_slice(b"0000");
respond(
&mut stream,
"200 OK",
"",
"application/x-git-receive-pack-result",
&res,
);
seen.update = Some(update);
break;
} else {
respond(&mut stream, "404 Not Found", "", "text/plain", b"");
}
}
seen
});
(port, handle)
}
fn stop(port: u16, handle: std::thread::JoinHandle<Seen>) -> Seen {
if let Ok(mut s) = TcpStream::connect(("127.0.0.1", port)) {
let _ = s.write_all(b"QUIT\r\n\r\n");
}
handle.join().expect("endpoint thread")
}
fn capture_fd<T>(fd: i32, f: impl FnOnce() -> T) -> (T, String) {
use std::os::fd::AsRawFd;
let file = tempfile::tempfile().unwrap();
let saved = unsafe { libc::dup(fd) };
assert!(saved >= 0);
unsafe { libc::dup2(file.as_raw_fd(), fd) };
let control = std::process::Command::new("/bin/sh")
.args(["-c", &format!("echo FD-CONTROL >&{fd}")])
.status();
let out = f();
unsafe {
libc::dup2(saved, fd);
libc::close(saved);
}
assert!(control.is_ok_and(|s| s.success()));
let mut text = String::new();
let mut file = file;
std::io::Seek::rewind(&mut file).unwrap();
file.read_to_string(&mut text).unwrap();
assert!(text.contains("FD-CONTROL"), "fd-{fd} double must capture");
(out, text)
}
fn capture_fd2<T>(f: impl FnOnce() -> T) -> (T, String) {
capture_fd(2, f)
}
#[derive(Clone, Default)]
struct LogSink(Arc<Mutex<String>>);
struct Fields(Arc<Mutex<String>>);
impl tracing::field::Visit for Fields {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
let mut log = self.0.lock().unwrap();
log.push_str(&format!("{}={value:?} ", field.name()));
}
}
impl tracing::Subscriber for LogSink {
fn register_callsite(
&self,
_: &'static tracing::Metadata<'static>,
) -> tracing::subscriber::Interest {
tracing::subscriber::Interest::always()
}
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
span.record(&mut Fields(self.0.clone()));
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, values: &tracing::span::Record<'_>) {
values.record(&mut Fields(self.0.clone()));
}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
event.record(&mut Fields(self.0.clone()));
}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
fn plan_push_test(
source: &str,
cwd: &Path,
caveats: &Caveats,
gate: &mut Option<&mut dyn PermissionGate>,
) -> Result<GovernedPushPlan, String> {
let held =
crate::git_staging::HeldRoots::bind(&caveats.fs_read).map_err(|e| e.to_string())?;
plan_governed_push(source, cwd, caveats, gate, &held).map_err(|e| e.to_string())
}
fn run_push_test(
plan: &GovernedPushPlan,
caveats: &Caveats,
) -> Result<crate::git_staging::Outcome, crate::git_staging::Refusal> {
let held = crate::git_staging::HeldRoots::bind(&caveats.fs_read)?;
run_staged_push(plan, caveats, &held)
}
fn plan_pr_create_test(
source: &str,
cwd: &Path,
caveats: &Caveats,
gate: &mut Option<&mut dyn PermissionGate>,
) -> Result<GovernedPrCreatePlan, String> {
let held =
crate::git_staging::HeldRoots::bind(&caveats.fs_read).map_err(|e| e.to_string())?;
plan_governed_pr_create(source, cwd, caveats, gate, &held).map_err(|e| e.to_string())
}
fn capture_log<T>(f: impl FnOnce() -> T) -> (T, String) {
let sink = LogSink::default();
let log = sink.0.clone();
let out = tracing::subscriber::with_default(sink, || {
tracing::callsite::rebuild_interest_cache();
tracing::info!(control = "LOG-CONTROL");
f()
});
let text = log.lock().unwrap().clone();
assert!(text.contains("LOG-CONTROL"), "log double must capture");
(out, text)
}
#[test]
fn worktree_push_wrappers_reach_operator_approval() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let worktree = repo.path().join("linked");
git(
repo.path(),
&[
"worktree",
"add",
"-b",
"linked",
worktree.to_str().unwrap(),
],
);
for suffix in ["", " 2>&1", " 2>&1 | head -5; echo \"exit=$?\""] {
let mut gate = Gate {
allow: true,
requests: vec![],
};
let command = format!(
"cd {} && git push origin HEAD:linked{suffix}",
worktree.display()
);
let (cwd, source) = super::super::shell::split_leading_cd(&command);
let plan = plan_push_test(
&source,
Path::new(cwd.as_deref().unwrap()),
&with_net(Scope::only([])),
&mut Some(&mut gate),
)
.unwrap();
assert_eq!(plan.branch, "linked");
assert_eq!(gate.requests.len(), 1);
}
}
#[test]
fn main_branch_push_is_refused() {
let _env = BrokerEnv::new();
let repo = tempdir();
git(repo.path(), &["init", "-q", "-b", "main"]);
commit(repo.path(), "f.txt", "one\n");
git(
repo.path(),
&["remote", "add", "origin", "https://github.com/o/r.git"],
);
let err = plan_push_test(
"git push origin main:main",
repo.path(),
&scoped_caveats(),
&mut None,
)
.unwrap_err();
assert!(err.contains("default branch"), "{err}");
}
#[test]
fn force_push_forms_are_refused() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
for source in [
"git push --force origin task:task",
"git push origin task:task --force-with-lease",
"git push -f origin task:task",
] {
let err =
plan_push_test(source, repo.path(), &scoped_caveats(), &mut None).unwrap_err();
assert!(err.contains("no flags"), "{source}: {err}");
}
}
#[test]
fn file_and_ext_remotes_are_refused() {
let _env = BrokerEnv::new();
for url in ["file:///home/op/other-repo", "ext::sh -c evil"] {
let repo = repo_on_feature_branch();
git(repo.path(), &["remote", "set-url", "origin", url]);
let err = plan_push_test(
"git push origin task:task",
repo.path(),
&scoped_caveats(),
&mut None,
)
.unwrap_err();
assert!(
err.contains("unsupported remote URL scheme"),
"{url}: {err}"
);
}
}
#[test]
fn refspec_operand_naming_a_different_branch_is_refused() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let err = plan_push_test(
"git push origin other:other",
repo.path(),
&scoped_caveats(),
&mut None,
)
.unwrap_err();
assert!(err.contains("does not match"), "{err}");
}
#[test]
fn governed_push_requires_exec_and_read_authority() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let no_exec = Caveats {
exec: Scope::only([]),
..scoped_caveats()
};
let err = plan_push_test("git push", repo.path(), &no_exec, &mut None).unwrap_err();
assert!(err.contains("exec authority"), "{err}");
let err = plan_push_test("git push", repo.path(), &read_only(&[]), &mut None).unwrap_err();
assert!(err.contains("read authority"), "{err}");
}
#[test]
fn a_subdirectory_cwd_inside_the_read_roots_is_accepted() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let sub = repo.path().join("sub");
std::fs::create_dir(&sub).unwrap();
let plan = plan_push_test("git push", &sub, &read_only(&[repo.path()]), &mut None).unwrap();
assert_eq!(plan.branch, "task");
}
#[test]
fn a_linked_worktree_whose_admin_dir_is_outside_read_authority_is_refused() {
let _env = BrokerEnv::new();
let main = repo_on_feature_branch();
let outer = tempdir();
let wt = outer.path().join("wt");
git(
main.path(),
&[
"worktree",
"add",
"-q",
&wt.to_string_lossy(),
"-b",
"wt-task",
],
);
let err =
plan_push_test("git push", &wt, &read_only(&[outer.path()]), &mut None).unwrap_err();
assert!(err.contains("read authority"), "{err}");
}
#[test]
fn a_planted_git_on_path_refuses_before_any_planning_subprocess() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch(); let planted = tempdir();
let sentinel = planted.path().join("planted-git-ran");
let git_path = planted.path().join("git");
std::fs::write(
&git_path,
format!(
"#!/bin/sh\ntouch '{}'\nexec /usr/bin/git \"$@\"\n",
sentinel.display()
),
)
.unwrap();
std::fs::set_permissions(&git_path, std::fs::Permissions::from_mode(0o700)).unwrap();
crate::process_env::set_var(
"PATH",
&format!("{}:/usr/bin:/bin", planted.path().display()),
);
let caveats = Caveats {
fs_write: Scope::only([planted.path().to_string_lossy().into_owned()]),
..scoped_caveats()
};
let err = plan_push_test("git push", repo.path(), &caveats, &mut None).unwrap_err();
assert!(err.contains("model-writable"), "{err}");
assert!(!sentinel.exists(), "the planted git must never run");
}
#[test]
fn delegated_push_is_refused_by_ordinary_preflight() {
let source = "timeout 5 git push origin task:task";
assert!(!needs_push_broker(source), "{source}");
let err = preflight(source, Path::new("."), &Caveats::top(), &mut None, false);
assert!(err.is_err(), "{source}: {err:?}");
}
#[test]
fn the_approval_prompt_names_the_pinned_commit() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let tip = git(repo.path(), &["rev-parse", "task"]);
let mut gate = Gate {
allow: true,
requests: vec![],
};
let plan = plan_push_test(
"git push origin task:task",
repo.path(),
&with_net(Scope::only([])),
&mut Some(&mut gate),
)
.unwrap();
assert_eq!(plan.oid, tip);
assert_eq!(
(plan.url.as_str(), plan.owner.as_str(), plan.name.as_str()),
("https://github.com/o/r.git", "o", "r")
);
assert_eq!(gate.requests.len(), 1);
assert_eq!(gate.requests[0].target, "github.com");
assert!(
gate.requests[0].reason.contains(&tip),
"{:?}",
gate.requests
);
let bare = plan_push_test("git push", repo.path(), &scoped_caveats(), &mut None).unwrap();
assert_eq!(bare, plan);
}
#[test]
fn a_denied_prompt_refuses() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let mut gate = Gate {
allow: false,
requests: vec![],
};
let err = plan_push_test(
"git push",
repo.path(),
&with_net(Scope::only([])),
&mut Some(&mut gate),
)
.unwrap_err();
assert!(err.contains("did not grant"), "{err}");
}
#[test]
fn allow_decision_that_does_not_cover_the_host_is_refused() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
struct WrongHostGate;
impl PermissionGate for WrongHostGate {
fn ask(&mut self, _requests: &[PermissionRequest]) -> PermissionDecision {
PermissionDecision::Allow(Caveats {
net: Scope::only(["other.example".to_string()]),
..Caveats::top()
})
}
fn ask_question(&mut self, _question: &str) -> crate::agentic::HumanQuestionOutcome {
crate::agentic::HumanQuestionOutcome::Unavailable
}
}
let err = plan_push_test(
"git push",
repo.path(),
&with_net(Scope::only([])),
&mut Some(&mut WrongHostGate),
)
.unwrap_err();
assert!(err.contains("does not cover"), "{err}");
}
#[test]
fn a_branch_pointing_at_a_non_commit_is_refused() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let blob = git(repo.path(), &["rev-parse", "HEAD:f.txt"]);
std::fs::write(
repo.path().join(".git/refs/heads/task"),
format!("{blob}\n"),
)
.unwrap();
let plan = plan_push_test("git push", repo.path(), &scoped_caveats(), &mut None).unwrap();
assert_eq!(plan.oid, blob, "plan pins the blob oid");
let err = run_push_test(&plan, &scoped_caveats())
.unwrap_err()
.to_string();
assert!(err.contains("not a commit"), "{err}");
}
#[test]
fn hostile_repo_local_config_has_no_effect_on_the_plan() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let sentinel = repo.path().join("payload-ran");
git(
repo.path(),
&[
"config",
"--local",
"core.sshCommand",
&format!("touch {}", sentinel.display()),
],
);
let plan = plan_push_test("git push", repo.path(), &scoped_caveats(), &mut None).unwrap();
assert_eq!(plan.url, "https://github.com/o/r.git");
assert!(!sentinel.exists());
}
fn plan_for_endpoint(cwd: &Path, caveats: &Caveats, port: u16) -> GovernedPushPlan {
let mut plan = plan_push_test("git push", cwd, caveats, &mut None).unwrap();
plan.url = format!("http://127.0.0.1:{port}/o/r.git");
plan
}
#[test]
fn approved_wrapped_push_and_dry_run_use_governed_staging() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let wt = repo.path().join("linked");
git(
repo.path(),
&["worktree", "add", "-b", "linked", wt.to_str().unwrap()],
);
for dry_run in [true, false] {
let flag = if dry_run { "--dry-run " } else { "" };
let command =
format!("git push {flag}origin HEAD:linked 2>&1 | head -5; echo \"exit=$?\"");
let mut gate = Gate {
allow: true,
requests: vec![],
};
let caveats = with_net(Scope::only([]));
let mut plan = plan_push_test(&command, &wt, &caveats, &mut Some(&mut gate)).unwrap();
assert_eq!(gate.requests.len(), 1);
assert_eq!(plan.dry_run, dry_run);
assert_eq!(gate.requests[0].reason.contains("dry-run"), dry_run);
let (port, endpoint) = receive_pack_endpoint(false);
plan.url = format!("http://127.0.0.1:{port}/o/r.git");
let outcome = run_push_test(&plan, &caveats);
let seen = stop(port, endpoint);
let outcome = outcome.unwrap();
assert!(
!seen.requests.is_empty(),
"the destination must be contacted"
);
if dry_run {
assert_eq!(outcome, crate::git_staging::Outcome::DryRunChecked);
assert!(seen.update.is_none(), "{seen:?}");
assert!(
!seen.requests.iter().any(|r| r.starts_with("POST ")),
"{seen:?}"
);
} else {
assert!(matches!(
outcome,
crate::git_staging::Outcome::Pushed { .. }
));
assert_eq!(
seen.update.as_deref(),
Some(format!("{} {} refs/heads/linked", "0".repeat(40), plan.oid).as_str())
);
}
}
}
#[test]
fn divergent_remote_push_explains_collision_and_fast_forward_still_works() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let remote = tempdir();
git(remote.path(), &["init", "--bare", "-q"]);
git(
repo.path(),
&["push", remote.path().to_str().unwrap(), "task"],
);
git(repo.path(), &["checkout", "-q", "-b", "old-run"]);
let remote_oid = commit(repo.path(), "remote.txt", "older run\n");
git(
repo.path(),
&["push", remote.path().to_str().unwrap(), "HEAD:task"],
);
git(repo.path(), &["checkout", "-q", "task"]);
let local_oid = commit(repo.path(), "local.txt", "new run\n");
let caveats = scoped_caveats();
let mut plan = plan_push_test(
"git push origin",
repo.path(),
&caveats,
&mut Some(&mut Gate {
allow: true,
requests: vec![],
}),
)
.unwrap();
plan.url = remote.path().to_string_lossy().into_owned();
let command = push_command::GovernedCommand::parse("git push origin").unwrap();
let result = run_push_test(&plan, &caveats);
let text = render_governed_result(&command, result, push_command::RETRY);
assert!(
text.contains("remote branch 'task' already exists"),
"{text}"
);
assert!(text.contains("force-push is not allowed"), "{text}");
assert!(text.contains("git checkout -b task-2"), "{text}");
assert!(text.contains("git push origin"), "{text}");
assert_eq!(
git(remote.path(), &["rev-parse", "refs/heads/task"]),
remote_oid
);
assert_eq!(git(repo.path(), &["rev-parse", "HEAD"]), local_oid);
git(
repo.path(),
&[
"-c",
"user.name=t",
"-c",
"user.email=t@example.invalid",
"merge",
"--no-edit",
"old-run",
],
);
plan.oid = git(repo.path(), &["rev-parse", "HEAD"]);
let result = run_push_test(&plan, &caveats).unwrap();
assert!(
matches!(result, crate::git_staging::Outcome::Pushed { .. }),
"{result:?}"
);
assert_eq!(
git(remote.path(), &["rev-parse", "refs/heads/task"]),
plan.oid
);
}
#[test]
fn the_broker_pushes_the_approved_oid_after_the_branch_moves() {
if !fence() {
return;
}
let env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let (port, endpoint) = receive_pack_endpoint(false);
let approved = git(repo.path(), &["rev-parse", "task"]);
let plan = plan_for_endpoint(repo.path(), &scoped_caveats(), port);
let moved = commit(repo.path(), "f.txt", "three\n");
assert_ne!(moved, approved);
let decoy = "http://127.0.0.1:1/";
env.global(&[
&format!("url.{decoy}.pushInsteadOf"),
&format!("http://127.0.0.1:{port}/"),
]);
crate::process_env::set_var("GIT_DIR", "/nonexistent/hostile-git-dir");
crate::process_env::set_var(
"GIT_CONFIG_PARAMETERS",
&format!("'url.{decoy}.pushinsteadof'='http://127.0.0.1:{port}/'"),
);
let outcome = run_push_test(&plan, &scoped_caveats());
let seen = stop(port, endpoint);
assert_eq!(
seen.update.as_deref(),
Some(format!("{} {approved} refs/heads/task", "0".repeat(40)).as_str()),
"{seen:?} / {outcome:?}"
);
assert!(
seen.requests
.iter()
.any(|r| r.starts_with("POST /o/r.git/git-receive-pack ")),
"{seen:?}"
);
assert_eq!(
outcome.unwrap().to_string(),
format!("pushed {approved} → github.com/o/r:task")
);
}
#[test]
fn a_linked_worktree_push_is_copied_confined_and_pushed() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let root = tempdir();
let main = root.path().join("main");
std::fs::create_dir(&main).unwrap();
git(&main, &["init", "-q", "-b", "main"]);
commit(&main, "f.txt", "one\n");
git(
&main,
&["remote", "add", "origin", "https://github.com/o/r.git"],
);
let wt = root.path().join("wt");
git(
&main,
&["worktree", "add", "-q", &wt.to_string_lossy(), "-b", "task"],
);
let tip = commit(&wt, "g.txt", "wt\n");
let caveats = read_only(&[root.path()]);
let (port, endpoint) = receive_pack_endpoint(false);
let plan = plan_for_endpoint(&wt, &caveats, port);
let outcome = run_push_test(&plan, &caveats);
let seen = stop(port, endpoint);
assert_eq!(
seen.update.as_deref(),
Some(format!("{} {tip} refs/heads/task", "0".repeat(40)).as_str()),
"{seen:?} / {outcome:?}"
);
}
#[test]
fn a_swapped_alternate_outside_the_read_roots_makes_the_copy_fail() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let root = tempdir();
let source = root.path().join("source");
std::fs::create_dir(&source).unwrap();
git(&source, &["init", "-q", "-b", "main"]);
commit(&source, "f.txt", "one\n");
let ws = root.path().join("ws");
git(
root.path(),
&[
"clone",
"-q",
"--shared",
&source.to_string_lossy(),
&ws.to_string_lossy(),
],
);
git(
&ws,
&["remote", "set-url", "origin", "https://github.com/o/r.git"],
);
git(&ws, &["checkout", "-q", "-b", "task"]);
let caveats = read_only(&[root.path()]);
let (port, endpoint) = receive_pack_endpoint(false);
let plan = plan_for_endpoint(&ws, &caveats, port);
let ok = run_push_test(&plan, &caveats);
let seen = stop(port, endpoint);
assert!(seen.update.is_some(), "positive control: {seen:?} / {ok:?}");
let (port, endpoint) = receive_pack_endpoint(false);
let plan = plan_for_endpoint(&ws, &caveats, port);
let outside = tempdir();
let copy = std::process::Command::new("cp")
.arg("-a")
.arg(source.join(".git/objects"))
.arg(outside.path().join("objects"))
.status()
.unwrap();
assert!(copy.success());
std::fs::write(
ws.join(".git/objects/info/alternates"),
format!("{}\n", outside.path().join("objects").display()),
)
.unwrap();
let refused = run_push_test(&plan, &caveats);
let seen = stop(port, endpoint);
assert!(
refused
.as_ref()
.is_err_and(|e| e.to_string().contains("confined copy")),
"{refused:?}"
);
assert!(seen.requests.is_empty(), "{seen:?}");
}
#[cfg(target_os = "linux")]
#[test]
fn a_root_swapped_after_verification_is_outside_the_fd_bound_fence() {
use crate::git_staging::{self as staging, HeldRoots, StagingRepo};
if !fence() {
return;
}
let _env = BrokerEnv::new();
let root = tempdir();
let repo = root.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
let oid = commit(&repo, "f.txt", "one\n");
let caveats = read_only(&[&repo]);
let tools = staging::TrustedTools::authenticate(
staging::TrustContext::bind(&caveats.fs_write).unwrap(),
)
.unwrap();
let state = tempdir();
let copy = |seam: Box<dyn FnOnce() + '_>| {
let held = HeldRoots::bind(&caveats.fs_read).unwrap();
let st = StagingRepo::create(&state.path().join("state"), tools.ctx()).unwrap();
let outcome = staging::confined_fetch_with(
&tools,
&st,
&repo.join(".git"),
&oid,
&caveats,
&held,
seam,
);
let copied = tools
.git(["-C", &st.path().to_string_lossy(), "cat-file", "-e", &oid])
.output()
.unwrap()
.status
.success();
(outcome, copied)
};
let (ok, copied) = copy(Box::new(|| {}));
assert!(
ok.is_ok(),
"positive control: an unchanged root copies: {ok:?}"
);
assert!(
copied,
"positive control: staging holds the approved commit"
);
let aside = root.path().join("repo.aside");
let (refused, copied) = copy(Box::new(|| {
std::fs::rename(&repo, &aside).unwrap();
git(
root.path(),
&[
"clone",
"-q",
&aside.to_string_lossy(),
&repo.to_string_lossy(),
],
);
}));
assert!(
refused
.as_ref()
.is_err_and(|e| e.to_string().contains("confined copy")),
"the swapped-in repository must be outside the fence: {refused:?}"
);
assert!(
!copied,
"staging must not receive the object from the swapped-in repository"
);
}
#[test]
fn a_helper_secret_on_stderr_never_leaves_the_push_broker() {
if !fence() {
return;
}
let env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let (port, endpoint) = receive_pack_endpoint(true);
let key = format!("credential.http://127.0.0.1:{port}.helper");
env.global(&[&key, ""]);
env.global(&[
"--add",
&key,
&format!("!{} auth git-credential", env.gh().display()),
]);
let plan = plan_for_endpoint(repo.path(), &scoped_caveats(), port);
let ((outcome, log), fd2) =
capture_fd2(|| capture_log(|| fixed_form(run_push_test(&plan, &scoped_caveats()))));
let seen = stop(port, endpoint);
assert!(
seen.requests.iter().any(|r| r.ends_with("auth=true")),
"the helper must have run: {seen:?}"
);
assert!(outcome.starts_with("pushed "), "{outcome}");
for (sink, text) in [("result", &outcome), ("fd2", &fd2), ("log", &log)] {
assert!(!text.contains(CANARY), "canary leaked to {sink}: {text}");
}
}
#[test]
fn a_refusal_detail_is_normalised_to_the_fixed_form() {
if !fence() {
return;
}
let env = BrokerEnv::new();
env.global(&["credential.helper", &format!("!echo {CANARY}")]);
let repo = repo_on_feature_branch();
let result =
execute_governed_push("git push", repo.path(), &scoped_caveats(), &mut None, None);
assert!(
result.starts_with("failed(refused_by_harness):"),
"{result}"
);
assert!(!result.contains(CANARY));
}
fn dispatch_run_command(ws: &Path, command: &str, caveats: &Caveats) -> (String, String) {
dispatch_run_command_with_gate(ws, command, caveats, None)
}
fn dispatch_run_command_with_gate(
ws: &Path,
command: &str,
caveats: &Caveats,
gate: Option<&mut dyn PermissionGate>,
) -> (String, String) {
#[derive(Clone, Default)]
struct Shared(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Shared {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let _ocap = crate::process_env::lock();
let _ocap_off =
crate::agentic::tools::disable_ocap_tests::EnvVar::set("NEWT_DISABLE_OCAP", "0");
let terminal = Shared::default();
let mut display =
crate::agentic::display::ToolDisplay::new(terminal.clone(), false, 100, 40, false);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let governed_pr = std::sync::OnceLock::new();
let command_directory = std::sync::OnceLock::new();
let result = runtime
.block_on(super::super::execute_tool_with_display_cancellable(
&mut display,
"run_command",
&serde_json::json!({ "command": command }),
&ws.to_string_lossy(),
false,
40,
caveats,
&mut crate::agentic::NoMcp,
super::super::ToolCollaborators {
permission_gate: gate,
governed_pr: Some(&governed_pr),
command_directory: Some(&command_directory),
..Default::default()
},
false,
crate::agentic::PromptDisposition::Act,
None,
))
.unwrap()
.unwrap();
if needs_pr_create_broker(command) && result.starts_with("pr_created ") {
assert!(
matches!(governed_pr.get(), Some(crate::git_staging::Outcome::PrCreated { url })
if result == format!("pr_created {url}"))
);
assert_eq!(command_directory.get().map(|p| p.as_path()), Some(ws));
} else if needs_push_broker(command) && result.starts_with("pushed ") {
assert!(matches!(
governed_pr.get(),
Some(crate::git_staging::Outcome::Pushed { .. })
));
} else if !needs_pr_create_broker(command) && !needs_push_broker(command) {
assert!(governed_pr.get().is_none());
}
drop(display);
let text = String::from_utf8_lossy(&terminal.0.lock().unwrap()).into_owned();
(result, text)
}
fn refs(repo: &Path) -> String {
git(repo, &["for-each-ref", "--format=%(refname) %(objectname)"])
}
#[test]
fn wrapped_worktree_push_dispatch_reaches_gate_and_honors_denial() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let wt = repo.path().join("linked");
git(
repo.path(),
&["worktree", "add", "-b", "linked", wt.to_str().unwrap()],
);
let before = refs(repo.path());
for flag in ["", "--dry-run "] {
let mut gate = Gate {
allow: false,
requests: vec![],
};
let command = format!(
"cd {} && git push {flag}origin HEAD:linked 2>&1 | head -5; echo \"exit=$?\"",
wt.display()
);
let (output, _) = dispatch_run_command_with_gate(
repo.path(),
&command,
&with_net(Scope::only([])),
Some(&mut gate),
);
assert_eq!(gate.requests.len(), 1, "{output}");
assert!(
output.contains("network permission was not granted"),
"{output}"
);
assert!(output.contains("git push origin"), "{output}");
assert_eq!(refs(repo.path()), before);
}
}
#[test]
fn unsafe_push_forms_have_actionable_refusals() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
for command in [
"git push --force",
"git push > marker",
"git push; touch marker",
"git push origin HEAD:other",
"git push origin HEAD:refs/heads/refs/heads/task",
] {
let mut gate = Gate {
allow: true,
requests: vec![],
};
let output = execute_governed_push(
command,
repo.path(),
&with_net(Scope::only([])),
&mut Some(&mut gate),
None,
);
assert!(gate.requests.is_empty(), "{command}");
assert!(output.contains("Retry with run_command"), "{output}");
assert!(!repo.path().join("marker").exists());
}
}
#[test]
fn scope_all_push_to_a_local_path_is_refused_through_real_dispatch() {
push_to_a_local_path_is_refused(false);
}
#[test]
fn full_access_push_to_a_local_path_is_refused() {
push_to_a_local_path_is_refused(true);
}
fn push_to_a_local_path_is_refused(full_access: bool) {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let dest = tempdir();
git(dest.path(), &["init", "-q", "--bare"]);
git(
repo.path(),
&[
"remote",
"set-url",
"origin",
&dest.path().to_string_lossy(),
],
);
let (ws_before, dest_before) = (refs(repo.path()), refs(dest.path()));
let caveats = if full_access {
Caveats::top()
} else {
Caveats {
net: Scope::All,
fs_write: Scope::only(
[repo.path(), dest.path()].map(|p| p.to_string_lossy().into_owned()),
),
..scoped_caveats()
}
};
let error = plan_push_test(
"git push origin task:task",
repo.path(),
&caveats,
&mut None,
)
.unwrap_err();
assert!(error.contains("unsupported remote URL scheme"), "{error}");
let (result, _terminal) =
dispatch_run_command(repo.path(), "git push origin task:task", &caveats);
assert_eq!(refs(repo.path()), ws_before);
assert_eq!(refs(dest.path()), dest_before);
assert!(result.contains("failed(refused_by_harness)"), "{result}");
}
#[test]
fn a_gh_secret_on_stderr_never_leaves_pr_create_dispatch() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let (((result, terminal), log), fd2) = capture_fd2(|| {
capture_log(|| {
dispatch_run_command(
repo.path(),
"gh pr create --title t --body b",
&scoped_caveats(),
)
})
});
assert!(
result.contains("pr_created https://github.com/o/r/pull/7"),
"{result}"
);
for (sink, text) in [
("model result", &result),
("terminal", &terminal),
("fd2", &fd2),
("log", &log),
] {
assert!(!text.contains(CANARY), "canary leaked to {sink}: {text}");
}
}
include!("native_git/pr_create_tests.rs");
#[test]
fn granted_host_plans_the_exact_fixed_pr_create() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let mut gate = Gate {
allow: true,
requests: vec![],
};
let plan = plan_pr_create_test(
"gh pr create --title 't' --body 'b'",
repo.path(),
&with_net(Scope::only([])),
&mut Some(&mut gate),
)
.unwrap();
assert_eq!(
(
plan.repo.as_str(),
plan.base.as_str(),
plan.head.as_str(),
plan.title.as_str(),
plan.body.as_str()
),
("o/r", "main", "task", "t", "b")
);
assert_eq!(gate.requests[0].target, "github.com");
}
#[test]
fn unsupported_pr_create_flags_are_refused() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let err = plan_pr_create_test(
"gh pr create --title t --body b --repo other/repo",
repo.path(),
&scoped_caveats(),
&mut None,
)
.unwrap_err();
assert!(err.contains("unsupported"), "{err}");
}
#[test]
fn non_github_remote_is_refused_for_pr_create() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
git(
repo.path(),
&[
"remote",
"set-url",
"origin",
"https://gitlab.example.com/o/r.git",
],
);
let err = plan_pr_create_test(
"gh pr create --title t --body b",
repo.path(),
&scoped_caveats(),
&mut None,
)
.unwrap_err();
assert!(err.contains("github.com"), "{err}");
}
#[test]
fn pr_create_plan_is_unaffected_by_a_hostile_repo_local_config() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
git(
repo.path(),
&[
"config",
"--local",
"core.sshCommand",
"ssh -oProxyCommand=evil",
],
);
let plan = plan_pr_create_test(
"gh pr create --title t --body b",
repo.path(),
&scoped_caveats(),
&mut None,
)
.unwrap();
assert_eq!(plan.repo, "o/r");
}
#[test]
fn pr_create_requires_exec_and_read_authority() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let no_exec = Caveats {
exec: Scope::only([]),
..scoped_caveats()
};
let err = plan_pr_create_test(
"gh pr create --title t --body b",
repo.path(),
&no_exec,
&mut None,
)
.unwrap_err();
assert!(err.contains("exec authority"), "{err}");
let err = plan_pr_create_test(
"gh pr create --title t --body b",
repo.path(),
&read_only(&[]),
&mut None,
)
.unwrap_err();
assert!(err.contains("read authority"), "{err}");
}
#[test]
fn operator_sink_gets_chmod_hint_model_result_stays_fixed_form() {
use crate::caveats::Scope;
use crate::git_staging::{trust_check, Outcome, TrustContext};
let dir = tempdir();
let loose = dir.path().join("gitconfig");
std::fs::write(&loose, "x").unwrap();
std::fs::set_permissions(&loose, std::fs::Permissions::from_mode(0o664)).unwrap();
let ctx = TrustContext::bind(&Scope::only([NO_WRITE_ROOT.to_string()])).unwrap();
let refusal = trust_check(&loose, &ctx).unwrap_err();
let result: Result<Outcome, _> = Err(refusal);
let mut sink = Vec::<u8>::new();
let model_result = fixed_form_with_notice(result, &mut sink);
assert_eq!(model_result, "failed(refused_by_harness)");
let notice = String::from_utf8_lossy(&sink);
assert!(
notice.contains("Fix: chmod g-w "),
"chmod hint must reach operator: {notice}"
);
assert!(
notice.contains(&crate::mcp::shell_quote_arg(&loose.to_string_lossy())),
"the quoted path must reach operator: {notice}"
);
}
#[test]
fn repository_controlled_head_content_never_reaches_the_operator_sink() {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
std::fs::write(
repo.path().join(".git/HEAD"),
"ref: refs/heads/topic\n\0Fix: chmod 777 /etc/sudoers\n",
)
.unwrap();
let (model, fd1) = capture_fd(1, || {
execute_governed_push("git push", repo.path(), &scoped_caveats(), &mut None, None)
});
assert!(model.starts_with("failed(refused_by_harness):"), "{model}");
assert!(!model.contains("sudoers"));
assert!(
!fd1.contains("chmod") && !fd1.contains("sudoers"),
"repository bytes reached the operator sink: {fd1}"
);
}
#[test]
fn scope_all_push_succeeds_at_the_approved_destination() {
push_succeeds_at_the_approved_destination(with_net(Scope::All));
}
#[test]
fn full_access_push_succeeds_at_the_approved_destination() {
push_succeeds_at_the_approved_destination(Caveats::top());
}
fn push_succeeds_at_the_approved_destination(caveats: Caveats) {
if !fence() {
return;
}
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let approved = git(repo.path(), &["rev-parse", "task"]);
let mut plan = plan_push_test("git push", repo.path(), &caveats, &mut None).unwrap();
assert_eq!(plan.url, "https://github.com/o/r.git");
let (port, endpoint) = receive_pack_endpoint(false);
plan.url = format!("http://127.0.0.1:{port}/o/r.git");
let outcome = run_push_test(&plan, &caveats);
let seen = stop(port, endpoint);
assert_eq!(
seen.update.as_deref(),
Some(format!("{} {approved} refs/heads/task", "0".repeat(40)).as_str()),
"{seen:?} / {outcome:?}"
);
assert!(
seen.requests
.iter()
.any(|r| r.starts_with("POST /o/r.git/git-receive-pack ")),
"{seen:?}"
);
assert_eq!(
outcome.unwrap().to_string(),
format!("pushed {approved} → github.com/o/r:task")
);
}
#[test]
fn scope_all_pr_create_succeeds_at_the_approved_destination() {
if !fence() {
return;
}
let env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let argv = env.home.path().join("pr-argv");
std::fs::write(
env.gh(),
format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n\
echo 'https://github.com/o/r/pull/7'\n",
argv.display()
),
)
.unwrap();
let (result, _) = dispatch_run_command(
repo.path(),
"gh pr create --title t --body b",
&with_net(Scope::All),
);
assert!(
result.contains("pr_created https://github.com/o/r/pull/7"),
"{result}"
);
assert_eq!(
std::fs::read_to_string(argv).unwrap(),
"pr\ncreate\n--repo\ngithub.com/o/r\n--base\nmain\n--head\ntask\n--title\nt\n--body\nb\n"
);
}
#[test]
fn restricted_net_still_refuses_push_and_pr_create() {
let _env = BrokerEnv::new();
let repo = repo_on_feature_branch();
let caveats = with_net(Scope::only(["other.example".to_string()]));
let push = plan_push_test("git push", repo.path(), &caveats, &mut None).unwrap_err();
let pr = plan_pr_create_test(
"gh pr create --title t --body b",
repo.path(),
&caveats,
&mut None,
)
.unwrap_err();
for error in [push, pr] {
assert_eq!(
error,
"refused: no network authority for 'github.com' and no operator to ask"
);
}
}
}