Skip to content

feat(cli): $REPO_ROOT variable expansion in profile paths#1041

Open
amitds1997 wants to merge 4 commits into
always-further:mainfrom
amitds1997:feat/add-ancestor-read
Open

feat(cli): $REPO_ROOT variable expansion in profile paths#1041
amitds1997 wants to merge 4 commits into
always-further:mainfrom
amitds1997:feat/add-ancestor-read

Conversation

@amitds1997

@amitds1997 amitds1997 commented May 28, 2026

Copy link
Copy Markdown

Linked Issue

Closes #1036

Summary

Adds support for $REPO_ROOT variable expansion in all profile path fields (filesystem.*, unix_socket fields, etc.), enabling portable profiles for monorepos and workspaces.

The repository root is resolved via this priority:

  1. --repo-root flag or NONO_REPO_ROOT environment variable (explicit override)
  2. Auto-detection via git rev-parse --show-toplevel from the current workdir
  3. Left unexpanded when not in a git repo (path silently skipped)

This allows profiles to reference files at the repository root without hardcoding ../ levels, especially useful when running from nested package directories in monorepos.

Resolution Features

  • Normal git repositories
  • Linked worktrees (non-bare and bare-with-worktrees layouts)
  • Nested repositories (innermost takes precedence)
  • Non-git directories (graceful no-op)

Changes

  • Add --repo-root flag and NONO_REPO_ROOT env var to SandboxArgs, WrapSandboxArgs, WhyArgs
  • Implement resolved_repo_root() with auto-detection and explicit override logic
  • Update expand_vars() to accept optional repo_root parameter
  • Update all expand_vars() call sites across the codebase
  • Update profile authoring guide with $REPO_ROOT documentation and monorepo example
  • Add 11 test cases covering explicit flags, auto-detection, worktrees, nested repos
  • Add 2 integration tests in capability_ext for profile-level expansion

Backward Compatibility

Profiles without $REPO_ROOT are completely unaffected.

Test Plan

  • Added 13 comprehensive test cases covering explicit flags, auto-detection, nested repos, linked worktrees, and bare repos with worktrees
  • All scenarios verified to work correctly

Checklist

  • An issue exists and is linked above
  • All commits are signed-off, using DCO
  • All new code follows the project's coding standards and is covered by tests
  • Public-facing changes are paired with documentation updates
  • Release note has been added to CHANGELOG.md if needed

@github-actions

github-actions Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

PR Review Summary

Size

Metric Value
Lines added +574
Lines removed -34
Total changed 608
Classification Large (> 300 lines)

Affected crates

  • crates/nono-cli — CLI changes. Verify argument parsing, flag documentation, and UX behaviour across supported platforms.

Blast radius — Moderate

This PR touches: source code,documentation


Updated automatically on each push to this PR.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces filesystem.discover_read to support ancestor-only read discovery by walking upward from a configured root directory to an optional inclusive ceiling. It updates the JSON schema, authoring guides, and documentation, integrates the discovered paths into the pre-exec trust scan, and adds comprehensive unit tests. The review feedback suggests optimizing the path discovery logic by canonicalizing the root and ceiling paths upfront, which avoids redundant filesystem I/O inside the ancestor traversal loop.

Comment thread crates/nono-cli/src/capability_ext.rs Outdated
Comment on lines +540 to +602
fn discover_read_ancestor_entries(
rule: &FilesystemAncestorDiscovery,
workdir: &Path,
) -> Result<Vec<(PathBuf, FilesystemAncestorEntryKind)>> {
let root = expand_vars(&rule.root, workdir)?;
if !root.exists() {
return Ok(Vec::new());
}
if !root.is_dir() {
return Err(NonoError::ConfigParse(format!(
"filesystem.discover_read root '{}' resolved to '{}' which is not a directory",
rule.root,
root.display()
)));
}

let ceiling = rule
.ceiling
.as_ref()
.map(|value| expand_vars(value, workdir))
.transpose()?;
if let Some(ref ceiling) = ceiling
&& !root.starts_with(ceiling)
{
return Err(NonoError::ConfigParse(format!(
"filesystem.discover_read ceiling '{}' resolved to '{}' which is not an ancestor of root '{}'",
rule.ceiling.as_deref().unwrap_or_default(),
ceiling.display(),
root.display()
)));
}

for entry in &rule.entries {
validate_discover_read_entry_path(&entry.path)?;
}

let mut discovered = BTreeSet::new();
let mut current = Some(root.as_path());
while let Some(ancestor) = current {
for entry in &rule.entries {
let candidate = ancestor.join(&entry.path);
let matched = match entry.kind {
FilesystemAncestorEntryKind::File => candidate.is_file(),
FilesystemAncestorEntryKind::Dir => candidate.is_dir(),
};
if matched {
discovered.insert((candidate, entry.kind));
}
}

if ceiling.as_ref().is_some_and(|limit| {
match (ancestor.canonicalize(), limit.canonicalize()) {
(Ok(a), Ok(c)) => a == c,
_ => ancestor == limit,
}
}) {
break;
}
current = ancestor.parent();
}

Ok(discovered.into_iter().collect())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Avoid performing filesystem I/O (such as path canonicalization) inside loops to prevent performance issues or hangs if the filesystem is slow or unresponsive. Pre-compute or pre-canonicalize paths before entering the loop.

In discover_read_ancestor_entries, ancestor.canonicalize() and limit.canonicalize() are called inside the while loop on every iteration. Since limit (the ceiling) is constant throughout the loop, and any parent of a canonicalized path is also canonicalized, we can canonicalize both root and ceiling upfront before entering the loop. This allows us to perform a simple, fast ancestor == limit comparison inside the loop without any filesystem I/O.

fn discover_read_ancestor_entries(
    rule: &FilesystemAncestorDiscovery,
    workdir: &Path,
) -> Result<Vec<(PathBuf, FilesystemAncestorEntryKind)>> {
    let root = expand_vars(&rule.root, workdir)?;
    if !root.exists() {
        return Ok(Vec::new());
    }
    if !root.is_dir() {
        return Err(NonoError::ConfigParse(format!(
            "filesystem.discover_read root '{}' resolved to '{}' which is not a directory",
            rule.root,
            root.display()
        )));
    }
    let root = root.canonicalize().map_err(|source| {
        NonoError::PathCanonicalization {
            path: root.clone(),
            source,
        }
    })?;

    let ceiling = rule
        .ceiling
        .as_ref()
        .map(|value| expand_vars(value, workdir))
        .transpose()?;
    let ceiling = if let Some(c) = ceiling {
        let c_canon = c.canonicalize().map_err(|source| {
            NonoError::PathCanonicalization {
                path: c.clone(),
                source,
            }
        })?;
        if !root.starts_with(&c_canon) {
            return Err(NonoError::ConfigParse(format!(
                "filesystem.discover_read ceiling '{}' resolved to '{}' which is not an ancestor of root '{}'",
                rule.ceiling.as_deref().unwrap_or_default(),
                c_canon.display(),
                root.display()
            )));
        }
        Some(c_canon)
    } else {
        None
    };

    for entry in &rule.entries {
        validate_discover_read_entry_path(&entry.path)?;
    }

    let mut discovered = BTreeSet::new();
    let mut current = Some(root.as_path());
    while let Some(ancestor) = current {
        for entry in &rule.entries {
            let candidate = ancestor.join(&entry.path);
            let matched = match entry.kind {
                FilesystemAncestorEntryKind::File => candidate.is_file(),
                FilesystemAncestorEntryKind::Dir => candidate.is_dir(),
            };
            if matched {
                discovered.insert((candidate, entry.kind));
            }
        }

        if ceiling.as_ref().is_some_and(|limit| ancestor == limit) {
            break;
        }
        current = ancestor.parent();
    }

    Ok(discovered.into_iter().collect())
}
References
  1. Avoid performing filesystem I/O (such as path canonicalization) inside display or diagnostic rendering loops to prevent performance issues or hangs if the filesystem is slow or unresponsive. Pre-compute or pre-canonicalize paths before entering the loop.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f6ae679

amitds1997 added a commit to amitds1997/nono that referenced this pull request May 28, 2026
Canonicalize root and ceiling once before the ancestor loop so ceiling
checks do not call canonicalize() on every iteration. Adjust unit tests
to expect canonical discovered paths.

Addresses PR always-further#1041 review feedback.

Signed-off-by: Amit Singh <amitds1997@gmail.com>
@panga

panga commented May 28, 2026

Copy link
Copy Markdown
Contributor

I think this solution adds too much complexity. What about a simple feature for ../ expansion during profile parsing?

e.g: $WORKDIR/../AGENTS.md → /Users/user/AGENTS.md

@amitds1997

amitds1997 commented May 28, 2026

Copy link
Copy Markdown
Author

I think this solution adds too much complexity. What about a simple feature for ../ expansion during profile parsing?

That's already supported? Could you explain this a bit more in case I'm misunderstanding something. For example, I use this right now:

"read_file": [
      "$HOME/.CFUserTextEncoding",
      "$WORKDIR/../AGENTS.md",
      "$WORKDIR/../../AGENTS.md"
    ],

and that resolves correctly. I just want to avoid having to hard-code each nesting level. Open to restructure this PR with better approaches

@lukehinds

lukehinds commented May 31, 2026

Copy link
Copy Markdown
Contributor

Hi @amitds1997, I would remove the trust-scan integration from this PR and keep it focused on filesystem grants. Folding ancestor discovery into trust scanning has quite a few extra edge cases, for example which trust-policy.json should apply if policies exist at different ancestor levels.

Also, do you need directory grants for this use case? kind: "dir" makes the security impact much wider.

kind: "file" is declarative and bounded:

{ "path": "AGENTS.md", "kind": "file" }

I’m comfortable with this shape because the user is explicitly saying that ancestor files with that relative path are ok for the sandbox to read.

With discovered dirs:

{
  "root": "$WORKDIR",
  "ceiling": "$HOME",
  "entries": [{ "path": "somedir", "kind": "dir" }]
}

the resulting recursive directory grant is conditional on launch location and filesystem layout. Depending on $WORKDIR, this could grant any of:

$HOME/work/mono/somedir
$HOME/work/other/somedir
$HOME/somedir

That may still be a valid feature, but it is a broader dynamic recursive grant than the original ancestor-file need. I’d prefer this PR be file-only unless there is a specific directory use case we want to review separately.

Possible alternative

I thought a bit more about this, and a simpler alternative may be to expose a repo-root variable instead of adding ancestor discovery.

For example, from a nested workdir , --show-toplevel in rev-parse always resolves to the monorepo root.

git rev-parse --show-toplevel
# /Users/lukehinds/dev/nono

nono could expose that as something like $REPO_ROOT, either from an env var:

NONO_REPO_ROOT=$(git rev-parse --show-toplevel)

or via a flag:

nono run --repo-root "$(git rev-parse --show-toplevel)" --allow-cwd -- ...

Then profiles can stay explicit and use existing read_file behavior:

{
  "filesystem": {
    "read_file": [
      "$REPO_ROOT/AGENTS.md",
      "$REPO_ROOT/.cursor/rules/AGENTS.md"
    ]
  }
}

This avoids hard-coding $WORKDIR/../... nesting levels without introducing dynamic ancestor discovery or recursive discovered directory grants. It also keeps the final granted paths explicit in the profile.

@amitds1997

amitds1997 commented May 31, 2026

Copy link
Copy Markdown
Author

@lukehinds Agree on both aspects, do you want me to limit this PR to just the file approach or rework it with $REPO_ROOT approach, in mind? Since both of them would work for my use case. If taking the git approach, I would like to support both normal and worktree-based git repos, if possible.

@lukehinds

Copy link
Copy Markdown
Contributor

I think $REPO_ROOT is a good approach, it means someone never need worry about setting ceilings etc

@panga

panga commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

I want to note that top-level resolution does not work for Git worktrees. In this case, we should also allow listing .git via git rev-parse --git-common-dir. For more complete, first-class Git support, we may also need to add GIT_ROOT expansion.

@amitds1997

Copy link
Copy Markdown
Author

Thanks both, I'll adjust the issue and the PR accordingly. Might take me a couple of days to get to it, though. I'll convert this back to draft until then.

@amitds1997 amitds1997 marked this pull request as draft June 1, 2026 12:41
Adds support for $REPO_ROOT variable expansion in all profile path fields
(filesystem.*, unix_socket fields, etc.), enabling portable profiles for
monorepos and workspaces.

The repository root is resolved via this priority:
  1. --repo-root flag or NONO_REPO_ROOT environment variable (explicit override)
  2. Auto-detection via `git rev-parse --show-toplevel` from the current workdir
  3. Left unexpanded when not in a git repo (path silently skipped)

Resolution correctly handles:
  - Normal git repositories
  - Linked worktrees (non-bare and bare-with-worktrees layouts)
  - Nested repositories (innermost takes precedence)
  - Non-git directories (graceful no-op)

Changes:
  - Add --repo-root flag and NONO_REPO_ROOT env var to SandboxArgs, WrapSandboxArgs, WhyArgs
  - Implement resolved_repo_root() with auto-detection and explicit override logic
  - Update expand_vars() to accept optional repo_root parameter
  - Update all expand_vars() call sites in capability_ext, command_runtime, profile_cmd, etc.
  - Update profile authoring guide with $REPO_ROOT docs and monorepo example
  - Add 11 test cases covering explicit flags, auto-detection, worktrees, nested repos
  - Add 2 integration tests in capability_ext for profile-level $REPO_ROOT expansion

Backward compatible: profiles without $REPO_ROOT are unaffected.

Signed-off-by: Amit Singh <amitds1997@gmail.com>
@amitds1997 amitds1997 force-pushed the feat/add-ancestor-read branch from 29fb5ef to 212f5ff Compare June 12, 2026 02:20
@amitds1997 amitds1997 changed the title feat(cli): add filesystem.discover_read ancestor grants feat(cli): $REPO_ROOT variable expansion in profile paths Jun 12, 2026
@amitds1997 amitds1997 marked this pull request as ready for review June 12, 2026 02:28
@amitds1997

Copy link
Copy Markdown
Author

Took me longer than expected to get to this, but it's now ready for review. I've updated both the issue and the PR that the goal is now to handle REPO_ROOT properly.

I have been using it for the past day and I have not run into any git or gh errors.

  "filesystem": {
    "read": [
      "$HOME/.config/gh", // GitHub CLI config directory
      "$REPO_ROOT",
      "$REPO_ROOT/../.git",
      "$REPO_ROOT/../.bare",
    ]
  },
  "env_credentials": {
    "env://GITHUB_TOKEN": "GITHUB_TOKEN" // For GitHub CLI
  },

I use a bare-repo style worktrees so that's why the extra .git and .bare, but I have added tests to cover the other possible git patterns.

…ag parity

- Reduce  doc comment continuation indent in profile/mod.rs to
  satisfy clippy::doc_overindented_list_items lint
- Run cargo fmt to fix long #[arg(...)] attribute lines in cli.rs,
  and expand_vars call lines in profile_runtime.rs and capability_ext.rs
- Add --repo-root flag entry to docs/cli/usage/flags.mdx to satisfy
  the RunArgs docs parity check

Signed-off-by: Amit Singh <amitds1997@gmail.com>
…ELOG conflict

Keep [Unreleased] $REPO_ROOT entry from this branch on top of the
[0.63.0] release notes brought in from upstream.

Signed-off-by: Amit Singh <amitds1997@gmail.com>
Upstream added expand_profile_set_vars in the merge, which called
expand_vars with 2 args. Our branch extended the signature with a
third repo_root argument. Pass None since env var value expansion
does not require $REPO_ROOT resolution.

Signed-off-by: Amit Singh <amitds1997@gmail.com>
@amitds1997 amitds1997 force-pushed the feat/add-ancestor-read branch from 1b884e6 to 1f449f1 Compare June 16, 2026 04:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support $REPO_ROOT variable expansion in profile paths

3 participants