use std::io::{BufRead, IsTerminal, Write};
use std::path::Path;
use anyhow::{bail, Context, Result};
use clap::Args;
use crate::policy::presets::{agent_network_allowlist, Preset};
#[derive(Args, Debug, Clone)]
pub struct WizardArgs {
#[arg(value_name = "PATH", default_value = ".")]
pub path: String,
#[arg(short = 'y', long = "yes")]
pub yes: bool,
#[arg(short = 'f', long = "force")]
pub force: bool,
#[arg(long, value_name = "PRESET")]
pub preset: Option<String>,
#[arg(long, value_name = "AGENT")]
pub agent: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AgentOption {
pub slug: &'static str,
pub display: &'static str,
}
pub const AGENT_OPTIONS: &[AgentOption] = &[
AgentOption {
slug: "codex",
display: "OpenAI Codex",
},
AgentOption {
slug: "claude",
display: "Claude Code",
},
AgentOption {
slug: "antigravity",
display: "Antigravity CLI",
},
AgentOption {
slug: "opencode",
display: "OpenCode",
},
AgentOption {
slug: "gemini",
display: "Google Gemini",
},
AgentOption {
slug: "cursor",
display: "Cursor",
},
AgentOption {
slug: "aider",
display: "Aider",
},
AgentOption {
slug: "cline",
display: "Cline",
},
AgentOption {
slug: "copilot",
display: "GitHub Copilot",
},
AgentOption {
slug: "windsurf",
display: "Windsurf",
},
AgentOption {
slug: "continue",
display: "Continue",
},
AgentOption {
slug: "goose",
display: "Block Goose",
},
AgentOption {
slug: "openhands",
display: "OpenHands",
},
AgentOption {
slug: "swe_agent",
display: "SWE-agent",
},
AgentOption {
slug: "plandex",
display: "Plandex",
},
AgentOption {
slug: "mentat",
display: "Mentat",
},
AgentOption {
slug: "gpt_engineer",
display: "GPT Engineer",
},
AgentOption {
slug: "devin",
display: "Cognition Devin",
},
AgentOption {
slug: "crust",
display: "Crust AI",
},
AgentOption {
slug: "amp",
display: "Amp AI",
},
AgentOption {
slug: "custom",
display: "Custom",
},
];
pub fn resolve_agent(input: &str) -> (&'static str, &'static str) {
let trimmed = input.trim();
if let Ok(idx) = trimmed.parse::<usize>() {
if idx >= 1 && idx <= AGENT_OPTIONS.len() {
let opt = &AGENT_OPTIONS[idx - 1];
return (opt.slug, opt.display);
}
}
let lower = trimmed.to_ascii_lowercase();
for opt in AGENT_OPTIONS {
if opt.slug == lower || opt.display.to_ascii_lowercase() == lower {
return (opt.slug, opt.display);
}
}
match lower.as_str() {
"openai" | "openai-codex" => ("codex", "OpenAI Codex"),
"claude-code" | "anthropic" => ("claude", "Claude Code"),
"agy" | "antigravity-cli" => ("antigravity", "Antigravity CLI"),
"aider-chat" => ("aider", "Aider"),
"cursor-server" | "cursor-agent" => ("cursor", "Cursor"),
"all-hands" => ("openhands", "OpenHands"),
"sweagent" | "swe-agent" => ("swe_agent", "SWE-agent"),
"gpte" => ("gpt_engineer", "GPT Engineer"),
_ => ("custom", "Custom"),
}
}
pub fn resolve_preset(input: &str) -> Result<Preset> {
let trimmed = input.trim();
match trimmed {
"1" => Ok(Preset::Balanced),
"2" => Ok(Preset::Paranoid),
"3" => Ok(Preset::Yolo),
_ => Preset::parse(trimmed),
}
}
pub fn resolve_net_mode(input: &str) -> &'static str {
let trimmed = input.trim();
match trimmed {
"1" => "allowlist",
"2" => "off",
"3" => "open",
_ => match trimmed.to_ascii_lowercase().as_str() {
"off" | "airgap" | "none" => "off",
"open" | "unrestricted" => "open",
_ => "allowlist",
},
}
}
pub struct WizardConfig {
pub project_name: String,
pub agent_slug: &'static str,
pub agent_display: &'static str,
pub preset: Preset,
pub net_mode: &'static str,
pub ecosystems: Vec<String>,
pub allow_read: Vec<String>,
pub network_domains: Vec<String>,
pub cpu_seconds: Option<u64>,
pub memory_bytes: Option<u64>,
}
pub fn generate_wizard_policy(config: &WizardConfig) -> String {
let eco_str = if config.ecosystems.is_empty() {
"Generic".to_string()
} else {
config.ecosystems.join(", ")
};
let extends_profile = match config.preset {
Preset::Paranoid => "strict",
Preset::Balanced => "default",
Preset::Yolo => "permissive",
};
let mut toml = format!(
r#"# policy.toml - Vetto Security Policy
# Generated by `vetto wizard` for {eco_str} ({})
# Security preset: {}
#
# Documentation: https://github.com/shleder/vetto
[metadata]
name = "{}"
description = "Vetto security policy ({} preset) for {}"
extends = ["{extends_profile}"]
[security]
# When immutable = true, lower configuration layers cannot override rules.
# immutable = false
[filesystem]
"#,
config.agent_display,
config.preset.as_str(),
config.project_name,
config.preset.as_str(),
config.agent_display,
);
match config.preset {
Preset::Paranoid => {
toml.push_str(
r#"# Paranoid preset: strict read-only workspace, temporary scratch only
allow_write = [
"/tmp",
"/dev/null",
]
deny_write = [
"$PROJECT",
"$PROJECT/.git",
]
allow_read = [
"$PROJECT",
]
"#,
);
}
Preset::Balanced => {
toml.push_str(
r#"# Balanced preset: project workspace and scratch space are writable
allow_write = [
"$PROJECT",
"/tmp",
"/dev/null",
]
deny_write = [
"$PROJECT/.git",
]
allow_read = [
"$PROJECT",
"#,
);
for path in &config.allow_read {
if path != "$PROJECT" {
toml.push_str(&format!(" \"{path}\",\n"));
}
}
toml.push_str("]\n");
}
Preset::Yolo => {
toml.push_str(
r#"# YOLO preset: workspace write, permissive toolchain roots
allow_write = [
"$PROJECT",
"/tmp",
"/dev/null",
]
deny_write = [
"$PROJECT/.git",
]
allow_read = [
"$PROJECT",
"#,
);
for path in &config.allow_read {
if path != "$PROJECT" {
toml.push_str(&format!(" \"{path}\",\n"));
}
}
toml.push_str(" \"/usr\",\n \"/opt\",\n]\n");
}
}
toml.push_str(
r#"
[display_only_deny]
# Sensitive credential-shaped files masked and blocked inside the sandbox:
paths = [
"$PROJECT/.env",
"$PROJECT/.env.*",
"$PROJECT/*.pem",
"$PROJECT/*.key",
"$PROJECT/*.pfx",
"$PROJECT/*.kdbx",
]
[environment]
pass_through = [
"HOME",
"PATH",
"USER",
"LANG",
"LC_*",
]
"#,
);
toml.push_str("\n[network]\n");
match config.net_mode {
"off" => {
toml.push_str(
r#"# Airgap lockdown: zero network egress
mode = "off"
allow = []
"#,
);
}
"open" => {
toml.push_str(
r#"# Unrestricted egress
mode = "open"
allow = [
"*",
]
"#,
);
}
_ => {
toml.push_str(
r#"# Allowlist mode: strictly limited to AI agent API and package registries
mode = "allowlist"
allow = [
"#,
);
if config.network_domains.is_empty() {
toml.push_str(" \"github.com:443\",\n");
} else {
for domain in &config.network_domains {
toml.push_str(&format!(" \"{domain}\",\n"));
}
}
toml.push_str("]\n");
}
}
toml.push_str("\n[limits]\n");
if config.cpu_seconds.is_some() || config.memory_bytes.is_some() {
if let Some(cpu) = config.cpu_seconds {
toml.push_str(&format!("cpu_seconds = {cpu}\n"));
}
if let Some(mem) = config.memory_bytes {
toml.push_str(&format!("memory_bytes = {mem}\n"));
}
} else {
toml.push_str(
r#"# Optional resource ceilings for sandboxed processes:
# cpu_seconds = 3600
# address_space_bytes = 8589934592 # 8 GiB
# memory_bytes = 8589934592 # 8 GiB
# processes = 512
# open_files = 4096
# file_size_bytes = 1073741824 # 1 GiB
"#,
);
}
toml
}
fn parse_resource_limits(input: &str) -> (Option<u64>, Option<u64>) {
let mut cpu = None;
let mut mem = None;
for part in input.split([',', ' ']) {
let part = part.trim();
if part.is_empty() || part.eq_ignore_ascii_case("none") {
continue;
}
if let Some(val) = part.strip_prefix("cpu=") {
if let Ok(secs) = val.parse::<u64>() {
cpu = Some(secs);
}
} else if let Some(val) = part.strip_prefix("mem=") {
if let Some(bytes) = parse_mem_bytes(val) {
mem = Some(bytes);
}
}
}
(cpu, mem)
}
fn parse_mem_bytes(val: &str) -> Option<u64> {
let v = val.trim().to_ascii_uppercase();
if let Some(num) = v.strip_suffix("GB").or_else(|| v.strip_suffix('G')) {
num.parse::<u64>().ok().map(|n| n * 1024 * 1024 * 1024)
} else if let Some(num) = v.strip_suffix("MB").or_else(|| v.strip_suffix('M')) {
num.parse::<u64>().ok().map(|n| n * 1024 * 1024)
} else {
v.parse::<u64>().ok()
}
}
pub fn run_wizard_cli(args: &WizardArgs) -> Result<()> {
let stdin = std::io::stdin();
let is_tty = stdin.is_terminal();
let mut reader = stdin.lock();
let mut writer = std::io::stdout();
run_wizard_flow(args, is_tty, &mut reader, &mut writer)
}
pub fn run_wizard_flow(
args: &WizardArgs,
is_tty: bool,
reader: &mut impl BufRead,
writer: &mut impl Write,
) -> Result<()> {
let root = Path::new(&args.path);
let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let target_dir = root_canon.join(".vetto");
let target_policy_path = target_dir.join("policy.toml");
if target_policy_path.exists() && !args.force {
bail!(
"policy file already exists at {} (use -f / --force to overwrite)",
target_policy_path.display()
);
}
let is_interactive = !args.yes && is_tty;
if is_interactive {
run_wizard_interactive_flow(&root_canon, args, reader, writer)
} else {
run_wizard_non_interactive_flow(&root_canon, args, writer)
}
}
fn run_wizard_non_interactive_flow(
root: &Path,
args: &WizardArgs,
writer: &mut impl Write,
) -> Result<()> {
let analysis = crate::init::analyze_project(root);
let detected_agent = crate::onboard::detect_agent(root).ok();
let (agent_slug, agent_disp) = if let Some(ref a) = args.agent {
resolve_agent(a)
} else if let Some(ref detected) = detected_agent {
resolve_agent(detected.name)
} else if !analysis.detected_agents.is_empty() {
resolve_agent(analysis.detected_agents[0])
} else {
("claude", "Claude Code")
};
let preset = if let Some(ref p) = args.preset {
resolve_preset(p)?
} else {
Preset::Balanced
};
let net_mode = match preset {
Preset::Paranoid => "off",
_ => "allowlist",
};
let network_domains = if net_mode == "allowlist" {
let mut domains = agent_network_allowlist(agent_slug);
domains.extend(analysis.recommended_network_domains.clone());
domains.sort();
domains.dedup();
domains
} else {
Vec::new()
};
let toml = generate_wizard_policy(&WizardConfig {
project_name: analysis.project_name.clone(),
agent_slug,
agent_display: agent_disp,
preset,
net_mode,
ecosystems: analysis
.detected_ecosystems
.iter()
.map(|s| s.to_string())
.collect(),
allow_read: analysis.recommended_allow_read.clone(),
network_domains,
cpu_seconds: None,
memory_bytes: None,
});
let target_dir = root.join(".vetto");
let target_policy_path = target_dir.join("policy.toml");
std::fs::create_dir_all(&target_dir)
.with_context(|| format!("failed to create directory {}", target_dir.display()))?;
std::fs::write(&target_policy_path, &toml)
.with_context(|| format!("failed to write policy to {}", target_policy_path.display()))?;
let display_path = if args.path == "." || args.path.is_empty() {
".vetto/policy.toml".to_string()
} else {
target_policy_path.display().to_string()
};
writeln!(
writer,
"✓ Vetto security policy generated at {display_path}"
)?;
writeln!(
writer,
" Preset: {} | Agent: {} | Network: {}",
preset.as_str(),
agent_slug,
net_mode
)?;
writeln!(
writer,
"Quick start: vetto enable {agent_slug} or vetto -- {agent_slug}"
)?;
Ok(())
}
pub fn run_wizard_interactive_flow(
root: &Path,
args: &WizardArgs,
reader: &mut impl BufRead,
writer: &mut impl Write,
) -> Result<()> {
let analysis = crate::init::analyze_project(root);
let detected_agent = crate::onboard::detect_agent(root).ok();
writeln!(
writer,
"╔════════════════════════════════════════════════════════════════╗"
)?;
writeln!(
writer,
"║ Vetto Interactive Wizard ║"
)?;
writeln!(
writer,
"║ Configure Sandbox Boundaries & Security Policy ║"
)?;
writeln!(
writer,
"╚════════════════════════════════════════════════════════════════╝"
)?;
writeln!(writer)?;
if !analysis.detected_ecosystems.is_empty() {
writeln!(
writer,
" Detected ecosystem: {}",
analysis.detected_ecosystems.join(", ")
)?;
}
if let Some(ref detected) = detected_agent {
writeln!(writer, " Detected agent: {}", detected.name)?;
}
writeln!(writer)?;
let default_agent_idx = if let Some(ref a) = args.agent {
let (slug, _) = resolve_agent(a);
AGENT_OPTIONS
.iter()
.position(|o| o.slug == slug)
.map(|i| i + 1)
.unwrap_or(2)
} else if let Some(ref detected) = detected_agent {
let (slug, _) = resolve_agent(detected.name);
AGENT_OPTIONS
.iter()
.position(|o| o.slug == slug)
.map(|i| i + 1)
.unwrap_or(2)
} else if !analysis.detected_agents.is_empty() {
let (slug, _) = resolve_agent(analysis.detected_agents[0]);
AGENT_OPTIONS
.iter()
.position(|o| o.slug == slug)
.map(|i| i + 1)
.unwrap_or(2)
} else {
2 };
writeln!(writer, "1. Target AI Coding Agent:")?;
writeln!(
writer,
" [1] OpenAI Codex [2] Claude Code [3] Antigravity CLI [4] Cursor [5] Aider [6] OpenCode [7] Custom"
)?;
write!(
writer,
" Select agent [1-7, default: {default_agent_idx}]: "
)?;
writer.flush()?;
let mut agent_line = String::new();
reader.read_line(&mut agent_line)?;
let (agent_slug, agent_disp) = if agent_line.trim().is_empty() {
let opt = &AGENT_OPTIONS[default_agent_idx - 1];
(opt.slug, opt.display)
} else {
resolve_agent(&agent_line)
};
writeln!(writer, " Selected agent: {agent_disp}")?;
writeln!(writer)?;
let default_preset = if let Some(ref p) = args.preset {
resolve_preset(p).unwrap_or(Preset::Balanced)
} else {
Preset::Balanced
};
let default_preset_idx = match default_preset {
Preset::Balanced => 1,
Preset::Paranoid => 2,
Preset::Yolo => 3,
};
writeln!(writer, "2. Security Preset:")?;
writeln!(
writer,
" [1] Balanced (recommended: workspace+tmp write, secrets masked, agent API allowlist)"
)?;
writeln!(
writer,
" [2] Paranoid (strict read-only workspace, no network, maximal lockdown)"
)?;
writeln!(
writer,
" [3] YOLO (workspace write, permissive toolchain, agent network)"
)?;
write!(
writer,
" Select preset [1-3, default: {default_preset_idx}]: "
)?;
writer.flush()?;
let mut preset_line = String::new();
reader.read_line(&mut preset_line)?;
let preset = if preset_line.trim().is_empty() {
default_preset
} else {
resolve_preset(&preset_line)?
};
writeln!(writer, " Selected preset: {}", preset.as_str())?;
writeln!(writer)?;
let default_net_idx = match preset {
Preset::Paranoid => 2,
_ => 1,
};
writeln!(writer, "3. Network Mode:")?;
writeln!(
writer,
" [1] Allowlist (only agent API and package registries)"
)?;
writeln!(writer, " [2] Off (full airgap lockdown, zero network)")?;
writeln!(writer, " [3] Open (unrestricted egress)")?;
write!(
writer,
" Select network mode [1-3, default: {default_net_idx}]: "
)?;
writer.flush()?;
let mut net_line = String::new();
reader.read_line(&mut net_line)?;
let net_mode = if net_line.trim().is_empty() {
match default_net_idx {
2 => "off",
3 => "open",
_ => "allowlist",
}
} else {
resolve_net_mode(&net_line)
};
writeln!(writer, " Selected network mode: {net_mode}")?;
writeln!(writer)?;
writeln!(writer, "4. Secret Protection:")?;
writeln!(
writer,
" Confirmed masked paths: ~/.ssh, ~/.aws, ~/.gnupg, $PROJECT/.env*, *.pem, *.key"
)?;
writeln!(writer)?;
writeln!(writer, "5. Resource Limits:")?;
write!(
writer,
" Optional session memory/CPU ceilings (e.g. mem=8G, cpu=3600) [Enter to skip]: "
)?;
writer.flush()?;
let mut limits_line = String::new();
reader.read_line(&mut limits_line)?;
let (cpu_seconds, memory_bytes) = parse_resource_limits(&limits_line);
if cpu_seconds.is_some() || memory_bytes.is_some() {
writeln!(
writer,
" Configured limits: cpu={:?}, mem={:?}",
cpu_seconds, memory_bytes
)?;
} else {
writeln!(writer, " Keeping default resource limits (none)")?;
}
writeln!(writer)?;
let network_domains = if net_mode == "allowlist" {
let mut domains = agent_network_allowlist(agent_slug);
domains.extend(analysis.recommended_network_domains.clone());
domains.sort();
domains.dedup();
domains
} else {
Vec::new()
};
let toml = generate_wizard_policy(&WizardConfig {
project_name: analysis.project_name.clone(),
agent_slug,
agent_display: agent_disp,
preset,
net_mode,
ecosystems: analysis
.detected_ecosystems
.iter()
.map(|s| s.to_string())
.collect(),
allow_read: analysis.recommended_allow_read.clone(),
network_domains,
cpu_seconds,
memory_bytes,
});
let target_dir = root.join(".vetto");
let target_policy_path = target_dir.join("policy.toml");
std::fs::create_dir_all(&target_dir)
.with_context(|| format!("failed to create directory {}", target_dir.display()))?;
std::fs::write(&target_policy_path, &toml)
.with_context(|| format!("failed to write policy to {}", target_policy_path.display()))?;
let display_path = if args.path == "." || args.path.is_empty() {
".vetto/policy.toml".to_string()
} else {
target_policy_path.display().to_string()
};
writeln!(
writer,
"✓ Vetto security policy generated at {display_path}"
)?;
writeln!(
writer,
" Preset: {} | Agent: {} | Network: {}",
preset.as_str(),
agent_slug,
net_mode
)?;
writeln!(
writer,
"Quick start: vetto enable {agent_slug} or vetto -- {agent_slug}"
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Cursor;
use std::path::PathBuf;
fn temp_test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"vetto-wizard-test-{name}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn test_wizard_non_interactive_generates_policy() {
let dir = temp_test_dir("non-interactive");
let args = WizardArgs {
path: dir.to_str().unwrap().to_string(),
yes: true,
force: false,
preset: None,
agent: None,
};
run_wizard_cli(&args).expect("run_wizard_cli should succeed");
let policy_path = dir.join(".vetto").join("policy.toml");
assert!(
policy_path.exists(),
"policy.toml must exist at .vetto/policy.toml"
);
let content = fs::read_to_string(&policy_path).unwrap();
let layer: crate::policy::loader::RawLayer =
toml::from_str(&content).expect("generated policy must be valid TOML RawLayer");
assert!(layer.metadata.is_some());
assert!(layer.filesystem.is_some());
assert!(layer.network.is_some());
assert_eq!(
layer.network.as_ref().unwrap().mode.as_deref(),
Some("allowlist")
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_wizard_preset_paranoid() {
let dir = temp_test_dir("preset-paranoid");
let args = WizardArgs {
path: dir.to_str().unwrap().to_string(),
yes: true,
force: false,
preset: Some("paranoid".to_string()),
agent: None,
};
run_wizard_cli(&args).expect("run_wizard_cli should succeed with paranoid preset");
let policy_path = dir.join(".vetto").join("policy.toml");
let content = fs::read_to_string(&policy_path).unwrap();
let layer: crate::policy::loader::RawLayer =
toml::from_str(&content).expect("must parse as valid RawLayer");
let net = layer.network.as_ref().expect("must have network section");
assert_eq!(
net.mode.as_deref(),
Some("off"),
"paranoid must set network mode to off"
);
let fs_layer = layer
.filesystem
.as_ref()
.expect("must have filesystem section");
let read_paths = fs_layer
.allow_read
.as_ref()
.expect("must have allow_read")
.clone()
.into_vec();
assert_eq!(
read_paths,
vec!["$PROJECT".to_string()],
"paranoid must strictly allow reading only $PROJECT"
);
let write_paths = fs_layer
.allow_write
.as_ref()
.expect("must have allow_write")
.clone()
.into_vec();
assert!(
!write_paths.contains(&"$PROJECT".to_string()),
"paranoid must have strict read-only workspace"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_wizard_respects_force_flag() {
let dir = temp_test_dir("respects-force");
let args = WizardArgs {
path: dir.to_str().unwrap().to_string(),
yes: true,
force: false,
preset: None,
agent: None,
};
assert!(run_wizard_cli(&args).is_ok());
let err = run_wizard_cli(&args).expect_err("second run without force must fail");
let msg = err.to_string();
assert!(
msg.contains("already exists") && (msg.contains("--force") || msg.contains("-f")),
"error must advise force flag, got: {msg}"
);
let force_args = WizardArgs {
force: true,
..args
};
assert!(run_wizard_cli(&force_args).is_ok());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_wizard_supports_antigravity() {
let dir = temp_test_dir("antigravity");
let args = WizardArgs {
path: dir.to_str().unwrap().to_string(),
yes: true,
force: false,
preset: None,
agent: Some("antigravity".to_string()),
};
run_wizard_cli(&args).expect("run_wizard_cli should succeed for antigravity");
let policy_path = dir.join(".vetto").join("policy.toml");
let content = fs::read_to_string(&policy_path).unwrap();
let layer: crate::policy::loader::RawLayer =
toml::from_str(&content).expect("must parse as valid RawLayer");
let net = layer.network.as_ref().expect("must have network section");
let allow = net
.allow
.as_ref()
.expect("must have allow list")
.clone()
.into_vec();
assert!(
allow.iter().any(|d| d.contains("googleapis.com")),
"network allowlist must include Google API domain for antigravity, got: {:?}",
allow
);
assert!(
content.contains("generativelanguage.googleapis.com"),
"policy must contain generativelanguage.googleapis.com"
);
assert!(
content.contains("Antigravity CLI"),
"policy metadata must mention Antigravity CLI"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_wizard_interactive_flow() {
let dir = temp_test_dir("interactive");
let args = WizardArgs {
path: dir.to_str().unwrap().to_string(),
yes: false,
force: false,
preset: None,
agent: None,
};
let input = "3\n1\n1\n\n";
let mut reader = Cursor::new(input.as_bytes());
let mut writer = Vec::new();
run_wizard_flow(&args, true, &mut reader, &mut writer)
.expect("interactive flow should succeed");
let policy_path = dir.join(".vetto").join("policy.toml");
assert!(policy_path.exists());
let content = fs::read_to_string(&policy_path).unwrap();
assert!(content.contains("Antigravity CLI"));
assert!(content.contains("generativelanguage.googleapis.com"));
let output = String::from_utf8(writer).unwrap();
assert!(output.contains("Vetto security policy generated at"));
assert!(output.contains("antigravity"));
let _ = fs::remove_dir_all(dir);
}
}