use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config::NetMode;
use crate::sandbox::Backend;
use super::loader::{load_with_options, PolicyLoadOptions};
use super::types::{Policy, Tier};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathExplanation {
pub path: String,
pub access: String,
pub writable: bool,
pub readable: bool,
pub denied: bool,
pub rule_type: String,
pub matching_rule: String,
pub how_to_change: String,
}
pub fn run_cli(
json: bool,
why: Option<&Path>,
profile: &str,
policy_path: Option<&Path>,
net: &NetMode,
limits_spec: Option<&str>,
) -> Result<()> {
let backend = Backend::detect(net.clone(), false).ok();
let tier = backend.as_ref().and_then(|b| b.tier());
let project = std::env::current_dir().context("getcwd")?;
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.context(
"neither $HOME nor %USERPROFILE% is set; vetto needs it to resolve policy variables",
)?;
let options = PolicyLoadOptions {
agent: None,
include_project_policy: true,
..PolicyLoadOptions::default()
};
let mut policy = load_with_options(
profile,
policy_path,
&project,
&home,
tier.unwrap_or(Tier::Full), &options,
)?;
if let Some(spec) = limits_spec {
super::limits_spec::apply_cli(&mut policy, spec)?;
}
let command_name = if policy.name.is_empty() {
"vetto-preview".to_string()
} else {
policy.name.clone()
};
let contract_input = crate::policy_ir::compiler::EffectivePolicyInput {
policy: &policy,
argv: &[command_name],
cwd: &project,
env: &std::collections::BTreeMap::new(),
net,
nonce: "explain-preview",
timeout: None,
tier,
backend: backend
.as_ref()
.map(|b| b.describe())
.unwrap_or_else(|| "none".to_string()),
observe_seccomp: backend
.as_ref()
.map(|b| b.observes_seccomp())
.unwrap_or(false),
debug_ports: None,
};
let contract = crate::policy_ir::compiler::PolicyCompiler::compile_effective(contract_input)?;
if let Some(target_path) = why {
let explanation = explain_why(&policy, target_path, &project);
if json {
println!("{}", serde_json::to_string_pretty(&explanation)?);
} else {
print_why_text(&explanation);
}
} else if json {
print_json(&policy, &contract, tier, net)?;
} else {
print_text(&policy, &contract, tier, net)?;
}
Ok(())
}
pub fn explain_why(policy: &Policy, target_path: &Path, project: &Path) -> PathExplanation {
let resolved = if target_path.is_relative() {
project.join(target_path)
} else {
target_path.to_path_buf()
};
let is_denied_secret = policy.deny_resolved.iter().any(|d| {
if d.is_dir {
resolved.starts_with(&d.path)
} else {
resolved == d.path
}
});
let is_denied_read = policy.deny_read.iter().any(|d| resolved.starts_with(d));
let is_denied_write = policy.deny_write.iter().any(|d| resolved.starts_with(d));
let matching_write_root = policy
.allow_write
.iter()
.find(|root| resolved.starts_with(root))
.map(|r| r.display().to_string());
let is_writable = matching_write_root.is_some() && !is_denied_write && !is_denied_secret;
let matching_read_root = policy
.allow_read
.iter()
.find(|root| resolved.starts_with(root))
.map(|r| r.display().to_string())
.or_else(|| matching_write_root.clone());
let is_readable = matching_read_root.is_some() && !is_denied_read && !is_denied_secret;
let (access, rule_type, matching_rule, how_to_change) = if is_denied_secret {
(
"DENIED".to_string(),
"display_only_deny".to_string(),
"masked credential / secret path".to_string(),
"To allow access: remove matching pattern from [display_only_deny.paths] in policy.toml, or move file out of masked secrets pattern.".to_string(),
)
} else if is_denied_read {
(
"DENIED".to_string(),
"deny_read".to_string(),
"filesystem.deny_read rule".to_string(),
"To allow access: remove path from [filesystem.deny_read] in policy.toml.".to_string(),
)
} else if is_writable {
(
"WRITABLE".to_string(),
"allow_write".to_string(),
format!("allow_write root: {}", matching_write_root.unwrap_or_default()),
"Path is writable and readable. To restrict to read-only, remove from [filesystem.allow_write] and keep in [filesystem.allow_read].".to_string(),
)
} else if is_denied_write && is_readable {
(
"READ_ONLY".to_string(),
"deny_write".to_string(),
format!("subtractive deny_write rule overrides write access in root: {}", matching_write_root.unwrap_or_default()),
format!(
"Path is read-only due to subtractive [filesystem.deny_write] override. To allow writing: remove \"{}\" from [filesystem.deny_write] in policy.toml.",
target_path.display()
),
)
} else if is_readable {
(
"READ_ONLY".to_string(),
"allow_read".to_string(),
format!("allow_read root: {}", matching_read_root.unwrap_or_default()),
format!(
"Path is read-only. To allow writing: add \"{}\" or parent directory to [filesystem.allow_write] in policy.toml.",
target_path.display()
),
)
} else {
(
"BLOCKED".to_string(),
"unmapped".to_string(),
"isolated scope (not in any allowed read or write root)".to_string(),
format!(
"Path is outside sandbox scope. To allow reading: add `allow_read = [\"{}\"]` to policy.toml. To allow writing: add to `allow_write`.",
target_path.display()
),
)
};
PathExplanation {
path: resolved.display().to_string(),
access,
writable: is_writable,
readable: is_readable,
denied: is_denied_secret || is_denied_read,
rule_type,
matching_rule,
how_to_change,
}
}
fn print_why_text(e: &PathExplanation) {
println!("vetto policy explain --why");
println!(" path: {}", e.path);
println!(" access: {}", e.access);
println!(" writable: {}", if e.writable { "yes" } else { "no" });
println!(" readable: {}", if e.readable { "yes" } else { "no" });
println!(" rule type: {}", e.rule_type);
println!(" matching rule: {}", e.matching_rule);
println!(" how to change: {}", e.how_to_change);
}
pub fn run_show(
effective: bool,
json: bool,
profile: &str,
policy_path: Option<&Path>,
net: &NetMode,
) -> Result<()> {
let _ = effective;
run_cli(json, None, profile, policy_path, net, None)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformInfo {
pub tier_name: String,
pub diagnostic: String,
}
fn platform_diagnostic(tier: Option<Tier>) -> PlatformInfo {
#[cfg(target_os = "linux")]
{
let tier_name = "Linux Tier 1".to_string();
let sub = match tier {
Some(Tier::Full) => "Full",
Some(Tier::FsOnly) => "FS-Only",
Some(Tier::Seccomp) => "Seccomp-Only",
None => "Unconfined",
};
let diagnostic =
format!("Linux Tier 1: {sub} (Landlock ABI, namespaces, cgroups v2, seccomp)");
PlatformInfo {
tier_name,
diagnostic,
}
}
#[cfg(target_os = "macos")]
{
let _ = tier;
PlatformInfo {
tier_name: "macOS Tier 2".to_string(),
diagnostic: "macOS Tier 2: Seatbelt SBPL (rlimits: unverified, cgroups: unsupported)"
.to_string(),
}
}
#[cfg(target_os = "windows")]
{
let _ = tier;
PlatformInfo {
tier_name: "Windows Tier 3".to_string(),
diagnostic: "Windows Tier 3: AppContainer / Job Objects (cgroups: unsupported)"
.to_string(),
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = tier;
PlatformInfo {
tier_name: "Unknown Tier".to_string(),
diagnostic: "Unsupported platform (no sandbox backend)".to_string(),
}
}
}
fn tier_label(tier: Option<Tier>) -> &'static str {
match tier {
Some(Tier::Full) => Tier::Full.label(),
Some(Tier::FsOnly) => Tier::FsOnly.label(),
Some(Tier::Seccomp) => Tier::Seccomp.label(),
None => "macos-seatbelt",
}
}
fn masking_strategy(tier: Option<Tier>) -> &'static str {
match tier {
Some(Tier::Full) => "mount-masked",
Some(Tier::FsOnly) => "allowlist-carved",
Some(Tier::Seccomp) => "unmasked-filesystem-seccomp-only",
None => "seatbelt-denied",
}
}
fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
fn format_limit(name: &str, value: u64, is_bytes: bool) -> String {
if is_bytes {
format!("{name}: {}", human_bytes(value))
} else {
format!("{name}: {value}")
}
}
const MAX_LISTED_READ_ROOTS: usize = 25;
fn print_text(
policy: &Policy,
contract: &crate::policy_ir::contract::SecurityContract,
tier: Option<Tier>,
net: &NetMode,
) -> Result<()> {
println!("vetto policy explain");
println!(" contract:");
println!(
" contract_digest_blake3: {}",
contract.contract_digest_blake3
);
println!(" contract_version: {}", contract.contract_version);
println!(" FSM: ContractSealed");
let p_info = platform_diagnostic(tier);
println!(" platform: {}", p_info.diagnostic);
println!(" tier: {} ({})", p_info.tier_name, tier_label(tier));
println!(" net: {}", net.label());
println!(" profile: {}", policy.name);
println!(" immutable: {}", policy.is_immutable);
println!(" write roots:");
if policy.allow_write.is_empty() {
println!(" (none)");
}
for root in &policy.allow_write {
println!(" {}", root.display());
}
println!(" read roots ({}):", policy.allow_read.len());
for root in policy.allow_read.iter().take(MAX_LISTED_READ_ROOTS) {
println!(" {}", root.display());
}
if policy.allow_read.len() > MAX_LISTED_READ_ROOTS {
println!(
" ... {} more",
policy.allow_read.len() - MAX_LISTED_READ_ROOTS
);
}
let strategy = masking_strategy(tier);
println!(
" masked secrets ({}): {}",
policy.deny_resolved.len(),
strategy
);
for entry in &policy.deny_resolved {
println!(
" {}{}",
entry.path.display(),
if entry.is_dir { "/" } else { "" }
);
}
println!(" Resources:");
println!(" CPU:");
println!(
" rlimit_cpu: {}",
policy
.limits
.cpu_seconds
.map(|s| format!("{s}s"))
.unwrap_or_else(|| "(none)".into())
);
println!(
" cpu.max: {}",
policy
.cpu_max
.as_deref()
.or_else(|| policy.cgroup.as_ref().and_then(|c| c.cpu_max.as_deref()))
.unwrap_or("(none)")
);
println!(" effective: {}%", contract.resources.max_cpu_percent);
println!(" Memory:");
println!(
" rlimit_as: {}",
policy
.limits
.address_space_bytes
.map(human_bytes)
.unwrap_or_else(|| "(none)".into())
);
println!(
" memory.max: {}",
policy
.cgroup
.as_ref()
.and_then(|c| c.memory_max.as_deref())
.unwrap_or("(none)")
);
println!(
" swap.max: {}",
policy
.cgroup
.as_ref()
.and_then(|c| c.swap_max.as_deref())
.unwrap_or("(none)")
);
println!(
" effective: {}",
if contract.resources.max_memory_bytes > 0 {
human_bytes(contract.resources.max_memory_bytes)
} else {
"unconstrained".into()
}
);
println!(" Process:");
println!(
" rlimit_nproc: {}",
policy
.limits
.processes
.map(|p| p.to_string())
.unwrap_or_else(|| "(none)".into())
);
println!(
" pids.max: {}",
policy
.cgroup
.as_ref()
.and_then(|c| c.pids_max.as_deref())
.unwrap_or("(none)")
);
println!(
" effective: {}",
if contract.resources.max_pids > 0 {
contract.resources.max_pids.to_string()
} else {
"unconstrained".into()
}
);
println!(" File:");
println!(
" open_files: {}",
policy
.limits
.open_files
.map(|f| f.to_string())
.unwrap_or_else(|| "(none)".into())
);
println!(
" file_size_bytes: {}",
policy
.limits
.file_size_bytes
.map(human_bytes)
.unwrap_or_else(|| "(none)".into())
);
println!(" limits:");
let limits = &policy.limits;
let mut printed = 0usize;
if let Some(value) = limits.cpu_seconds {
println!(" {}", format_limit("cpu_seconds", value, false));
printed += 1;
}
if let Some(value) = limits.address_space_bytes {
println!(" {}", format_limit("address_space_bytes", value, true));
printed += 1;
}
if let Some(value) = limits.processes {
println!(" {}", format_limit("processes", value, false));
printed += 1;
}
if let Some(value) = limits.open_files {
println!(" {}", format_limit("open_files", value, false));
printed += 1;
}
if let Some(value) = limits.file_size_bytes {
println!(" {}", format_limit("file_size_bytes", value, true));
printed += 1;
}
if printed == 0 {
println!(" (none)");
}
println!(
" environment: {} pass-through pattern(s), {} deny pattern(s)",
policy.environment.pass_through.len(),
policy.environment.deny.len()
);
if policy.environment.pass_through.is_empty() {
println!(" pass-through: (none)");
} else {
println!(
" pass-through: {}",
policy.environment.pass_through.join(", ")
);
}
if policy.environment.deny.is_empty() {
println!(" deny: (none)");
} else {
println!(" deny: {}", policy.environment.deny.join(", "));
}
println!(" deny_network: {}", policy.deny_network);
if policy.warnings.is_empty() {
println!(" warnings: (none)");
} else {
println!(" warnings ({}):", policy.warnings.len());
for warning in &policy.warnings {
println!(" - {warning}");
}
}
Ok(())
}
fn print_json(
policy: &Policy,
contract: &crate::policy_ir::contract::SecurityContract,
tier: Option<Tier>,
net: &NetMode,
) -> Result<()> {
let strategy = masking_strategy(tier);
let limits = &policy.limits;
let p_info = platform_diagnostic(tier);
let object = serde_json::json!({
"contract": {
"contract_digest_blake3": contract.contract_digest_blake3,
"contract_version": contract.contract_version,
"contract_id": contract.contract_id,
"fsm_state": "ContractSealed",
},
"contract_digest_blake3": contract.contract_digest_blake3,
"contract_version": contract.contract_version,
"fsm_state": "ContractSealed",
"tier": tier_label(tier),
"platform_tier": p_info.tier_name,
"platform": {
"tier": p_info.tier_name,
"diagnostic": p_info.diagnostic,
},
"net": net.label(),
"profile": policy.name.clone(),
"immutable": policy.is_immutable,
"write_roots": paths_as_strings(&policy.allow_write),
"read_root_count": policy.allow_read.len(),
"read_roots": paths_as_strings(
&policy.allow_read.iter().take(MAX_LISTED_READ_ROOTS).cloned().collect::<Vec<_>>(),
),
"masked_secrets": policy
.deny_resolved
.iter()
.map(|entry| {
serde_json::json!({
"path": entry.path.display().to_string(),
"is_dir": entry.is_dir,
"strategy": strategy,
})
})
.collect::<Vec<_>>(),
"resources": {
"cpu": {
"rlimit_cpu": limits.cpu_seconds,
"cpu_max": policy.cpu_max.as_deref().or_else(|| policy.cgroup.as_ref().and_then(|c| c.cpu_max.as_deref())),
"effective_percent": contract.resources.max_cpu_percent,
},
"memory": {
"rlimit_as": limits.address_space_bytes,
"memory_max": policy.cgroup.as_ref().and_then(|c| c.memory_max.as_deref()),
"swap_max": policy.cgroup.as_ref().and_then(|c| c.swap_max.as_deref()),
"effective_bytes": contract.resources.max_memory_bytes,
},
"process": {
"rlimit_nproc": limits.processes,
"pids_max": policy.cgroup.as_ref().and_then(|c| c.pids_max.as_deref()),
"effective_pids": contract.resources.max_pids,
},
"file": {
"open_files": limits.open_files,
"file_size_bytes": limits.file_size_bytes,
},
"rlimit_cpu": limits.cpu_seconds,
"cpu_max": policy.cpu_max.as_deref().or_else(|| policy.cgroup.as_ref().and_then(|c| c.cpu_max.as_deref())),
"rlimit_as": limits.address_space_bytes,
"memory_max": policy.cgroup.as_ref().and_then(|c| c.memory_max.as_deref()),
"swap_max": policy.cgroup.as_ref().and_then(|c| c.swap_max.as_deref()),
"rlimit_nproc": limits.processes,
"pids_max": policy.cgroup.as_ref().and_then(|c| c.pids_max.as_deref()),
"open_files": limits.open_files,
"file_size_bytes": limits.file_size_bytes,
"max_pids": contract.resources.max_pids,
"max_memory_bytes": contract.resources.max_memory_bytes,
"max_cpu_percent": contract.resources.max_cpu_percent,
},
"limits": {
"cpu_seconds": limits.cpu_seconds,
"address_space_bytes": limits.address_space_bytes,
"processes": limits.processes,
"open_files": limits.open_files,
"file_size_bytes": limits.file_size_bytes,
},
"environment": {
"pass_through": policy.environment.pass_through.clone(),
"deny": policy.environment.deny.clone(),
},
"deny_network": policy.deny_network,
"warnings": policy.warnings.clone(),
});
println!("{}", serde_json::to_string_pretty(&object)?);
Ok(())
}
fn paths_as_strings(paths: &[PathBuf]) -> Vec<String> {
paths
.iter()
.map(|path| path.display().to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::types::DenyEntry;
#[test]
fn human_bytes_uses_binary_units_with_one_decimal() {
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(1024), "1.0 KiB");
assert_eq!(human_bytes(8589934592), "8.0 GiB");
assert_eq!(human_bytes(5 * 1024 * 1024), "5.0 MiB");
assert_eq!(human_bytes(2 * 1024u64.pow(4)), "2.0 TiB");
}
#[test]
fn tier_and_masking_labels_cover_all_tiers() {
assert_eq!(tier_label(Some(Tier::Full)), "full");
assert_eq!(tier_label(Some(Tier::FsOnly)), "fs-only");
assert_eq!(tier_label(None), "macos-seatbelt");
assert_eq!(masking_strategy(Some(Tier::Full)), "mount-masked");
assert_eq!(masking_strategy(Some(Tier::FsOnly)), "allowlist-carved");
}
#[test]
fn explain_why_identifies_writable_readable_and_denied_paths() {
let mut policy = Policy::default();
let project = PathBuf::from("/home/user/project");
policy.allow_write = vec![project.clone(), PathBuf::from("/tmp")];
policy.allow_read = vec![PathBuf::from("/usr")];
policy.deny_resolved = vec![DenyEntry {
path: project.join(".env"),
is_dir: false,
}];
let src_file = project.join("src/main.rs");
let exp_src = explain_why(&policy, &src_file, &project);
assert_eq!(exp_src.access, "WRITABLE");
assert!(exp_src.writable);
assert!(exp_src.readable);
let env_file = project.join(".env");
let exp_env = explain_why(&policy, &env_file, &project);
assert_eq!(exp_env.access, "DENIED");
assert!(!exp_env.writable);
assert!(!exp_env.readable);
assert!(exp_env.denied);
assert!(exp_env.how_to_change.contains("display_only_deny"));
let usr_lib = PathBuf::from("/usr/lib");
let exp_usr = explain_why(&policy, &usr_lib, &project);
assert_eq!(exp_usr.access, "READ_ONLY");
assert!(!exp_usr.writable);
assert!(exp_usr.readable);
let etc_pass = PathBuf::from("/etc/shadow");
let exp_etc = explain_why(&policy, &etc_pass, &project);
assert_eq!(exp_etc.access, "BLOCKED");
assert_eq!(exp_etc.rule_type, "unmapped");
assert!(!exp_etc.writable);
assert!(!exp_etc.readable);
assert!(exp_etc.matching_rule.contains("isolated scope"));
let sub_write = project.join("src/generated.rs");
policy.deny_write = vec![sub_write.clone()];
let exp_deny_write = explain_why(&policy, &sub_write, &project);
assert_eq!(exp_deny_write.access, "READ_ONLY");
assert_eq!(exp_deny_write.rule_type, "deny_write");
assert!(!exp_deny_write.writable);
assert!(exp_deny_write.readable);
assert!(exp_deny_write
.matching_rule
.contains("subtractive deny_write"));
assert!(exp_deny_write.how_to_change.contains("deny_write"));
}
#[test]
fn platform_diagnostic_returns_valid_info() {
let p_info = platform_diagnostic(Some(Tier::Full));
assert!(!p_info.tier_name.is_empty());
assert!(!p_info.diagnostic.is_empty());
}
}