Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.63.0] - 2026-06-15

### Bug Fixes
Expand Down
20 changes: 20 additions & 0 deletions crates/nono-cli/data/profile-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,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`) |
Expand All @@ -549,6 +550,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`.
Expand Down
92 changes: 78 additions & 14 deletions crates/nono-cli/src/capability_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,10 +650,11 @@ 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
for path_template in &fs.allow {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(path_template, workdir, repo_root)?;
validate_requested_dir(
&path,
"Profile",
Expand All @@ -669,7 +670,7 @@ impl CapabilitySetExt for CapabilitySet {

// Read-only filesystem entries (directory or file)
for path_template in &fs.read {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(path_template, workdir, repo_root)?;
let label = format!("Profile path '{}' does not exist, skipping", path_template);

let reads_file = std::fs::metadata(&path)
Expand Down Expand Up @@ -702,7 +703,7 @@ impl CapabilitySetExt for CapabilitySet {

// Directories with write-only access
for path_template in &fs.write {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(path_template, workdir, repo_root)?;
validate_requested_dir(
&path,
"Profile",
Expand All @@ -718,7 +719,7 @@ impl CapabilitySetExt for CapabilitySet {

// Single files with read+write access
for path_template in &fs.allow_file {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(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,
Expand All @@ -737,7 +738,7 @@ impl CapabilitySetExt for CapabilitySet {

// Single files with read-only access
for path_template in &fs.read_file {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(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,
Expand All @@ -753,7 +754,7 @@ impl CapabilitySetExt for CapabilitySet {

// Single files with write-only access
for path_template in &fs.write_file {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(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,
Expand All @@ -775,7 +776,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_vars(path_template, workdir)?;
let path = expand_vars(path_template, workdir, repo_root)?;
validate_requested_file(
&path,
"Profile",
Expand All @@ -802,7 +803,7 @@ impl CapabilitySetExt for CapabilitySet {
}

for path_template in &fs.unix_socket_bind {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(path_template, workdir, repo_root)?;
validate_requested_file(
&path,
"Profile",
Expand Down Expand Up @@ -843,7 +844,7 @@ impl CapabilitySetExt for CapabilitySet {
}

for path_template in &fs.unix_socket_dir {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(path_template, workdir, repo_root)?;
validate_requested_dir(
&path,
"Profile",
Expand All @@ -870,7 +871,7 @@ impl CapabilitySetExt for CapabilitySet {
}

for path_template in &fs.unix_socket_dir_bind {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(path_template, workdir, repo_root)?;
validate_requested_dir(
&path,
"Profile",
Expand All @@ -897,7 +898,7 @@ impl CapabilitySetExt for CapabilitySet {
}

for path_template in &fs.unix_socket_subtree {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(path_template, workdir, repo_root)?;
validate_requested_dir(
&path,
"Profile",
Expand All @@ -924,7 +925,7 @@ impl CapabilitySetExt for CapabilitySet {
}

for path_template in &fs.unix_socket_subtree_bind {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(path_template, workdir, repo_root)?;
validate_requested_dir(
&path,
"Profile",
Expand Down Expand Up @@ -956,7 +957,7 @@ impl CapabilitySetExt for CapabilitySet {
// allow/read/write entries are already applied above — these branches
// apply the remaining deny and deny-command surfaces.
for path_template in &profile.filesystem.deny {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(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: {}",
Expand Down Expand Up @@ -1048,7 +1049,7 @@ impl CapabilitySetExt for CapabilitySet {
// while keeping the security posture unchanged.
let mut profile_overrides = Vec::with_capacity(profile.filesystem.bypass_protection.len());
for path_template in &profile.filesystem.bypass_protection {
let path = expand_vars(path_template, workdir)?;
let path = expand_vars(path_template, workdir, repo_root)?;
if path.exists() {
profile_overrides.push(path);
} else {
Expand Down Expand Up @@ -3139,4 +3140,67 @@ mod tests {
assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
assert_eq!(socks[0].scope, SocketScope::DirSubtree);
}

#[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"
);
}
}
31 changes: 31 additions & 0 deletions crates/nono-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,16 @@ pub struct SandboxArgs {
#[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
pub workdir: Option<PathBuf>,

/// 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<PathBuf>,

// ── Network ──────────────────────────────────────────────────────────
/// Block outbound network access (allowed by default)
/// ALIAS(canonical="--block-net", introduced="v0.0.0", remove_by="indefinite", issue="#302")
Expand Down Expand Up @@ -1479,6 +1489,16 @@ pub struct WrapSandboxArgs {
#[arg(long, value_name = "DIR", help_heading = "FILESYSTEM")]
pub workdir: Option<PathBuf>,

/// 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<PathBuf>,

// ── Network ──────────────────────────────────────────────────────────
/// Block outbound network access (allowed by default)
/// ALIAS(canonical="--block-net", introduced="v0.0.0", remove_by="indefinite", issue="#302")
Expand Down Expand Up @@ -1616,6 +1636,7 @@ impl From<WrapSandboxArgs> 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,
Expand Down Expand Up @@ -1931,6 +1952,16 @@ pub struct WhyArgs {
#[arg(long, value_name = "DIR", help_heading = "CONTEXT")]
pub workdir: Option<PathBuf>,

/// 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<PathBuf>,

/// Print help
#[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")]
pub help: Option<bool>,
Expand Down
2 changes: 1 addition & 1 deletion crates/nono-cli/src/command_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,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));
}
}
Expand Down
Loading