use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use clap::ValueEnum;
use mentra::agent::{
AgentConfig, ContextCompactionConfig, MemoryConfig, TaskConfig, TeamConfig, ToolProfile,
WorkspaceConfig,
};
use mentra::provider::{BuiltinProvider, ModelInfo, ModelSelector};
use mentra::runtime::{RuntimeError, RuntimePolicy, SqliteRuntimeStore};
use mentra::tool::{ToolAuthorizationDecision, ToolAuthorizationRequest, ToolAuthorizer};
use mentra::{ContentBlock, Runtime};
use tempfile::TempDir;
use crate::domain::dependency_query::{
ResolvedInspectionSource, load_manifest_for_inspection, resolve_inspection_target,
};
use crate::git::{ensure_git_dependency, normalize_alias_from_url, normalize_git_url};
use crate::manifest::{
DependencyComponent, DependencyKind, DependencySpec, LoadedManifest, PackageRole,
RequestedGitRef as ManifestRequestedGitRef, normalize_dependency_alias,
};
use crate::report::Reporter;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ReviewProvider {
#[value(name = "openai")]
Openai,
#[value(name = "anthropic")]
Anthropic,
#[value(name = "gemini")]
Gemini,
}
impl ReviewProvider {
fn builtin(self) -> BuiltinProvider {
match self {
Self::Openai => BuiltinProvider::OpenAI,
Self::Anthropic => BuiltinProvider::Anthropic,
Self::Gemini => BuiltinProvider::Gemini,
}
}
fn api_key_env(self) -> &'static str {
match self {
Self::Openai => "OPENAI_API_KEY",
Self::Anthropic => "ANTHROPIC_API_KEY",
Self::Gemini => "GEMINI_API_KEY",
}
}
fn display_name(self) -> &'static str {
match self {
Self::Openai => "OpenAI",
Self::Anthropic => "Anthropic",
Self::Gemini => "Gemini",
}
}
}
impl std::fmt::Display for ReviewProvider {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.display_name())
}
}
#[derive(Debug, Clone)]
pub struct ReviewSummary {
pub package_count: usize,
pub provider: ReviewProvider,
}
#[derive(Debug, Clone)]
struct ReviewScope {
target_index: usize,
packages: Vec<ReviewPackage>,
}
#[derive(Debug, Clone)]
struct ReviewPackage {
aliases: BTreeSet<String>,
root: PathBuf,
manifest: LoadedManifest,
source: ReviewSource,
selected_components: Option<Vec<DependencyComponent>>,
dependencies: Vec<ReviewDependency>,
}
#[derive(Debug, Clone)]
struct ReviewDependency {
alias: String,
kind: DependencyKind,
package_index: usize,
}
#[derive(Debug, Clone)]
enum ReviewSource {
Path {
path: PathBuf,
},
Git {
url: String,
tag: Option<String>,
branch: Option<String>,
rev: String,
},
}
#[derive(Debug, Clone)]
struct ResolvedReviewTarget {
alias: String,
manifest: LoadedManifest,
source: ReviewSource,
selected_components: Option<Vec<DependencyComponent>>,
role: PackageRole,
}
#[derive(Default)]
struct ReviewCollector {
packages: Vec<ReviewPackage>,
index_by_root: BTreeMap<PathBuf, usize>,
active_roots: BTreeSet<PathBuf>,
}
struct ReviewSessionPaths {
_temp: TempDir,
store_path: PathBuf,
team_dir: PathBuf,
tasks_dir: PathBuf,
transcript_dir: PathBuf,
}
struct ReadOnlyReviewAuthorizer;
pub struct ReviewRequest<'a> {
pub package: &'a str,
pub tag: Option<&'a str>,
pub branch: Option<&'a str>,
pub provider: ReviewProvider,
pub model: Option<&'a str>,
}
pub fn review_package_in_dir(
cwd: &Path,
cache_root: &Path,
request: ReviewRequest<'_>,
reporter: &Reporter,
) -> Result<ReviewSummary> {
if !cfg!(any(target_os = "macos", target_os = "linux")) {
bail!("`nodus review` is currently supported only on macOS and Linux");
}
reporter.status(
"Collecting",
format!("package graph for {}", request.package),
)?;
let scope = collect_review_scope(
cwd,
cache_root,
request.package,
request.tag,
request.branch,
reporter,
)?;
reporter.status(
"Preparing",
format!(
"{} package{} in review scope",
scope.packages.len(),
if scope.packages.len() == 1 { "" } else { "s" }
),
)?;
let package_count = scope.packages.len();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("failed to start review runtime")?;
runtime.block_on(run_review(scope, request.provider, request.model, reporter))?;
Ok(ReviewSummary {
package_count,
provider: request.provider,
})
}
async fn run_review(
scope: ReviewScope,
provider: ReviewProvider,
model: Option<&str>,
reporter: &Reporter,
) -> Result<()> {
let session = ReviewSessionPaths::new().context("failed to prepare review session storage")?;
let api_key = std::env::var(provider.api_key_env()).with_context(|| {
format!(
"{} is required to run `nodus review --provider {}`",
provider.api_key_env(),
provider.to_string().to_ascii_lowercase()
)
})?;
let runtime = build_review_runtime(&scope, &session, provider, api_key)?;
reporter.status("Resolving", format!("{provider} model"))?;
let model = resolve_review_model(&runtime, provider, model).await?;
reporter.status(
"Reviewing",
format!("{} with {}", scope.target_alias(), model.id),
)?;
let review = execute_review_with_runtime(&runtime, model, &scope, &session).await?;
reporter.line(review)?;
Ok(())
}
fn build_review_runtime(
scope: &ReviewScope,
session: &ReviewSessionPaths,
provider: ReviewProvider,
api_key: String,
) -> Result<Runtime> {
let mut policy = RuntimePolicy::default();
for root in scope.allowed_read_roots() {
policy = policy.with_allowed_read_root(root);
}
Runtime::builder()
.with_store(SqliteRuntimeStore::new(session.store_path.clone()))
.with_provider(provider.builtin(), api_key)
.with_policy(policy)
.with_tool_authorizer(ReadOnlyReviewAuthorizer)
.with_runtime_identifier(format!(
"nodus-review-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
.build()
.map_err(Into::into)
}
async fn resolve_review_model(
runtime: &Runtime,
provider: ReviewProvider,
model: Option<&str>,
) -> Result<ModelInfo> {
let selector = model
.map(|value| ModelSelector::Id(value.to_string()))
.or_else(|| std::env::var("MENTRA_MODEL").ok().map(ModelSelector::Id))
.unwrap_or(ModelSelector::NewestAvailable);
runtime
.resolve_model(provider.builtin(), selector)
.await
.map_err(Into::into)
}
async fn execute_review_with_runtime(
runtime: &Runtime,
model: ModelInfo,
scope: &ReviewScope,
session: &ReviewSessionPaths,
) -> Result<String> {
let target = scope.target();
let config = AgentConfig {
system: Some(REVIEW_SYSTEM_PROMPT.to_string()),
tool_profile: ToolProfile::only(["files"]),
workspace: WorkspaceConfig {
base_dir: target.root.clone(),
auto_route_shell: false,
},
memory: MemoryConfig {
auto_recall_enabled: false,
write_tools_enabled: false,
..MemoryConfig::default()
},
team: TeamConfig {
team_dir: session.team_dir.clone(),
..TeamConfig::default()
},
task: TaskConfig {
tasks_dir: session.tasks_dir.clone(),
..TaskConfig::default()
},
context_compaction: ContextCompactionConfig {
transcript_dir: session.transcript_dir.clone(),
..ContextCompactionConfig::default()
},
..AgentConfig::default()
};
let mut agent = runtime
.spawn_with_config("Nodus Review Agent", model, config)
.map_err(Into::<anyhow::Error>::into)?;
let response = agent
.send(vec![ContentBlock::text(build_review_prompt(scope))])
.await
.map_err(Into::<anyhow::Error>::into)?;
let text = response.text().trim().to_string();
if text.is_empty() {
bail!("review model returned an empty response");
}
Ok(text)
}
fn collect_review_scope(
cwd: &Path,
cache_root: &Path,
package: &str,
tag: Option<&str>,
branch: Option<&str>,
reporter: &Reporter,
) -> Result<ReviewScope> {
let target = resolve_review_target(cwd, cache_root, package, tag, branch, reporter)?;
let mut collector = ReviewCollector::default();
let target_index = collector.collect(target, cache_root, reporter)?;
Ok(ReviewScope {
target_index,
packages: collector.packages,
})
}
fn resolve_review_target(
cwd: &Path,
cache_root: &Path,
package: &str,
tag: Option<&str>,
branch: Option<&str>,
reporter: &Reporter,
) -> Result<ResolvedReviewTarget> {
let trimmed = package.trim();
if trimmed.is_empty() {
bail!("package must not be empty");
}
if let Some((alias, dependency, root_manifest)) = resolve_direct_dependency(cwd, trimmed)? {
if tag.is_some() || branch.is_some() {
bail!(
"`--tag` and `--branch` can only be used when reviewing a direct package reference"
);
}
return resolve_from_dependency_spec(
&alias,
&dependency,
&root_manifest,
cache_root,
reporter,
);
}
if let Some(package_root) = resolve_local_package_path(cwd, trimmed)? {
if tag.is_some() || branch.is_some() {
bail!("`--tag` and `--branch` cannot be used when reviewing a local package path");
}
let (manifest, role) = load_review_manifest_for_inspection(&package_root)?;
return Ok(ResolvedReviewTarget {
alias: normalize_dependency_alias(&manifest.effective_name())?,
manifest,
source: ReviewSource::Path { path: package_root },
selected_components: None,
role,
});
}
let normalized_url = normalize_git_url(trimmed);
let alias = normalize_alias_from_url(&normalized_url)?;
let checkout = ensure_git_dependency(
cache_root,
&normalized_url,
match (tag, branch) {
(Some(tag), None) => Some(ManifestRequestedGitRef::Tag(tag)),
(None, Some(branch)) => Some(ManifestRequestedGitRef::Branch(branch)),
(None, None) => None,
_ => bail!("git dependency must not request both `tag` and `branch`"),
},
true,
reporter,
)?;
let (manifest, role) = load_review_manifest_for_inspection(&checkout.path)
.with_context(|| format!("dependency `{alias}` does not match the Nodus package layout"))?;
Ok(ResolvedReviewTarget {
alias,
manifest,
source: ReviewSource::Git {
url: checkout.url,
tag: checkout.tag,
branch: checkout.branch,
rev: checkout.rev,
},
selected_components: None,
role,
})
}
fn resolve_direct_dependency(
cwd: &Path,
package: &str,
) -> Result<Option<(String, DependencySpec, LoadedManifest)>> {
crate::domain::dependency_query::resolve_direct_dependency(cwd, package)
}
fn resolve_local_package_path(cwd: &Path, package: &str) -> Result<Option<PathBuf>> {
crate::domain::dependency_query::resolve_local_package_path(cwd, package)
}
fn load_review_manifest_for_inspection(root: &Path) -> Result<(LoadedManifest, PackageRole)> {
load_manifest_for_inspection(root)
}
fn resolve_from_dependency_spec(
alias: &str,
dependency: &DependencySpec,
root_manifest: &LoadedManifest,
cache_root: &Path,
reporter: &Reporter,
) -> Result<ResolvedReviewTarget> {
let target = resolve_inspection_target(alias, dependency, root_manifest, cache_root, reporter)?;
let source = match target.source {
ResolvedInspectionSource::Path { resolved_root, .. } => ReviewSource::Path {
path: resolved_root,
},
ResolvedInspectionSource::Git {
url,
tag,
branch,
rev,
..
} => ReviewSource::Git {
url,
tag,
branch,
rev,
},
};
Ok(ResolvedReviewTarget {
alias: target.alias,
manifest: target.manifest,
source,
selected_components: target.selected_components,
role: target.role,
})
}
impl ReviewCollector {
fn collect(
&mut self,
target: ResolvedReviewTarget,
cache_root: &Path,
reporter: &Reporter,
) -> Result<usize> {
let root = target.manifest.root.clone();
if self.active_roots.contains(&root) {
bail!(
"dependency cycle detected while reviewing {}",
root.display()
);
}
if let Some(index) = self.index_by_root.get(&root).copied() {
self.packages[index].aliases.insert(target.alias);
return Ok(index);
}
let index = self.packages.len();
let mut aliases = BTreeSet::new();
aliases.insert(target.alias.clone());
self.index_by_root.insert(root.clone(), index);
self.active_roots.insert(root.clone());
self.packages.push(ReviewPackage {
aliases,
root: root.clone(),
manifest: target.manifest,
source: target.source,
selected_components: target.selected_components,
dependencies: Vec::new(),
});
let dependencies = self.packages[index]
.manifest
.manifest
.active_dependency_entries_for_role(target.role)
.into_iter()
.map(|entry| (entry.alias.to_string(), entry.kind, entry.spec.clone()))
.collect::<Vec<_>>();
for (alias, kind, spec) in dependencies {
let resolved = resolve_from_dependency_spec(
&alias,
&spec,
&self.packages[index].manifest,
cache_root,
reporter,
)?;
let dependency_index = self.collect(resolved, cache_root, reporter)?;
self.packages[index].dependencies.push(ReviewDependency {
alias,
kind,
package_index: dependency_index,
});
}
self.active_roots.remove(&root);
Ok(index)
}
}
impl ReviewScope {
fn target(&self) -> &ReviewPackage {
&self.packages[self.target_index]
}
fn target_alias(&self) -> String {
self.target()
.aliases
.iter()
.next()
.cloned()
.unwrap_or_else(|| "package".into())
}
fn allowed_read_roots(&self) -> Vec<PathBuf> {
self.packages
.iter()
.map(|package| package.root.clone())
.collect()
}
}
impl ReviewPackage {
fn primary_alias(&self) -> &str {
self.aliases
.iter()
.next()
.map(String::as_str)
.unwrap_or("package")
}
}
impl ReviewDependency {
fn display_alias(&self) -> String {
if self.kind.is_dev() {
format!("{} (dev)", self.alias)
} else {
self.alias.clone()
}
}
}
impl ReviewSource {
fn describe(&self) -> String {
match self {
Self::Path { path } => format!("path {}", path.display()),
Self::Git {
url,
tag,
branch,
rev,
} => {
let reference = tag
.as_deref()
.map(|value| format!("tag {value}"))
.or_else(|| branch.as_deref().map(|value| format!("branch {value}")))
.unwrap_or_else(|| format!("rev {rev}"));
format!("git {url} ({reference}, rev {rev})")
}
}
}
}
impl ReviewSessionPaths {
fn new() -> Result<Self> {
let temp = tempfile::tempdir().context("failed to allocate temporary review directory")?;
let store_path = temp.path().join("runtime.sqlite");
let team_dir = temp.path().join("team");
let tasks_dir = temp.path().join("tasks");
let transcript_dir = temp.path().join("transcripts");
std::fs::create_dir_all(&team_dir)
.with_context(|| format!("failed to create {}", team_dir.display()))?;
std::fs::create_dir_all(&tasks_dir)
.with_context(|| format!("failed to create {}", tasks_dir.display()))?;
std::fs::create_dir_all(&transcript_dir)
.with_context(|| format!("failed to create {}", transcript_dir.display()))?;
Ok(Self {
_temp: temp,
store_path,
team_dir,
tasks_dir,
transcript_dir,
})
}
}
fn build_review_prompt(scope: &ReviewScope) -> String {
let mut prompt = String::new();
let target = scope.target();
let _ = writeln!(
prompt,
"Review this Nodus package graph for safety before installation."
);
let _ = writeln!(prompt);
let _ = writeln!(prompt, "Target package: {}", target.primary_alias());
let _ = writeln!(prompt, "Target root: {}", target.root.display());
let _ = writeln!(prompt, "Allowed review roots:");
for root in scope.allowed_read_roots() {
let _ = writeln!(prompt, "- {}", root.display());
}
let _ = writeln!(prompt);
let _ = writeln!(prompt, "Review expectations:");
let _ = writeln!(
prompt,
"- Use the `files` tool to inspect concrete evidence before you make claims."
);
let _ = writeln!(
prompt,
"- Focus on capabilities, instructions, commands, transitive dependencies, remote downloads, credential access, persistence, code execution, and surprising side effects."
);
let _ = writeln!(
prompt,
"- Treat ambiguous or missing justification for sensitive behavior as risk, not as proof of safety."
);
let _ = writeln!(
prompt,
"- Do not rely on reputation or external knowledge; reason only from the package graph and files you can inspect."
);
let _ = writeln!(prompt);
let _ = writeln!(prompt, "Dependency graph:");
render_dependency_tree(scope, scope.target_index, 0, &mut prompt);
let _ = writeln!(prompt);
let _ = writeln!(prompt, "Package summaries:");
for (index, package) in scope.packages.iter().enumerate() {
let _ = writeln!(prompt, "[package {}]", index + 1);
let _ = writeln!(
prompt,
"aliases = {}",
package
.aliases
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
let _ = writeln!(prompt, "root = {}", package.root.display());
let _ = writeln!(prompt, "source = {}", package.source.describe());
if let Some(components) = &package.selected_components {
let _ = writeln!(
prompt,
"selected_components = {}",
components
.iter()
.map(|component| component.as_str())
.collect::<Vec<_>>()
.join(", ")
);
} else {
let _ = writeln!(prompt, "selected_components = all");
}
let _ = writeln!(
prompt,
"skills = {}",
package
.manifest
.discovered
.skills
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
let _ = writeln!(
prompt,
"agents = {}",
package
.manifest
.discovered
.agents
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
let _ = writeln!(
prompt,
"rules = {}",
package
.manifest
.discovered
.rules
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
let _ = writeln!(
prompt,
"commands = {}",
package
.manifest
.discovered
.commands
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if package.manifest.manifest.capabilities.is_empty() {
let _ = writeln!(prompt, "capabilities = none");
} else {
let _ = writeln!(prompt, "capabilities:");
for capability in &package.manifest.manifest.capabilities {
let _ = writeln!(
prompt,
"- {} ({}){}",
capability.id,
capability.sensitivity,
capability
.justification
.as_deref()
.map(|value| format!(": {value}"))
.unwrap_or_default()
);
}
}
if package.dependencies.is_empty() {
let _ = writeln!(prompt, "dependencies = none");
} else {
let _ = writeln!(
prompt,
"dependencies = {}",
package
.dependencies
.iter()
.map(|dependency| dependency.display_alias())
.collect::<Vec<_>>()
.join(", ")
);
}
let _ = writeln!(prompt);
}
let _ = writeln!(prompt, "Respond with these sections:");
let _ = writeln!(prompt, "1. Verdict");
let _ = writeln!(prompt, "2. Findings");
let _ = writeln!(prompt, "3. Good Signs");
let _ = writeln!(prompt, "4. Unknowns");
let _ = writeln!(prompt, "5. Recommendation");
prompt
}
fn render_dependency_tree(scope: &ReviewScope, index: usize, depth: usize, output: &mut String) {
let package = &scope.packages[index];
let indent = " ".repeat(depth);
let _ = writeln!(
output,
"{}- {} ({})",
indent,
package.primary_alias(),
package.root.display()
);
for dependency in &package.dependencies {
let child = &scope.packages[dependency.package_index];
let child_indent = " ".repeat(depth + 1);
let _ = writeln!(
output,
"{}alias `{}` -> {}",
child_indent,
dependency.display_alias(),
child.root.display()
);
render_dependency_tree(scope, dependency.package_index, depth + 2, output);
}
}
fn authorize_review_request(request: &ToolAuthorizationRequest) -> ToolAuthorizationDecision {
if request.tool_name != "files" {
return ToolAuthorizationDecision::deny("review agent only allows the files tool");
}
let Some(operations) = request
.preview
.structured_input
.get("operations")
.and_then(|value| value.as_array())
else {
return ToolAuthorizationDecision::deny(
"review agent requires explicit file operations in tool input",
);
};
let mut disallowed = Vec::new();
for operation in operations {
match operation.get("op").and_then(|value| value.as_str()) {
Some("read" | "list" | "search") => {}
Some(other) => disallowed.push(other.to_string()),
None => {
return ToolAuthorizationDecision::deny(
"review agent requires each file operation to declare an `op`",
);
}
}
}
if disallowed.is_empty() {
ToolAuthorizationDecision::allow()
} else {
ToolAuthorizationDecision::deny(format!(
"review agent is read-only; blocked file operations: {}",
disallowed.join(", ")
))
}
}
#[async_trait]
impl ToolAuthorizer for ReadOnlyReviewAuthorizer {
async fn authorize(
&self,
request: &ToolAuthorizationRequest,
) -> Result<ToolAuthorizationDecision, RuntimeError> {
Ok(authorize_review_request(request))
}
}
const REVIEW_SYSTEM_PROMPT: &str = r#"You are a package safety reviewer for Nodus packages.
Your job is to assess whether the target package and its transitive Nodus dependencies appear safe to install and use.
Constraints:
- You may only rely on the files available in the package roots provided by the user.
- Do not assume safety from popularity, branding, or repository location.
- Prefer direct evidence from files over speculation.
- If you cannot verify something, say so plainly.
Risk areas to inspect:
- declared capabilities, especially high-sensitivity ones
- instructions that ask the user to run shell commands, download binaries, or grant secrets
- commands, rules, agents, or skills that could lead to destructive or hidden side effects
- transitive dependencies that expand trust boundaries
- persistence, credential access, exfiltration, privilege escalation, and remote execution patterns
Be precise, skeptical, and evidence-based."#;
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use mentra::provider::{
Provider, ProviderDescriptor, ProviderError, ProviderEventStream, ProviderId, Request,
Response, Role, provider_event_stream_from_response,
};
use serde_json::json;
use super::*;
#[derive(Clone)]
struct ScriptedProvider {
kind: ProviderId,
models: Vec<ModelInfo>,
turns: Arc<Mutex<VecDeque<String>>>,
requests: Arc<Mutex<Vec<Request<'static>>>>,
}
impl ScriptedProvider {
fn new(model: ModelInfo, turns: Vec<String>) -> Self {
Self {
kind: model.provider.clone(),
models: vec![model],
turns: Arc::new(Mutex::new(turns.into())),
requests: Arc::new(Mutex::new(Vec::new())),
}
}
fn recorded_requests(&self) -> Vec<Request<'static>> {
self.requests.lock().unwrap().clone()
}
}
#[async_trait]
impl Provider for ScriptedProvider {
fn descriptor(&self) -> ProviderDescriptor {
ProviderDescriptor::new(self.kind.clone())
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
Ok(self.models.clone())
}
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
self.requests.lock().unwrap().push(request.into_owned());
let text = self
.turns
.lock()
.unwrap()
.pop_front()
.expect("scripted provider missing response");
Ok(provider_event_stream_from_response(Response {
id: "review-response".into(),
model: self.models[0].id.clone(),
role: Role::Assistant,
content: vec![ContentBlock::text(text)],
stop_reason: None,
usage: None,
}))
}
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
#[test]
fn collects_nested_dependency_graph_for_review() {
let temp = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
let child = root.join("vendor/child");
write_file(
&root.join("nodus.toml"),
"[dependencies]\nchild = { path = \"vendor/child\" }\n",
);
write_file(
&root.join("skills/root/SKILL.md"),
"---\nname: Root\ndescription: Root skill.\n---\n# Root\n",
);
write_file(
&child.join("skills/review/SKILL.md"),
"---\nname: Review\ndescription: Review safely.\n---\n# Review\n",
);
write_file(
&child.join("commands/check.md"),
"# Check\nRun this review.\n",
);
let reporter = Reporter::silent();
let scope = collect_review_scope(&root, cache.path(), ".", None, None, &reporter).unwrap();
assert_eq!(scope.packages.len(), 2);
assert_eq!(scope.target().primary_alias(), "root");
assert_eq!(scope.packages[0].dependencies.len(), 1);
assert_eq!(scope.packages[1].primary_alias(), "child");
}
#[test]
fn review_marks_root_dev_dependencies_in_the_prompt() {
let temp = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
write_file(
&root.join("nodus.toml"),
"[dev-dependencies]\nchild = { path = \"vendor/child\" }\n",
);
write_file(
&root.join("skills/root/SKILL.md"),
"---\nname: Root\ndescription: Root skill.\n---\n# Root\n",
);
write_file(
&root.join("vendor/child/skills/review/SKILL.md"),
"---\nname: Review\ndescription: Review safely.\n---\n# Review\n",
);
let reporter = Reporter::silent();
let scope = collect_review_scope(&root, cache.path(), ".", None, None, &reporter).unwrap();
let prompt = build_review_prompt(&scope);
assert!(prompt.contains("dependencies = child (dev)"));
}
#[test]
fn review_prompt_includes_dependency_graph_and_roots() {
let temp = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
write_file(
&root.join("skills/review/SKILL.md"),
"---\nname: Review\ndescription: Review safely.\n---\n# Review\n",
);
let reporter = Reporter::silent();
let scope = collect_review_scope(&root, cache.path(), ".", None, None, &reporter).unwrap();
let prompt = build_review_prompt(&scope);
assert!(prompt.contains("Allowed review roots:"));
assert!(prompt.contains(&root.display().to_string()));
assert!(prompt.contains("Respond with these sections:"));
}
#[test]
fn authorizer_blocks_mutating_file_operations() {
let request = ToolAuthorizationRequest {
agent_id: "agent-1".into(),
agent_name: "review".into(),
model: "mock-model".into(),
history_len: 1,
tool_call_id: "call-1".into(),
tool_name: "files".into(),
preview: mentra::tool::ToolAuthorizationPreview {
working_directory: PathBuf::from("/tmp"),
capabilities: Vec::new(),
side_effect_level: mentra::tool::ToolSideEffectLevel::None,
durability: mentra::tool::ToolDurability::Ephemeral,
raw_input: json!({}),
structured_input: json!({
"operations": [
{ "op": "read", "path": "/tmp/a" },
{ "op": "set", "path": "/tmp/a", "content": "x" }
]
}),
},
};
let decision = authorize_review_request(&request);
assert_eq!(
decision.outcome,
mentra::tool::ToolAuthorizationOutcome::Deny
);
assert!(
decision
.reason
.unwrap()
.contains("blocked file operations: set")
);
}
#[tokio::test]
async fn execute_review_uses_only_files_tool() {
let temp = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
write_file(
&root.join("skills/review/SKILL.md"),
"---\nname: Review\ndescription: Review safely.\n---\n# Review\n",
);
let reporter = Reporter::silent();
let scope = collect_review_scope(&root, cache.path(), ".", None, None, &reporter).unwrap();
let session = ReviewSessionPaths::new().unwrap();
let model = ModelInfo::new("mock-model", BuiltinProvider::OpenAI);
let provider = ScriptedProvider::new(model.clone(), vec!["Verdict: caution".into()]);
let runtime = Runtime::builder()
.with_store(SqliteRuntimeStore::new(session.store_path.clone()))
.with_policy(RuntimePolicy::default().with_allowed_read_root(root.clone()))
.with_tool_authorizer(ReadOnlyReviewAuthorizer)
.with_provider_instance(provider.clone())
.build()
.unwrap();
let review = execute_review_with_runtime(&runtime, model, &scope, &session)
.await
.unwrap();
let requests = provider.recorded_requests();
assert_eq!(review, "Verdict: caution");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].tools.len(), 1);
assert_eq!(requests[0].tools[0].name, "files");
assert_eq!(requests[0].system.as_deref(), Some(REVIEW_SYSTEM_PROMPT));
}
}