diff --git a/CHANGELOG.md b/CHANGELOG.md index a061616d7..d263ee393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [Unreleased] + +### Features + +- *(cli)* `$REPO_ROOT` variable expansion in profile path fields: resolves to the git + repository root, auto-detected via `git rev-parse --show-toplevel` with + worktree support. Override via `--repo-root DIR` flag or `NONO_REPO_ROOT` env + var. Left unexpanded (path silently skipped) when not in a git repo. + ## [0.67.1] - 2026-07-06 ### Bug Fixes diff --git a/crates/nono-cli/data/profile-authoring-guide.md b/crates/nono-cli/data/profile-authoring-guide.md index 9e4e887b0..8eedc94ff 100644 --- a/crates/nono-cli/data/profile-authoring-guide.md +++ b/crates/nono-cli/data/profile-authoring-guide.md @@ -1032,6 +1032,7 @@ The following variables are expanded in all path fields (`filesystem.*`, includi |--------------------|------------| | `$HOME` | User's home directory | | `$WORKDIR` | Working directory (from `--workdir` flag or cwd) | +| `$REPO_ROOT` | Git repository root (`--repo-root`, `NONO_REPO_ROOT`, or auto-detected via `git rev-parse`). Left unexpanded when not in a git repo — path is then silently skipped. | | `$TMPDIR` | System temporary directory | | `$UID` | Current user ID | | `$XDG_CONFIG_HOME` | XDG config directory (default: `$HOME/.config`) | @@ -1042,6 +1043,25 @@ The following variables are expanded in all path fields (`filesystem.*`, includi Always use these variables instead of hardcoded absolute paths to keep profiles portable across machines and users. +### Monorepo example + +When running from a nested package directory, use `$REPO_ROOT` to reference +files at the repository root without hardcoding `../` levels: + +```json +{ + "filesystem": { + "read_file": [ + "$REPO_ROOT/AGENTS.md", + "$REPO_ROOT/.cursor/rules/AGENTS.md" + ] + } +} +``` + +Override the detected root with `--repo-root /path/to/repo` or by setting +`NONO_REPO_ROOT=/path/to/repo` in your environment. + ## 7. Platform Predicates Profile entries that list paths, group names, URL origins, or env credentials can be unconditional strings or conditional objects with `when`. diff --git a/crates/nono-cli/src/capability_ext.rs b/crates/nono-cli/src/capability_ext.rs index 58b955fcc..c43c9abf4 100644 --- a/crates/nono-cli/src/capability_ext.rs +++ b/crates/nono-cli/src/capability_ext.rs @@ -13,9 +13,9 @@ use crate::tool_sandbox::dynamic_providers::expand_dynamic_tokens; /// Expand a profile path template: arbitrary `$VAR` tokens from the process /// environment are resolved first, then built-in nono vars (`$HOME`, /// `$WORKDIR`, `$TMPDIR`, `$XDG_*`, etc.) are applied. -fn expand_path(template: &str, workdir: &Path) -> nono::Result { +fn expand_path(template: &str, workdir: &Path, repo_root: Option<&Path>) -> nono::Result { let after_env = policy::expand_env_vars(template); - expand_vars(&after_env, workdir) + expand_vars(&after_env, workdir, repo_root) } use nono::{ AccessMode, CapabilitySet, CapabilitySource, FsCapability, NonoError, Result, SocketScope, @@ -671,11 +671,12 @@ impl CapabilitySetExt for CapabilitySet { // the banner but NOT tracked for rollback snapshots (only User-sourced paths // representing the project workspace are tracked). let fs = &profile.filesystem; + let repo_root = args.repo_root.as_deref(); // Directories with read+write access let allow_expanded = expand_dynamic_tokens(&fs.allow, Some(workdir))?; for path_template in &allow_expanded { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; validate_requested_dir( &path, "Profile", @@ -692,7 +693,7 @@ impl CapabilitySetExt for CapabilitySet { // Read-only filesystem entries (directory or file) let read_expanded = expand_dynamic_tokens(&fs.read, Some(workdir))?; for path_template in &read_expanded { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; let label = format!("Profile path '{}' does not exist, skipping", path_template); let reads_file = std::fs::metadata(&path) @@ -726,7 +727,7 @@ impl CapabilitySetExt for CapabilitySet { // Directories with write-only access let write_expanded = expand_dynamic_tokens(&fs.write, Some(workdir))?; for path_template in &write_expanded { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; validate_requested_dir( &path, "Profile", @@ -743,7 +744,7 @@ impl CapabilitySetExt for CapabilitySet { // Single files with read+write access let allow_file_expanded = expand_dynamic_tokens(&fs.allow_file, Some(workdir))?; for path_template in &allow_file_expanded { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; let label = format!("Profile file '{}' does not exist, skipping", path_template); if let Some(mut cap) = try_new_profile_exact_path( &path, @@ -763,7 +764,7 @@ impl CapabilitySetExt for CapabilitySet { // Single files with read-only access let read_file_expanded = expand_dynamic_tokens(&fs.read_file, Some(workdir))?; for path_template in &read_file_expanded { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; let label = format!("Profile file '{}' does not exist, skipping", path_template); if let Some(mut cap) = try_new_profile_exact_path( &path, @@ -780,7 +781,7 @@ impl CapabilitySetExt for CapabilitySet { // Single files with write-only access let write_file_expanded = expand_dynamic_tokens(&fs.write_file, Some(workdir))?; for path_template in &write_file_expanded { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; let label = format!("Profile file '{}' does not exist, skipping", path_template); if let Some(mut cap) = try_new_profile_exact_path( &path, @@ -802,7 +803,7 @@ impl CapabilitySetExt for CapabilitySet { // implied FsCapability with matching access mode. Source is // marked as Profile so `--dry-run -v` can show provenance. for path_template in &fs.unix_socket { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; validate_requested_file( &path, "Profile", @@ -829,7 +830,7 @@ impl CapabilitySetExt for CapabilitySet { } for path_template in &fs.unix_socket_bind { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; validate_requested_file( &path, "Profile", @@ -870,7 +871,7 @@ impl CapabilitySetExt for CapabilitySet { } for path_template in &fs.unix_socket_dir { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; validate_requested_dir( &path, "Profile", @@ -897,7 +898,7 @@ impl CapabilitySetExt for CapabilitySet { } for path_template in &fs.unix_socket_dir_bind { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; validate_requested_dir( &path, "Profile", @@ -924,7 +925,7 @@ impl CapabilitySetExt for CapabilitySet { } for path_template in &fs.unix_socket_subtree { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; validate_requested_dir( &path, "Profile", @@ -951,7 +952,7 @@ impl CapabilitySetExt for CapabilitySet { } for path_template in &fs.unix_socket_subtree_bind { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; validate_requested_dir( &path, "Profile", @@ -984,7 +985,7 @@ impl CapabilitySetExt for CapabilitySet { // apply the remaining deny and deny-command surfaces. let deny_expanded = expand_dynamic_tokens(&profile.filesystem.deny, Some(workdir))?; for path_template in &deny_expanded { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; let path_str = path.to_str().ok_or_else(|| { NonoError::ConfigParse(format!( "Profile filesystem deny path contains non-UTF-8 bytes: {}", @@ -1070,7 +1071,7 @@ impl CapabilitySetExt for CapabilitySet { expand_dynamic_tokens(&profile.filesystem.bypass_protection, Some(workdir))?; let mut profile_overrides = Vec::with_capacity(bypass_expanded.len()); for path_template in &bypass_expanded { - let path = expand_path(path_template, workdir)?; + let path = expand_path(path_template, workdir, repo_root)?; if path.exists() { profile_overrides.push(path); } else { @@ -3269,4 +3270,67 @@ mod tests { ); assert_eq!(fs_matches[0].access, AccessMode::ReadWrite); } + + #[test] + fn test_from_profile_repo_root_read_file() { + let dir = tempdir().expect("tmpdir"); + // Create the file that $REPO_ROOT will expand to + let agents_file = dir.path().join("AGENTS.md"); + std::fs::write(&agents_file, "# agents").expect("write AGENTS.md"); + + let profile_path = dir.path().join("repo-root-test.json"); + std::fs::write( + &profile_path, + r#"{ + "meta": { "name": "repo-root-test" }, + "filesystem": { "read_file": ["$REPO_ROOT/AGENTS.md"] } + }"#, + ) + .expect("write profile"); + let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile"); + + let workdir = tempdir().expect("workdir"); + let args = SandboxArgs { + repo_root: Some(dir.path().to_path_buf()), + ..SandboxArgs::default() + }; + + let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps"); + let fs_caps: Vec<_> = caps + .fs_capabilities() + .iter() + .filter(|c| c.source == CapabilitySource::Profile) + .collect(); + assert!( + !fs_caps.is_empty(), + "expected at least one profile fs capability for $REPO_ROOT/AGENTS.md" + ); + } + + #[test] + fn test_from_profile_repo_root_unexpanded_skipped() { + // When args.repo_root is None, $REPO_ROOT is left unexpanded and the path + // doesn't exist, so it should be silently skipped (no error, no cap). + let dir = tempdir().expect("tmpdir"); + let profile_path = dir.path().join("repo-root-none.json"); + std::fs::write( + &profile_path, + r#"{ + "meta": { "name": "repo-root-none" }, + "filesystem": { "read_file": ["$REPO_ROOT/AGENTS.md"] } + }"#, + ) + .expect("write profile"); + let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile"); + + let workdir = tempdir().expect("workdir"); + let args = SandboxArgs::default(); // repo_root = None + + // Should not error: unexpanded path doesn't exist → silently skipped + let result = from_profile_locked(&profile, workdir.path(), &args); + assert!( + result.is_ok(), + "from_profile should not error when $REPO_ROOT is unexpanded" + ); + } } diff --git a/crates/nono-cli/src/cli.rs b/crates/nono-cli/src/cli.rs index 2e076f77e..73dfb9745 100644 --- a/crates/nono-cli/src/cli.rs +++ b/crates/nono-cli/src/cli.rs @@ -1166,6 +1166,16 @@ pub struct SandboxArgs { #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")] pub workdir: Option, + /// Repository root for $REPO_ROOT expansion in profiles. + /// Auto-detected via git when unset. Override with NONO_REPO_ROOT env var. + #[arg( + long, + value_name = "DIR", + env = "NONO_REPO_ROOT", + help_heading = "FILESYSTEM" + )] + pub repo_root: Option, + // ── Network ────────────────────────────────────────────────────────── /// Block outbound network access (allowed by default) /// ALIAS(canonical="--block-net", introduced="v0.0.0", remove_by="indefinite", issue="#302") @@ -1767,6 +1777,16 @@ pub struct WrapSandboxArgs { #[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")] pub workdir: Option, + /// Repository root for $REPO_ROOT expansion in profiles. + /// Auto-detected via git when unset. Override with NONO_REPO_ROOT env var. + #[arg( + long, + value_name = "DIR", + env = "NONO_REPO_ROOT", + help_heading = "FILESYSTEM" + )] + pub repo_root: Option, + // ── Network ────────────────────────────────────────────────────────── /// Block outbound network access (allowed by default) /// ALIAS(canonical="--block-net", introduced="v0.0.0", remove_by="indefinite", issue="#302") @@ -1917,6 +1937,7 @@ impl From for SandboxArgs { suppress_save_prompt: args.suppress_save_prompt, allow_cwd: args.allow_cwd, workdir: args.workdir, + repo_root: args.repo_root, block_net: args.block_net, allow_net: false, network_profile: None, @@ -2264,6 +2285,16 @@ pub struct WhyArgs { #[arg(long, value_name = "DIR", help_heading = "CONTEXT")] pub workdir: Option, + /// Repository root for $REPO_ROOT expansion in profiles. + /// Auto-detected via git when unset. Override with NONO_REPO_ROOT env var. + #[arg( + long, + value_name = "DIR", + env = "NONO_REPO_ROOT", + help_heading = "CONTEXT" + )] + pub repo_root: Option, + /// Print help #[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")] pub help: Option, diff --git a/crates/nono-cli/src/command_runtime.rs b/crates/nono-cli/src/command_runtime.rs index ccff96369..2ecc01076 100644 --- a/crates/nono-cli/src/command_runtime.rs +++ b/crates/nono-cli/src/command_runtime.rs @@ -204,7 +204,7 @@ pub(crate) fn run_sandbox(mut run_args: RunArgs, silent: bool) -> Result<()> { .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(|| PathBuf::from(".")); for arg in &loaded.command_args { - let expanded = profile::expand_vars(arg, &workdir)?; + let expanded = profile::expand_vars(arg, &workdir, None)?; cmd_args.push(OsString::from(expanded)); } } diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index 03c84e5a2..b740160bd 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -3750,6 +3750,9 @@ pub(crate) fn is_valid_profile_name(name: &str) -> bool { /// /// Supported variables: /// - $WORKDIR: Working directory (--workdir or cwd) +/// - $REPO_ROOT: Git repository root. Resolved from --repo-root, NONO_REPO_ROOT, +/// or auto-detected via `git rev-parse`. Left unexpanded when unset +/// or not in a git repo (path is then silently skipped). /// - $HOME: User's home directory /// - $XDG_CONFIG_HOME: XDG config directory /// - $NONO_CONFIG: nono config root (`$XDG_CONFIG_HOME/nono`) @@ -3762,7 +3765,7 @@ pub(crate) fn is_valid_profile_name(name: &str) -> bool { /// /// If $HOME cannot be determined and the path uses $HOME or XDG variables, /// the unexpanded variable is left in place (which will cause the path to not exist). -pub fn expand_vars(path: &str, workdir: &Path) -> Result { +pub fn expand_vars(path: &str, workdir: &Path, repo_root: Option<&Path>) -> Result { use crate::config; let home = config::validated_home()?; @@ -3778,6 +3781,14 @@ pub fn expand_vars(path: &str, workdir: &Path) -> Result { let expanded = path.replace("$WORKDIR", &workdir.to_string_lossy()); + // $REPO_ROOT: left unexpanded when None (path won't exist → silently skipped + // by the existing "does not exist, skipping" logic in capability_ext). + let expanded = if let Some(root) = repo_root { + expanded.replace("$REPO_ROOT", &root.to_string_lossy()) + } else { + expanded + }; + // Expand $TMPDIR and $UID let tmpdir = config::validated_tmpdir()?; let uid = nix::unistd::getuid().to_string(); @@ -4127,10 +4138,10 @@ mod tests { let workdir = PathBuf::from("/projects/myapp"); - let expanded = expand_vars("$WORKDIR/src", &workdir).expect("valid env"); + let expanded = expand_vars("$WORKDIR/src", &workdir, None).expect("valid env"); assert_eq!(expanded, PathBuf::from("/projects/myapp/src")); - let expanded = expand_vars("$HOME/.config", &workdir).expect("valid env"); + let expanded = expand_vars("$HOME/.config", &workdir, None).expect("valid env"); assert_eq!(expanded, PathBuf::from("/home/user/.config")); } @@ -4150,12 +4161,12 @@ mod tests { ]); let workdir = PathBuf::from("/projects/myapp"); - let expanded = expand_vars("$XDG_STATE_HOME/history", &workdir).expect("valid env"); + let expanded = expand_vars("$XDG_STATE_HOME/history", &workdir, None).expect("valid env"); assert_eq!(expanded, PathBuf::from("/custom/state/history")); // Fallback when env var is unset _env.remove("XDG_STATE_HOME"); - let expanded = expand_vars("$XDG_STATE_HOME/history", &workdir).expect("valid env"); + let expanded = expand_vars("$XDG_STATE_HOME/history", &workdir, None).expect("valid env"); assert_eq!(expanded, PathBuf::from("/home/user/.local/state/history")); } @@ -4171,11 +4182,11 @@ mod tests { ]); let workdir = PathBuf::from("/projects/myapp"); - let expanded = expand_vars("$XDG_CONFIG_HOME/nono/profiles", &workdir).expect("valid env"); + let expanded = expand_vars("$XDG_CONFIG_HOME/nono/profiles", &workdir, None).expect("valid env"); assert_eq!(expanded, PathBuf::from("/custom/config/nono/profiles")); _env.remove("XDG_CONFIG_HOME"); - let expanded = expand_vars("$XDG_CONFIG_HOME/nono/profiles", &workdir).expect("valid env"); + let expanded = expand_vars("$XDG_CONFIG_HOME/nono/profiles", &workdir, None).expect("valid env"); assert_eq!(expanded, PathBuf::from("/home/user/.config/nono/profiles")); } @@ -4195,7 +4206,7 @@ mod tests { ]); let workdir = PathBuf::from("/projects/myapp"); - let expanded = expand_vars("$NONO_CONFIG/profiles", &workdir).expect("valid env"); + let expanded = expand_vars("$NONO_CONFIG/profiles", &workdir, None).expect("valid env"); assert_eq!( nono::try_canonicalize(&expanded), nono::try_canonicalize(&config_home.join("nono").join("profiles")) @@ -4214,12 +4225,12 @@ mod tests { ]); let workdir = PathBuf::from("/projects/myapp"); - let expanded = expand_vars("$XDG_CACHE_HOME/pip", &workdir).expect("valid env"); + let expanded = expand_vars("$XDG_CACHE_HOME/pip", &workdir, None).expect("valid env"); assert_eq!(expanded, PathBuf::from("/custom/cache/pip")); // Fallback when env var is unset _env.remove("XDG_CACHE_HOME"); - let expanded = expand_vars("$XDG_CACHE_HOME/pip", &workdir).expect("valid env"); + let expanded = expand_vars("$XDG_CACHE_HOME/pip", &workdir, None).expect("valid env"); assert_eq!(expanded, PathBuf::from("/home/user/.cache/pip")); } @@ -4232,13 +4243,13 @@ mod tests { let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_RUNTIME_DIR", "/run/user/1000")]); let workdir = PathBuf::from("/projects/myapp"); - let expanded = expand_vars("$XDG_RUNTIME_DIR/pulse", &workdir).expect("valid env"); + let expanded = expand_vars("$XDG_RUNTIME_DIR/pulse", &workdir, None).expect("valid env"); assert_eq!(expanded, PathBuf::from("/run/user/1000/pulse")); // When unset, $XDG_RUNTIME_DIR has no default per the spec — the // variable should be left unexpanded so the path won't resolve. _env.remove("XDG_RUNTIME_DIR"); - let expanded = expand_vars("$XDG_RUNTIME_DIR/pulse", &workdir).expect("valid env"); + let expanded = expand_vars("$XDG_RUNTIME_DIR/pulse", &workdir, None).expect("valid env"); assert_eq!( expanded, PathBuf::from("$XDG_RUNTIME_DIR/pulse"), @@ -4246,6 +4257,37 @@ mod tests { ); } + #[test] + fn test_expand_vars_repo_root_some() { + let workdir = PathBuf::from("/projects/myapp"); + let repo_root = Path::new("/repo"); + let expanded = + expand_vars("$REPO_ROOT/AGENTS.md", &workdir, Some(repo_root)).expect("valid env"); + assert_eq!(expanded, PathBuf::from("/repo/AGENTS.md")); + } + + #[test] + fn test_expand_vars_repo_root_none() { + let workdir = PathBuf::from("/projects/myapp"); + let expanded = expand_vars("$REPO_ROOT/AGENTS.md", &workdir, None).expect("valid env"); + // When repo_root is None, $REPO_ROOT is left unexpanded so the path + // won't resolve (silently skipped by the capability layer). + assert_eq!(expanded, PathBuf::from("$REPO_ROOT/AGENTS.md")); + } + + #[test] + fn test_expand_vars_repo_root_and_workdir_together() { + let workdir = PathBuf::from("/projects/myapp"); + let repo_root = Path::new("/repos/monorepo"); + let expanded = + expand_vars("$REPO_ROOT/AGENTS.md", &workdir, Some(repo_root)).expect("valid env"); + assert_eq!(expanded, PathBuf::from("/repos/monorepo/AGENTS.md")); + + // $WORKDIR expansion still works alongside $REPO_ROOT + let expanded = expand_vars("$WORKDIR/src", &workdir, Some(repo_root)).expect("valid env"); + assert_eq!(expanded, PathBuf::from("/projects/myapp/src")); + } + #[test] fn test_resolve_user_config_dir_uses_valid_absolute_xdg() { let _guard = match crate::test_env::ENV_LOCK.lock() { diff --git a/crates/nono-cli/src/profile_cmd.rs b/crates/nono-cli/src/profile_cmd.rs index 73e7036cc..2ccfd09bd 100644 --- a/crates/nono-cli/src/profile_cmd.rs +++ b/crates/nono-cli/src/profile_cmd.rs @@ -2824,7 +2824,7 @@ fn resolve_to_manifest( // Helper: expand a path template and convert to string for the manifest let expand = |tmpl: &str| -> Result { - let expanded = profile::expand_vars(tmpl, workdir)?; + let expanded = profile::expand_vars(tmpl, workdir, None)?; Ok(expanded.to_string_lossy().into_owned()) }; @@ -2896,7 +2896,7 @@ fn resolve_to_manifest( .filesystem .bypass_protection .iter() - .filter_map(|tmpl| profile::expand_vars(tmpl, workdir).ok()) + .filter_map(|tmpl| profile::expand_vars(tmpl, workdir, None).ok()) .map(|p| { if p.exists() { p.canonicalize().unwrap_or(p) diff --git a/crates/nono-cli/src/profile_runtime.rs b/crates/nono-cli/src/profile_runtime.rs index 9c9a4c3b2..ce0d3e649 100644 --- a/crates/nono-cli/src/profile_runtime.rs +++ b/crates/nono-cli/src/profile_runtime.rs @@ -404,7 +404,8 @@ fn validate_bundle_relative_path<'a>( fn expand_bypass_protection_path(path: &Path, workdir: &Path) -> PathBuf { let path_str = path.to_string_lossy(); - let expanded = profile::expand_vars(&path_str, workdir).unwrap_or_else(|_| path.to_path_buf()); + let expanded = + profile::expand_vars(&path_str, workdir, None).unwrap_or_else(|_| path.to_path_buf()); if expanded.exists() { expanded.canonicalize().unwrap_or(expanded) } else { @@ -424,7 +425,7 @@ fn collect_bypass_protection_paths( .bypass_protection .iter() .filter_map(|template| { - profile::expand_vars(template, workdir) + profile::expand_vars(template, workdir, None) .ok() .map(|expanded| { if expanded.exists() { @@ -450,7 +451,8 @@ fn collect_bypass_protection_paths( fn expand_ignored_denial_path(path: &Path, workdir: &Path) -> PathBuf { let path_str = path.to_string_lossy(); - let expanded = profile::expand_vars(&path_str, workdir).unwrap_or_else(|_| path.to_path_buf()); + let expanded = + profile::expand_vars(&path_str, workdir, None).unwrap_or_else(|_| path.to_path_buf()); nono::try_canonicalize(&expanded) } @@ -479,7 +481,7 @@ fn expand_profile_set_vars( let Some(value) = env_config.set_vars.get(key) else { continue; }; - let expanded_value = profile::expand_vars(value, workdir)? + let expanded_value = profile::expand_vars(value, workdir, None)? .to_string_lossy() .into_owned(); expanded.push((key.clone(), expanded_value)); @@ -499,7 +501,7 @@ fn collect_ignored_denial_paths( .suppress_save_prompt .iter() .filter_map(|template| { - profile::expand_vars(template, workdir) + profile::expand_vars(template, workdir, None) .ok() .map(|expanded| nono::try_canonicalize(&expanded)) }) @@ -565,7 +567,7 @@ fn precreate_file_json_credential_store_dirs( } fn expanded_credential_store_path(path: &str, workdir: &Path) -> crate::Result { - let expanded = profile::expand_vars(path, workdir)?; + let expanded = profile::expand_vars(path, workdir, None)?; let absolute = if expanded.is_absolute() { expanded } else { diff --git a/crates/nono-cli/src/sandbox_prepare.rs b/crates/nono-cli/src/sandbox_prepare.rs index 059209de2..d389b387c 100644 --- a/crates/nono-cli/src/sandbox_prepare.rs +++ b/crates/nono-cli/src/sandbox_prepare.rs @@ -513,6 +513,50 @@ fn resolved_workdir(args: &SandboxArgs) -> PathBuf { canonical_cwd.unwrap_or_else(|| PathBuf::from(".")) } +/// Resolve the repository root for `$REPO_ROOT` expansion. +/// +/// Resolution order: +/// 1. `--repo-root` flag / `NONO_REPO_ROOT` env var (already in `args.repo_root`) +/// 2. Auto-detect via `git rev-parse --show-toplevel` from `workdir`. +/// This handles all repo layouts correctly: +/// - Normal repos: returns the repo root +/// - Linked worktrees (non-bare): returns the worktree checkout root +/// - Bare repos with worktrees: returns the worktree checkout root +/// 3. Returns `None` if git is unavailable or `workdir` is not inside a repo. +/// +/// The returned path is always absolute and canonicalized. +fn resolved_repo_root(args: &SandboxArgs, workdir: &Path) -> Option { + // 1. Explicit override via --repo-root / NONO_REPO_ROOT + if let Some(ref p) = args.repo_root { + return p.canonicalize().ok().filter(|c| c.is_absolute()); + } + + // 2. Auto-detect via git rev-parse --show-toplevel. + // Clear GIT_DIR / GIT_WORK_TREE so that caller-set overrides don't mislead git. + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(workdir) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let toplevel_str = std::str::from_utf8(&output.stdout).ok()?.trim(); + if toplevel_str.is_empty() { + return None; + } + let toplevel = PathBuf::from(toplevel_str); + + // `git rev-parse --show-toplevel` returns the correct working root + // for all repo layouts (normal repos, linked worktrees, bare repos with + // worktrees). Canonicalize to resolve any symlinks in the path. + toplevel.canonicalize().ok().filter(|c| c.is_absolute()) +} + fn cwd_access_requirement(profile_workdir_access: Option<&WorkdirAccess>) -> Option { if let Some(access) = profile_workdir_access { match access { @@ -568,6 +612,17 @@ pub(crate) fn resolve_detached_cwd_prompt_response( } let workdir = resolved_workdir(args); + // Auto-detect repo root for $REPO_ROOT expansion when not explicitly set. + let effective_args: Option = if args.repo_root.is_none() { + resolved_repo_root(args, &workdir).map(|root| { + let mut a = args.clone(); + a.repo_root = Some(root); + a + }) + } else { + None + }; + let args: &SandboxArgs = effective_args.as_ref().unwrap_or(args); let crate::profile_runtime::PreparedProfile { loaded_profile, workdir_access: profile_workdir_access, @@ -1197,6 +1252,17 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result = if args.repo_root.is_none() { + resolved_repo_root(args, &workdir).map(|root| { + let mut a = args.clone(); + a.repo_root = Some(root); + a + }) + } else { + None + }; + let args: &SandboxArgs = effective_args.as_ref().unwrap_or(args); if let Some(ref config_path) = args.config { let json = std::fs::read_to_string(config_path).map_err(|e| { @@ -1213,14 +1279,14 @@ pub(crate) fn prepare_sandbox(args: &SandboxArgs, silent: bool) -> Result bool { + let init = std::process::Command::new("git") + .args(["init"]) + .current_dir(dir) + .output(); + let Ok(out) = init else { + return false; + }; + if !out.status.success() { + return false; + } + for (k, v) in &[("user.email", "t@t.com"), ("user.name", "T")] { + let _ = std::process::Command::new("git") + .args(["config", k, v]) + .current_dir(dir) + .output(); + } + std::fs::write(dir.join("README"), "hi").expect("write README"); + let add = std::process::Command::new("git") + .args(["add", "."]) + .current_dir(dir) + .output(); + if add.map(|o| !o.status.success()).unwrap_or(true) { + return false; + } + let commit = std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(dir) + .output(); + commit.map(|o| o.status.success()).unwrap_or(false) + } + + // ── 1. Explicit --repo-root: existing path is returned canonicalized ── + #[test] + fn resolved_repo_root_returns_explicit_flag() { + let dir = tempdir().expect("tmpdir"); + let args = SandboxArgs { + repo_root: Some(dir.path().to_path_buf()), + ..SandboxArgs::default() + }; + let workdir = PathBuf::from("/some/workdir"); + let result = resolved_repo_root(&args, &workdir); + assert_eq!(result, dir.path().canonicalize().ok()); + } + + // ── 2. Explicit --repo-root: nonexistent path → None ───────────────── + #[test] + fn resolved_repo_root_explicit_nonexistent_returns_none() { + let args = SandboxArgs { + repo_root: Some(PathBuf::from("/nonexistent/path/that/does/not/exist")), + ..SandboxArgs::default() + }; + let result = resolved_repo_root(&args, &PathBuf::from("/tmp")); + assert!( + result.is_none(), + "nonexistent explicit path should return None" + ); + } + + // ── 3. Explicit --repo-root overrides git auto-detection ───────────── + #[test] + fn resolved_repo_root_explicit_overrides_auto_detection() { + let repo_dir = tempdir().expect("repo tmpdir"); + let override_dir = tempdir().expect("override tmpdir"); + if !git_init_with_commit(repo_dir.path()) { + return; // git not available + } + // Pointing repo_root at override_dir even though we're inside repo_dir + let args = SandboxArgs { + repo_root: Some(override_dir.path().to_path_buf()), + ..SandboxArgs::default() + }; + let result = resolved_repo_root(&args, repo_dir.path()); + assert_eq!( + result, + override_dir.path().canonicalize().ok(), + "explicit --repo-root must override git auto-detection" + ); + } + + // ── 4. Non-git directory → None ─────────────────────────────────────── + #[test] + fn resolved_repo_root_returns_none_outside_git() { + let dir = tempdir().expect("tmpdir"); + let args = SandboxArgs::default(); + let result = resolved_repo_root(&args, dir.path()); + assert!(result.is_none(), "expected None for non-git directory"); + } + + // ── 5. Normal repo: called from the repo root itself ────────────────── + #[test] + fn resolved_repo_root_from_repo_root_itself() { + let dir = tempdir().expect("tmpdir"); + if !git_init_with_commit(dir.path()) { + return; + } + let args = SandboxArgs::default(); + let result = resolved_repo_root(&args, dir.path()); + assert_eq!( + result, + dir.path().canonicalize().ok(), + "calling from the repo root should return the repo root itself" + ); + } + + // ── 6. Normal repo: called from a subdirectory ──────────────────────── + #[test] + fn resolved_repo_root_detects_git_repo() { + let dir = tempdir().expect("tmpdir"); + if !git_init_with_commit(dir.path()) { + return; + } + let subdir = dir.path().join("packages/app"); + std::fs::create_dir_all(&subdir).expect("create subdir"); + let args = SandboxArgs::default(); + let result = resolved_repo_root(&args, &subdir); + assert_eq!( + result, + dir.path().canonicalize().ok(), + "should return the repo root when called from a subdirectory" + ); + } + + // ── 7. Normal repo: called from a deeply-nested subdirectory ────────── + #[test] + fn resolved_repo_root_deeply_nested_subdir() { + let dir = tempdir().expect("tmpdir"); + if !git_init_with_commit(dir.path()) { + return; + } + let deep = dir.path().join("a/b/c/d/e"); + std::fs::create_dir_all(&deep).expect("create deep subdir"); + let args = SandboxArgs::default(); + let result = resolved_repo_root(&args, &deep); + assert_eq!( + result, + dir.path().canonicalize().ok(), + "deep nesting should still resolve to the repo root" + ); + } + + // ── 8. Nested repos: inner repo takes precedence over outer ─────────── + // + // Layout: outer/ (git repo) + // inner/ (separate git repo) + // sub/ (subdirectory inside inner) + // + // Running from outer/inner/sub should yield outer/inner, not outer. + #[test] + fn resolved_repo_root_nested_repo_uses_innermost() { + let outer = tempdir().expect("outer tmpdir"); + if !git_init_with_commit(outer.path()) { + return; + } + let inner = outer.path().join("inner"); + std::fs::create_dir_all(&inner).expect("create inner"); + if !git_init_with_commit(&inner) { + return; + } + let sub = inner.join("sub"); + std::fs::create_dir_all(&sub).expect("create sub"); + let args = SandboxArgs::default(); + let result = resolved_repo_root(&args, &sub); + assert_eq!( + result, + inner.canonicalize().ok(), + "innermost git repo should take precedence over outer repo" + ); + } + + // ── 9. Non-bare repo: linked worktree returns the worktree root ──────── + // + // Layout: main/ (normal git repo, first commit) + // feat/ (linked worktree checked out via `git worktree add`) + // + // Running from feat/ should yield feat/ (the worktree checkout root), + // not main/ (the original repo). + #[test] + fn resolved_repo_root_worktree_finds_main_root() { + let main_dir = tempdir().expect("main tmpdir"); + if !git_init_with_commit(main_dir.path()) { + return; + } + let wt_dir = tempdir().expect("wt tmpdir"); + let wt_path = wt_dir.path().join("feat"); + let wt_add = std::process::Command::new("git") + .args([ + "worktree", + "add", + "-b", + "feat", + wt_path.to_str().expect("path str"), + ]) + .current_dir(main_dir.path()) + .output(); + if wt_add.map(|o| !o.status.success()).unwrap_or(true) { + return; // worktree add failed (old git), skip + } + let args = SandboxArgs::default(); + let result = resolved_repo_root(&args, &wt_path); + assert_eq!( + result, + wt_path.canonicalize().ok(), + "linked worktree: should return the worktree checkout root" + ); + } + + // ── 10. Non-bare repo: linked worktree subdir ───────────────────────── + // + // Same as above but called from a subdirectory *inside* the worktree. + #[test] + fn resolved_repo_root_worktree_subdir() { + let main_dir = tempdir().expect("main tmpdir"); + if !git_init_with_commit(main_dir.path()) { + return; + } + let wt_dir = tempdir().expect("wt tmpdir"); + let wt_path = wt_dir.path().join("feat"); + let wt_add = std::process::Command::new("git") + .args([ + "worktree", + "add", + "-b", + "feat", + wt_path.to_str().expect("path str"), + ]) + .current_dir(main_dir.path()) + .output(); + if wt_add.map(|o| !o.status.success()).unwrap_or(true) { + return; + } + let sub = wt_path.join("apps/service"); + std::fs::create_dir_all(&sub).expect("create wt subdir"); + let args = SandboxArgs::default(); + let result = resolved_repo_root(&args, &sub); + assert_eq!( + result, + wt_path.canonicalize().ok(), + "subdirectory inside a linked worktree should resolve to the worktree root" + ); + } + + // ── 11. Bare repo with worktrees (the squash-bug layout) ────────────── + // + // Layout: bare.git/ (bare repo) + // bare.git/main/ (worktree: main branch) + // bare.git/feat/ (worktree: feature branch) + // + // `git rev-parse --show-toplevel` from bare.git/feat/apps/service + // should return bare.git/feat, not bare.git. + #[test] + fn resolved_repo_root_bare_repo_with_worktree() { + // 1. Create a normal repo, make a commit. + let src = tempdir().expect("src tmpdir"); + if !git_init_with_commit(src.path()) { + return; + } + // 2. Clone it as a bare repo. + let bare = tempdir().expect("bare tmpdir"); + let clone = std::process::Command::new("git") + .args([ + "clone", + "--bare", + src.path().to_str().expect("src path"), + bare.path().to_str().expect("bare path"), + ]) + .output(); + if clone.map(|o| !o.status.success()).unwrap_or(true) { + return; + } + // 3. Add a worktree from the bare repo. + let wt_path = bare.path().join("feat"); + let wt_add = std::process::Command::new("git") + .args(["worktree", "add", wt_path.to_str().expect("wt path")]) + .current_dir(bare.path()) + .output(); + if wt_add.map(|o| !o.status.success()).unwrap_or(true) { + return; + } + // 4. Call from a subdirectory inside the worktree checkout. + let sub = wt_path.join("apps/service"); + std::fs::create_dir_all(&sub).expect("create sub"); + let args = SandboxArgs::default(); + let result = resolved_repo_root(&args, &sub); + assert_eq!( + result, + wt_path.canonicalize().ok(), + "bare repo worktree subdir should resolve to the worktree checkout root" + ); + } + #[cfg(unix)] #[test] fn resolved_workdir_prefers_pwd_symlink_over_getcwd_when_valid() { diff --git a/crates/nono-cli/src/tool-sandbox/platform/macos.rs b/crates/nono-cli/src/tool-sandbox/platform/macos.rs index 617a163a4..1e8dd2f57 100644 --- a/crates/nono-cli/src/tool-sandbox/platform/macos.rs +++ b/crates/nono-cli/src/tool-sandbox/platform/macos.rs @@ -2621,7 +2621,7 @@ fn add_policy_credentials( } fn resolve_policy_path(entry: &str, workdir: &Path, cwd: &Path) -> Result { - let expanded = crate::profile::expand_vars(entry, workdir)?; + let expanded = crate::profile::expand_vars(entry, workdir, None)?; if expanded.is_absolute() { Ok(expanded) } else { diff --git a/crates/nono-cli/src/why_runtime.rs b/crates/nono-cli/src/why_runtime.rs index 12dfa9a21..a20accc1a 100644 --- a/crates/nono-cli/src/why_runtime.rs +++ b/crates/nono-cli/src/why_runtime.rs @@ -135,12 +135,13 @@ pub(crate) fn run_why(args: WhyArgs) -> Result<()> { write_file: args.write_file.clone(), block_net: args.block_net, workdir: args.workdir.clone(), + repo_root: args.repo_root.clone(), ..SandboxArgs::default() }; let mut override_paths = Vec::new(); for tmpl in &profile.filesystem.bypass_protection { - let expanded = profile::expand_vars(tmpl, &workdir)?; + let expanded = profile::expand_vars(tmpl, &workdir, None)?; if expanded.exists() { if let Ok(canonical) = expanded.canonicalize() { override_paths.push(canonical); @@ -176,6 +177,7 @@ pub(crate) fn run_why(args: WhyArgs) -> Result<()> { write_file: args.write_file.clone(), block_net: args.block_net, workdir: args.workdir.clone(), + repo_root: args.repo_root.clone(), ..SandboxArgs::default() }; diff --git a/docs/cli/usage/flags.mdx b/docs/cli/usage/flags.mdx index 371a83c78..54f58b79b 100644 --- a/docs/cli/usage/flags.mdx +++ b/docs/cli/usage/flags.mdx @@ -887,6 +887,14 @@ Working directory for `$WORKDIR` expansion in profiles (defaults to current dire nono run --profile always-further/claude --workdir ./my-project -- claude ``` +#### `--repo-root` + +Explicit repository root for `$REPO_ROOT` expansion in profile paths. When omitted, nono auto-detects the root via `git rev-parse --show-toplevel` from the current directory. Set `NONO_REPO_ROOT` as an environment variable alternative. Paths containing `$REPO_ROOT` are silently skipped when the root cannot be determined. + +```bash +nono run --profile always-further/claude --repo-root /path/to/repo -- claude +``` + #### `--allow-cwd` Allow access to the current working directory without prompting. By default, nono prompts interactively for CWD sharing. The access level is determined by the profile's `[workdir]` config or defaults to read-only.