diff --git a/crates/agentic-git/src/lib.rs b/crates/agentic-git/src/lib.rs index fcc992c..5e20c40 100644 --- a/crates/agentic-git/src/lib.rs +++ b/crates/agentic-git/src/lib.rs @@ -242,6 +242,13 @@ fn shim_main() { .and_then(|i| args.get(i)) .map(|s| s.as_str()) .unwrap_or(""); + + // #34: nested submodule--helper at depth > 0 — invoked by real git + // internally, not a direct agent call. Pass through without classify/ + // snapshot/audit; the depth-0 shim already routed the parent operation. + if shim_depth() > 0 && subcommand == "submodule--helper" { + exec_real_git(&args, None); + } let norm_args: &[String] = match sub_idx { Some(i) => &args[i..], None => &args, @@ -968,6 +975,7 @@ fn is_mutating_local(subcmd: &str) -> bool { | "read-tree" | "update-index" | "apply" + | "submodule" ) } @@ -1029,8 +1037,12 @@ fn branch_tag_names_ref(subcmd: &str, args: &[String]) -> bool { fn bypass_op_is_audited(subcmd: &str, args: &[String]) -> bool { matches!( subcmd, - "worktree" | "checkout" | "switch" | "reset" | "clean" | "push" + "worktree" | "checkout" | "switch" | "reset" | "clean" | "push" | "submodule--helper" ) || (subcmd == "branch" && branch_tag_names_ref(subcmd, args)) + || (subcmd == "submodule" && { + let sub_idx = subcommand_index(args).unwrap_or(0); + submodule_op_is_write(&args[sub_idx + 1..]) + }) } /// #2158: which bypass layer authorized this op — forensics that distinguishes a @@ -1059,14 +1071,22 @@ fn apply_foreign_repo_passthrough( args: &[String], cwd_foreign: bool, ) -> Action { - match action { - Action::ChdirPass(_) - if cwd_foreign && (is_mutating_local(subcmd) || branch_tag_names_ref(subcmd, args)) => - { - Action::Passthrough - } - other => other, + if !cwd_foreign || !matches!(action, Action::ChdirPass(_)) { + return action; + } + if subcmd == "submodule" { + let sub_idx = subcommand_index(args).unwrap_or(0); + let rest = &args[sub_idx + 1..]; + return if submodule_op_is_write(rest) { + Action::Deny("submodule writes are denied in foreign repositories".into()) + } else { + action + }; } + if is_mutating_local(subcmd) || branch_tag_names_ref(subcmd, args) { + return Action::Passthrough; + } + action } /// #1463: index of the real subcommand in `args` — the first non-option token, @@ -1154,7 +1174,12 @@ fn effective_cwd_through_globals(args: &[String], sub_idx: usize) -> PathBuf { /// canonical protection. fn strip_target_overrides(args: &[String]) -> Vec { let sub_idx = match subcommand_index(args) { - Some(i) if is_mutating_local(args[i].as_str()) => i, + Some(i) if is_mutating_local(args[i].as_str()) => { + if args[i].as_str() == "submodule" && !submodule_op_is_write(&args[i + 1..]) { + return args.to_vec(); + } + i + } // No subcommand, or a non-mutating one → leave `-C` etc. intact. _ => return args.to_vec(), }; @@ -1193,6 +1218,31 @@ fn strip_target_overrides(args: &[String]) -> Vec { out } +/// #34: whether the tokens after `submodule` represent a WRITE operation. +/// Validates the COMPLETE token sequence against the supported read grammar: +/// [--quiet|-q|--cached]* [status|summary]? [--quiet|-q|--cached]* +/// Any unconsumed, duplicate-class, or unrecognized token/flag fails closed +/// as write. Bare (empty rest) is read. +pub(crate) fn submodule_op_is_write(rest: &[String]) -> bool { + const RECOGNIZED: [&str; 3] = ["--quiet", "-q", "--cached"]; + let mut saw_op = false; + for t in rest { + let s = t.as_str(); + if RECOGNIZED.contains(&s) { + continue; + } + if s.starts_with('-') { + return true; + } + if !saw_op && matches!(s, "status" | "summary") { + saw_op = true; + continue; + } + return true; + } + false +} + /// #1511 follow-up: a MUTATING-form action — deny when unbound, else route to /// the caller's PRIVATE bound worktree. Mirrors the porcelain mutating arm body /// so the flag-discriminated plumbing arms (`restore --staged`, `update-ref`, @@ -1395,6 +1445,20 @@ fn classify( pass_unbound_else_chdir(bound, binding) } } + "submodule" => { + let sub_idx = subcommand_index(args).unwrap_or(0); + let rest = &args[sub_idx + 1..]; + if submodule_op_is_write(rest) { + deny_unbound_else_chdir(bound, binding) + } else { + pass_unbound_else_chdir(bound, binding) + } + } + "submodule--helper" => Action::Deny( + "direct submodule--helper invocation is not allowed — \ + submodule operations must go through `git submodule`" + .into(), + ), // Default: passthrough when unbound, chdir when bound. _ => { if bound { @@ -1440,7 +1504,7 @@ const KNOWN_SUBCOMMANDS: &[&str] = &[ "remote", "branch", "tag", "describe", "shortlog", "reflog", "config", "help", "version", "init", "clone", "push", "commit", "pull", "reset", "revert", "cherry-pick", "stash", "merge", "rebase", "am", "add", "rm", "mv", "read-tree", "update-index", "apply", "checkout", "switch", - "worktree", "restore", "update-ref", "symbolic-ref", + "worktree", "restore", "update-ref", "symbolic-ref", "submodule", "submodule--helper", ]; fn is_known_git_subcommand(s: &str) -> bool { diff --git a/crates/agentic-git/src/snapshot.rs b/crates/agentic-git/src/snapshot.rs index a136fce..609eb24 100644 --- a/crates/agentic-git/src/snapshot.rs +++ b/crates/agentic-git/src/snapshot.rs @@ -17,7 +17,10 @@ use std::path::Path; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; -use super::{env_compat, resolve_real_git, subcommand_index, write_git_event_typed}; +use super::{ + env_compat, resolve_real_git, subcommand_index, submodule_op_is_write, + write_git_event_typed, +}; /// Amortized + manual prune default TTL (Δd: committer-date based). pub(crate) const DEFAULT_TTL_SECS: u64 = 7 * 24 * 60 * 60; @@ -61,6 +64,7 @@ pub(crate) fn destructive_op_slug(args: &[String]) -> Option<&'static str> { "checkout" if checkout_is_destructive(rest) => Some("checkout"), "restore" if restore_touches_worktree(rest) => Some("restore"), "switch" if switch_is_destructive(rest) => Some("switch"), + "submodule" if submodule_op_is_write(rest) => Some("submodule"), _ => None, } } diff --git a/crates/agentic-git/src/tests.rs b/crates/agentic-git/src/tests.rs index 7e52ee5..f521dde 100644 --- a/crates/agentic-git/src/tests.rs +++ b/crates/agentic-git/src/tests.rs @@ -3873,3 +3873,245 @@ fn known_subcommands_mirror_classify_arms_27() { "bare `git gc` (no global) must keep its pre-#27 Passthrough" ); } + +// ── #34: submodule policy — RED phase ─────────────────────────────────── +// These tests prove the current fail-open gaps. They must all FAIL at +// baseline 5306c85 and PASS after the GREEN commit. + +#[test] +fn submodule_write_unbound_must_deny_34() { + let unbound = Binding { + task_id: None, + branch: None, + worktree: None, + }; + for op in [ + "init", "update", "deinit", "add", "set-branch", "set-url", "sync", "foreach", + "absorbgitdirs", + ] { + let args = s(&["submodule", op]); + let action = classify("submodule", &args, &unbound, false, false, false); + assert!( + matches!(action, Action::Deny(_)), + "unbound `submodule {op}` must Deny, got {action:?}" + ); + } +} + +#[test] +fn submodule_read_unbound_passthrough_34() { + let unbound = Binding { + task_id: None, + branch: None, + worktree: None, + }; + for args_slice in [ + &["submodule"][..], + &["submodule", "status"][..], + &["submodule", "summary"][..], + &["submodule", "status", "--cached"][..], + &["submodule", "--quiet"][..], + &["submodule", "--quiet", "status"][..], + &["submodule", "--quiet", "summary"][..], + &["submodule", "--cached"][..], + ] { + let args = s(args_slice); + let action = classify("submodule", &args, &unbound, false, false, false); + assert!( + matches!(action, Action::Passthrough), + "unbound `{args_slice:?}` must Passthrough (read), got {action:?}" + ); + } +} + +#[test] +fn submodule_helper_depth0_must_deny_34() { + let unbound = Binding { + task_id: None, + branch: None, + worktree: None, + }; + let bound = bound_binding("fix/x", "/wt"); + let args = s(&["submodule--helper", "update"]); + let action_unbound = classify("submodule--helper", &args, &unbound, false, false, false); + assert!( + matches!(action_unbound, Action::Deny(_)), + "top-level submodule--helper must Deny (unbound), got {action_unbound:?}" + ); + let action_bound = classify("submodule--helper", &args, &bound, false, false, false); + assert!( + matches!(action_bound, Action::Deny(_)), + "top-level submodule--helper must Deny (bound too), got {action_bound:?}" + ); +} + +#[test] +fn submodule_write_is_mutating_local_34() { + assert!( + is_mutating_local("submodule"), + "submodule must be in the mutating-local set (for strip_target_overrides + foreign gate)" + ); +} + +#[test] +fn submodule_write_has_destructive_op_slug_34() { + for op in ["init", "update", "deinit", "sync", "foreach"] { + assert!( + super::snapshot::destructive_op_slug(&s(&["submodule", op])).is_some(), + "submodule {op} must have a destructive_op_slug for pre-op snapshot" + ); + } + for read in [&["submodule"][..], &["submodule", "status"][..], &["submodule", "summary"][..]] { + assert!( + super::snapshot::destructive_op_slug(&s(read)).is_none(), + "submodule read {read:?} must NOT have a destructive_op_slug" + ); + } +} + +#[test] +fn submodule_write_bypass_audit_34() { + assert!( + bypass_op_is_audited("submodule", &s(&["submodule", "update"])), + "bypass submodule write must be audited" + ); + assert!( + bypass_op_is_audited("submodule--helper", &s(&["submodule--helper"])), + "bypass submodule--helper must be audited" + ); + assert!( + !bypass_op_is_audited("submodule", &s(&["submodule"])), + "bypass bare submodule (read/status) must NOT be audited" + ); + assert!( + !bypass_op_is_audited("submodule", &s(&["submodule", "status"])), + "bypass submodule status must NOT be audited" + ); + assert!( + !bypass_op_is_audited("submodule", &s(&["submodule", "--quiet"])), + "bypass bare submodule with --quiet flag (read) must NOT be audited" + ); + assert!( + bypass_op_is_audited("submodule", &s(&["submodule", "--quiet", "update"])), + "bypass submodule --quiet update (write) must be audited" + ); +} + +#[test] +fn submodule_foreign_cwd_write_must_deny_34() { + use Action::*; + let a = |toks: &[&str]| toks.iter().map(|s| s.to_string()).collect::>(); + let action = apply_foreign_repo_passthrough( + ChdirPass("/wt".into()), + "submodule", + &a(&["submodule", "update"]), + true, + ); + assert!( + matches!(action, Deny(_)), + "foreign cwd submodule write must Deny (not Passthrough), got {action:?}" + ); + let action_read = apply_foreign_repo_passthrough( + ChdirPass("/wt".into()), + "submodule", + &a(&["submodule", "status"]), + true, + ); + assert_eq!( + action_read, + ChdirPass("/wt".into()), + "foreign cwd submodule read must stay ChdirPass" + ); +} + +#[test] +fn submodule_unknown_op_fail_closed_34() { + let unbound = Binding { + task_id: None, + branch: None, + worktree: None, + }; + let args = s(&["submodule", "futureop"]); + let action = classify("submodule", &args, &unbound, false, false, false); + assert!( + matches!(action, Action::Deny(_)), + "unbound submodule with unknown operation must Deny (fail-closed), got {action:?}" + ); +} + +#[test] +fn submodule_read_preserves_target_overrides_34() { + let read_args = s(&["-C", "/other", "submodule", "status"]); + let stripped = strip_target_overrides(&read_args); + assert_eq!( + stripped, read_args, + "submodule read must preserve -C target override" + ); + let write_args = s(&["-C", "/other", "submodule", "update"]); + let stripped = strip_target_overrides(&write_args); + assert!( + !stripped.iter().any(|a| a == "-C"), + "submodule write must strip -C target override, got {stripped:?}" + ); +} + +#[test] +fn submodule_leading_flags_write_34() { + let unbound = Binding { + task_id: None, + branch: None, + worktree: None, + }; + for args_slice in [ + &["submodule", "--quiet", "update"][..], + &["submodule", "--quiet", "init"][..], + &["submodule", "--quiet", "deinit"][..], + ] { + let args = s(args_slice); + let action = classify("submodule", &args, &unbound, false, false, false); + assert!( + matches!(action, Action::Deny(_)), + "unbound `{args_slice:?}` must Deny (write despite leading flag), got {action:?}" + ); + } +} + +#[test] +fn submodule_unknown_flag_fail_closed_34() { + let unbound = Binding { + task_id: None, + branch: None, + worktree: None, + }; + for args_slice in [ + &["submodule", "--future-flag"][..], + &["submodule", "--verbose", "status"][..], + &["submodule", "-v"][..], + &["submodule", "--recursive"][..], + &["submodule", "status", "--future-flag"][..], + &["submodule", "summary", "--future-flag"][..], + &["submodule", "status", "some-path"][..], + &["submodule", "status", "status"][..], + ] { + let args = s(args_slice); + let action = classify("submodule", &args, &unbound, false, false, false); + assert!( + matches!(action, Action::Deny(_)), + "unbound `{args_slice:?}` must Deny (unknown/trailing = fail-closed write), got {action:?}" + ); + } + // Recognized trailing --cached after status/summary must remain read. + for args_slice in [ + &["submodule", "status", "--cached"][..], + &["submodule", "summary", "--cached"][..], + &["submodule", "status", "--quiet"][..], + &["submodule", "--quiet", "status", "--cached"][..], + ] { + let args = s(args_slice); + let action = classify("submodule", &args, &unbound, false, false, false); + assert!( + matches!(action, Action::Passthrough), + "unbound `{args_slice:?}` must Passthrough (recognized trailing read), got {action:?}" + ); + } +} diff --git a/crates/agentic-git/tests/exec_reachability_invariant.rs b/crates/agentic-git/tests/exec_reachability_invariant.rs index 55a4eaa..4d9b601 100644 --- a/crates/agentic-git/tests/exec_reachability_invariant.rs +++ b/crates/agentic-git/tests/exec_reachability_invariant.rs @@ -124,6 +124,8 @@ fn if_condition_desc(cond: &syn::Expr) -> String { "if should_bypass".to_string() } else if scan.called.iter().any(|c| c == "is_empty") { "if is_empty".to_string() + } else if scan.called.iter().any(|c| c == "shim_depth") { + "if shim_depth".to_string() } else { "if other".to_string() } @@ -341,6 +343,9 @@ fn expected_whitelist() -> BTreeMap { // No-agent early path: `if agent.is_empty() || home.is_empty()` — // non-agent caller passthrough (#2234 defect#2 instrumentation above). "lib.rs :: shim_main :: call exec_real_git @ if is_empty", + // #34: nested submodule--helper at depth > 0 — pass through without + // classify/snapshot/audit (the depth-0 shim already routed the parent). + "lib.rs :: shim_main :: call exec_real_git @ if shim_depth", // Action dispatch arms — the ONLY post-classify exec points. "lib.rs :: shim_main :: call exec_real_git @ dispatch-arm Passthrough (unguarded)", "lib.rs :: shim_main :: call exec_real_git @ dispatch-arm ChdirPass (unguarded)", diff --git a/crates/agentic-git/tests/submodule_policy.rs b/crates/agentic-git/tests/submodule_policy.rs new file mode 100644 index 0000000..3b84911 --- /dev/null +++ b/crates/agentic-git/tests/submodule_policy.rs @@ -0,0 +1,645 @@ +//! #34: hermetic real-entry integration tests for the fail-closed submodule +//! policy. Drives the COMPILED shim binary (argv[0] forced to "git") through +//! actual file:// submodule fixtures. Covers: target preservation, recognized +//! read flags, bound/unbound own/foreign routing, depth-0 helper deny vs +//! depth>0 helper passthrough, unknown-op deny, bypass audit behaviour. + +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn tempdir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "agentic-git-submod-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("mkdir tempdir"); + dir +} + +fn cleanup(dir: &Path) { + let _ = std::fs::remove_dir_all(dir); +} + +fn resolve_real_git() -> PathBuf { + let path_env = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path_env) { + let candidate = dir.join("git"); + if candidate.exists() { + let s = candidate.to_string_lossy(); + if s.contains(".agend-terminal") || s.contains(".agentic-git") { + continue; + } + return candidate; + } + } + panic!("no real (non-shim) git found on PATH"); +} + +fn sanitized_path(real_git: &Path) -> std::ffi::OsString { + let path_env = std::env::var_os("PATH").unwrap_or_default(); + let mut dirs: Vec = Vec::new(); + if let Some(parent) = real_git.parent() { + dirs.push(parent.to_path_buf()); + } + for p in std::env::split_paths(&path_env) { + let s = p.to_string_lossy(); + if s.contains(".agend-terminal") || s.contains(".agentic-git") { + continue; + } + dirs.push(p); + } + std::env::join_paths(dirs).unwrap_or(path_env) +} + +fn setup_git(real_git: &Path, args: &[&str], cwd: &Path) -> std::process::Output { + let out = Command::new(real_git) + .args(["-c", "protocol.file.allow=always"]) + .args(args) + .current_dir(cwd) + .env("AGENTIC_GIT_BYPASS", "1") + .env("AGEND_GIT_BYPASS", "1") + .output() + .expect("run real git for setup"); + assert!( + out.status.success(), + "setup git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + out +} + +fn init_repo(real_git: &Path, dir: &Path) { + setup_git(real_git, &["init", "-q", "-b", "main", "."], dir); + setup_git(real_git, &["config", "user.name", "Test"], dir); + setup_git(real_git, &["config", "user.email", "t@example.com"], dir); + std::fs::write(dir.join("README.md"), "hello\n").unwrap(); + setup_git(real_git, &["add", "."], dir); + setup_git(real_git, &["commit", "-q", "-m", "init"], dir); +} + +fn write_binding(home: &Path, agent: &str, branch: &str, worktree: &Path) { + std::fs::create_dir_all(home).unwrap(); + std::fs::write(home.join(".config-integrity-key"), [7u8; 32]).unwrap(); + let dir = home.join("runtime").join(agent); + std::fs::create_dir_all(&dir).unwrap(); + let body = serde_json::json!({ + "version": 1, + "agent": agent, + "task_id": format!("{agent}-task"), + "branch": branch, + "issued_at": "2026-01-01T00:00:00Z", + "worktree": worktree.to_string_lossy(), + "source_repo": "irrelevant-for-this-suite", + }) + .to_string(); + std::fs::write(dir.join("binding.json"), &body).unwrap(); + let sig = agentic_git_core::integrity_core::sign(home, body.as_bytes()); + std::fs::write(dir.join("binding.json.sig"), sig).unwrap(); +} + +fn worktree_of(root: &Path, real_git: &Path, repo: &Path, branch: &str) -> PathBuf { + let wt = root.join("wt"); + setup_git( + real_git, + &["worktree", "add", wt.to_str().unwrap(), "-b", branch, "main"], + repo, + ); + wt +} + +fn run_shim( + cwd: &Path, + home: &Path, + agent: &str, + real_git: &Path, + extra_env: &[(&str, &str)], + args: &[&str], +) -> std::process::Output { + let mut c = Command::new(env!("CARGO_BIN_EXE_agentic-git")); + c.arg0("git") + .args(["-c", "protocol.file.allow=always"]) + .args(args) + .current_dir(cwd) + .env("AGENTIC_GIT_HOME", home) + .env("AGENTIC_GIT_AGENT", agent) + .env("AGENTIC_GIT_REAL_GIT", real_git) + .env("PATH", sanitized_path(real_git)) + .env_remove("AGEND_HOME") + .env_remove("AGEND_INSTANCE_NAME") + .env_remove("AGEND_REAL_GIT") + .env_remove("AGENTIC_GIT_BYPASS") + .env_remove("AGEND_GIT_BYPASS") + .env_remove("AGENTIC_GIT_BYPASS_AGENT") + .env_remove("AGEND_GIT_BYPASS_AGENT") + .env_remove("AGENTIC_GIT_BYPASS_UNTIL") + .env_remove("AGEND_GIT_BYPASS_UNTIL") + .env_remove("AGENTIC_GIT_SHIM_DEPTH") + .env_remove("AGEND_GIT_SHIM_DEPTH") + .env_remove("AGENTIC_GIT_SNAPSHOTS") + .env_remove("AGEND_GIT_SNAPSHOTS") + .env_remove("GIT_AUTHOR_DATE") + .env_remove("GIT_COMMITTER_DATE"); + for (k, v) in extra_env { + c.env(k, v); + } + c.output().expect("run shim") +} + +/// Create a parent repo with a file:// submodule already added and committed. +fn setup_parent_with_submodule(root: &Path, real_git: &Path) -> (PathBuf, PathBuf) { + let sub_repo = root.join("sub-upstream"); + std::fs::create_dir_all(&sub_repo).unwrap(); + init_repo(real_git, &sub_repo); + std::fs::write(sub_repo.join("lib.txt"), "submodule content\n").unwrap(); + setup_git(real_git, &["add", "."], &sub_repo); + setup_git(real_git, &["commit", "-q", "-m", "sub-content"], &sub_repo); + + let parent = root.join("parent"); + std::fs::create_dir_all(&parent).unwrap(); + init_repo(real_git, &parent); + let sub_url = format!("file://{}", sub_repo.display()); + setup_git( + real_git, + &["submodule", "add", &sub_url, "vendor/sub"], + &parent, + ); + setup_git( + real_git, + &["commit", "-q", "-m", "add submodule"], + &parent, + ); + (parent, sub_repo) +} + +// ── Unbound routing ──────────────────────────────────────────────────── + +#[test] +fn unbound_submodule_status_passes_through() { + let root = tempdir("unbound-read"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + + let out = run_shim(&parent, &home, "test-agent", &real_git, &[], &["submodule", "status"]); + assert!( + out.status.success() || out.status.code() == Some(0), + "unbound submodule status must pass through; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + cleanup(&root); +} + +#[test] +fn unbound_submodule_update_denied() { + let root = tempdir("unbound-write"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + + let out = run_shim(&parent, &home, "test-agent", &real_git, &[], &["submodule", "update"]); + assert!( + !out.status.success(), + "unbound submodule update must be denied" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("unbound") || stderr.contains("Deny"), + "deny message must mention unbound; stderr={stderr}" + ); + cleanup(&root); +} + +#[test] +fn unbound_submodule_quiet_status_passes_through() { + let root = tempdir("unbound-quiet-read"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + + let out = run_shim( + &parent, + &home, + "test-agent", + &real_git, + &[], + &["submodule", "--quiet", "status"], + ); + assert!( + out.status.success(), + "unbound submodule --quiet status must pass through; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + cleanup(&root); +} + +#[test] +fn unbound_submodule_quiet_update_denied() { + let root = tempdir("unbound-quiet-write"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + + let out = run_shim( + &parent, + &home, + "test-agent", + &real_git, + &[], + &["submodule", "--quiet", "update"], + ); + assert!( + !out.status.success(), + "unbound submodule --quiet update must be denied" + ); + cleanup(&root); +} + +#[test] +fn unbound_submodule_unknown_op_denied() { + let root = tempdir("unbound-unknown"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + + let out = run_shim( + &parent, + &home, + "test-agent", + &real_git, + &[], + &["submodule", "futureop"], + ); + assert!( + !out.status.success(), + "unbound submodule with unknown op must be denied (fail-closed)" + ); + cleanup(&root); +} + +// ── Bound routing ────────────────────────────────────────────────────── + +#[test] +fn bound_submodule_status_routes_to_worktree() { + let root = tempdir("bound-read"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + let wt = worktree_of(&root, &real_git, &parent, "fix/test"); + write_binding(&home, "test-agent", "fix/test", &wt); + + let out = run_shim(&wt, &home, "test-agent", &real_git, &[], &["submodule", "status"]); + assert!( + out.status.success(), + "bound submodule status must succeed via ChdirPass; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + cleanup(&root); +} + +#[test] +fn bound_submodule_update_routes_to_worktree() { + let root = tempdir("bound-write"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + let wt = worktree_of(&root, &real_git, &parent, "fix/test"); + write_binding(&home, "test-agent", "fix/test", &wt); + + let out = run_shim( + &wt, + &home, + "test-agent", + &real_git, + &[], + &["submodule", "update", "--init"], + ); + assert!( + out.status.success(), + "bound submodule update --init must succeed via ChdirPass; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + wt.join("vendor/sub/lib.txt").exists(), + "submodule content must be checked out in the worktree" + ); + cleanup(&root); +} + +// ── Helper depth boundary ────────────────────────────────────────────── + +#[test] +fn submodule_helper_depth0_denied() { + let root = tempdir("helper-d0"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + + let out = run_shim( + &parent, + &home, + "test-agent", + &real_git, + &[], + &["submodule--helper", "update"], + ); + assert!( + !out.status.success(), + "direct submodule--helper at depth 0 must be denied" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("submodule--helper") || stderr.contains("not allowed"), + "deny message must mention submodule--helper; stderr={stderr}" + ); + cleanup(&root); +} + +#[test] +fn submodule_helper_depth1_passes_through() { + let root = tempdir("helper-d1"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + + let out = run_shim( + &parent, + &home, + "test-agent", + &real_git, + &[("AGENTIC_GIT_SHIM_DEPTH", "1")], + &["submodule--helper", "status"], + ); + let stderr = String::from_utf8_lossy(&out.stderr); + // Discriminating: old behavior (no depth boundary) would deny with + // "submodule--helper invocation is not allowed". Passthrough must NOT + // produce the shim deny message regardless of exit code (the helper + // invoked directly may exit non-zero for other reasons). + assert!( + !stderr.contains("not allowed"), + "depth>0 submodule--helper must pass through, not be denied; stderr={stderr}" + ); + assert!( + !stderr.contains("Deny"), + "depth>0 submodule--helper must not hit classify deny; stderr={stderr}" + ); + cleanup(&root); +} + +// ── Target preservation (reads preserve -C; writes strip) ────────────── + +#[test] +fn bound_submodule_read_preserves_target_override() { + let root = tempdir("target-read"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + let wt = worktree_of(&root, &real_git, &parent, "fix/test"); + write_binding(&home, "test-agent", "fix/test", &wt); + + let other_dir = root.join("other"); + std::fs::create_dir_all(&other_dir).unwrap(); + init_repo(&real_git, &other_dir); + + let out = run_shim( + &wt, + &home, + "test-agent", + &real_git, + &[], + &["-C", other_dir.to_str().unwrap(), "submodule", "status"], + ); + assert!( + out.status.success(), + "submodule read with -C must preserve the target; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + // Discriminating: other_dir has NO submodules → stdout must be empty. + // If -C were stripped (old behavior), the shim would run in the worktree + // which HAS a submodule, producing non-empty stdout. + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.contains("vendor/sub"), + "stdout must NOT show worktree's submodule — -C should redirect to other_dir; stdout={stdout}" + ); + cleanup(&root); +} + +// ── Bypass audit ─────────────────────────────────────────────────────── + +#[test] +fn bypass_submodule_write_emits_audit() { + let root = tempdir("bypass-audit"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + std::fs::create_dir_all(home.join("runtime").join("test-agent")).unwrap(); + + let out = run_shim( + &parent, + &home, + "test-agent", + &real_git, + &[("AGENTIC_GIT_BYPASS", "1")], + &["submodule", "update", "--init"], + ); + // With bypass, the command should succeed (passthrough). + assert!( + out.status.success(), + "bypassed submodule update must succeed; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + // Discriminating: old behavior (missing audit) would not create the + // events file. The shim writes to $AGENTIC_GIT_HOME/fleet_events.jsonl. + let events_path = home.join("fleet_events.jsonl"); + assert!( + events_path.exists(), + "fleet_events.jsonl must exist after bypass write; path={events_path:?}" + ); + let content = std::fs::read_to_string(&events_path).unwrap(); + assert!( + content.contains("submodule"), + "bypass audit event must mention submodule; events={content}" + ); + cleanup(&root); +} + +#[test] +fn bypass_submodule_read_no_audit() { + let root = tempdir("bypass-no-audit"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + std::fs::create_dir_all(home.join("runtime").join("test-agent")).unwrap(); + + let out = run_shim( + &parent, + &home, + "test-agent", + &real_git, + &[("AGENTIC_GIT_BYPASS", "1")], + &["submodule", "status"], + ); + assert!( + out.status.success(), + "bypassed submodule status must succeed; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + // Read operations should NOT emit a bypass audit event. + let events_path = home.join("fleet_events.jsonl"); + if events_path.exists() { + let content = std::fs::read_to_string(&events_path).unwrap_or_default(); + let has_submodule_audit = content + .lines() + .any(|l| l.contains("\"git_event\"") && l.contains("submodule")); + assert!( + !has_submodule_audit, + "bypass submodule READ must NOT emit audit; events={content}" + ); + } + cleanup(&root); +} + +// ── Foreign-cwd routing ─────────────────────────────────────────────── + +#[test] +fn foreign_cwd_submodule_read_routes_to_worktree() { + let root = tempdir("foreign-read"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + let wt = worktree_of(&root, &real_git, &parent, "fix/test"); + write_binding(&home, "test-agent", "fix/test", &wt); + + // Create a foreign repo (different git object store). + let foreign = root.join("foreign"); + std::fs::create_dir_all(&foreign).unwrap(); + init_repo(&real_git, &foreign); + + // Run from foreign cwd — read must route to bound worktree (ChdirPass). + // The worktree has a submodule at vendor/sub; the foreign repo does not. + let out = run_shim( + &foreign, + &home, + "test-agent", + &real_git, + &[], + &["submodule", "status"], + ); + assert!( + out.status.success(), + "foreign-cwd submodule read must route to worktree (ChdirPass); stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + // Discriminating: if the shim ran in the foreign repo (old Passthrough + // behavior), stdout would be empty (no submodules there). ChdirPass to + // the bound worktree must show the worktree's registered submodule. + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("vendor/sub"), + "foreign-cwd read must show bound worktree's submodule (proves ChdirPass); stdout={stdout}" + ); + cleanup(&root); +} + +#[test] +fn foreign_cwd_submodule_write_denied() { + let root = tempdir("foreign-write"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + let wt = worktree_of(&root, &real_git, &parent, "fix/test"); + write_binding(&home, "test-agent", "fix/test", &wt); + + // Create a foreign repo (different git object store). + let foreign = root.join("foreign"); + std::fs::create_dir_all(&foreign).unwrap(); + init_repo(&real_git, &foreign); + + // Run from foreign cwd — write must be denied (not passthrough). + let out = run_shim( + &foreign, + &home, + "test-agent", + &real_git, + &[], + &["submodule", "update"], + ); + assert!( + !out.status.success(), + "foreign-cwd submodule write must be denied" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("denied") || stderr.contains("foreign"), + "deny message must mention foreign/denied; stderr={stderr}" + ); + cleanup(&root); +} + +// ── Snapshot on submodule write ───────────────────────────────────────── + +#[test] +fn bound_submodule_write_creates_snapshot() { + let root = tempdir("snap-write"); + let real_git = resolve_real_git(); + let (parent, _sub) = setup_parent_with_submodule(&root, &real_git); + let home = root.join("home"); + let wt = worktree_of(&root, &real_git, &parent, "fix/test"); + write_binding(&home, "test-agent", "fix/test", &wt); + + // Dirty the worktree so the snapshot layer has content to capture. + // Leave a file staged (not committed) — a clean tree skips snapshotting. + std::fs::write(wt.join("dirty.txt"), "dirty\n").unwrap(); + setup_git(&real_git, &["add", "dirty.txt"], &wt); + + let out = run_shim( + &wt, + &home, + "test-agent", + &real_git, + &[("AGENTIC_GIT_SNAPSHOTS", "1")], + &["submodule", "update", "--init"], + ); + assert!( + out.status.success(), + "bound submodule update must succeed; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + + // Snapshots are stored in the COMMON .git (shared across worktrees). + // Check for a snapshot ref containing 'submodule' in the parent repo. + let refs_out = Command::new(&real_git) + .args([ + "-c", + "protocol.file.allow=always", + "for-each-ref", + "--format=%(refname)", + "refs/agentic-git/snapshots/", + ]) + .current_dir(&wt) + .env("AGENTIC_GIT_BYPASS", "1") + .env("AGEND_GIT_BYPASS", "1") + .output() + .expect("for-each-ref"); + let refs = String::from_utf8_lossy(&refs_out.stdout); + let has_submod_snap = refs.lines().any(|l| l.contains("submodule")); + assert!( + has_submod_snap, + "submodule write should create a snapshot ref containing 'submodule'; refs={}", + refs + ); + cleanup(&root); +}