use std::path::Path;
use serde::{Deserialize, Serialize};
use super::Config;
use crate::error::{NewtError, Result};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ModeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skill: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub framing: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PermissionPreset {
ReadOnly,
WorkspaceEdit,
#[default]
WorkspaceDev,
WorkspaceFullAccess,
FullAccess,
Custom,
}
impl PermissionPreset {
pub const ALL: [Self; 5] = [
Self::ReadOnly,
Self::WorkspaceEdit,
Self::WorkspaceDev,
Self::WorkspaceFullAccess,
Self::FullAccess,
];
pub fn as_str(&self) -> &'static str {
match self {
Self::ReadOnly => "read_only",
Self::WorkspaceEdit => "workspace_edit",
Self::WorkspaceDev => "workspace_dev",
Self::WorkspaceFullAccess => "workspace_full_access",
Self::FullAccess => "full_access",
Self::Custom => "custom",
}
}
pub fn toggle(&self) -> Self {
let idx = Self::ALL.iter().position(|p| p == self).unwrap_or(2);
Self::ALL[(idx + 1) % Self::ALL.len()].clone()
}
pub fn description(&self) -> &'static str {
match self {
Self::ReadOnly => "read files + list dirs; no writes, no commands",
Self::WorkspaceEdit => "read + write workspace; no shell commands",
Self::WorkspaceDev => "read, write workspace, run: cargo just git grep rg fd ...",
Self::WorkspaceFullAccess => "full access inside workspace: commands + create/change/rename/delete; network separate",
Self::FullAccess => "unrestricted (prompts y/N before each write)",
Self::Custom => "workspace-dev tools plus your extra commands",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ToolPermissions {
pub preset: PermissionPreset,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_exec: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub net: Vec<String>,
#[serde(default)]
pub prompt: bool,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_prompt_default"
)]
pub prompt_default: Option<crate::PermissionAction>,
#[serde(deserialize_with = "deserialize_mcp_net_prompt_default")]
pub mcp_net_prompt_default: crate::PermissionAction,
}
fn deserialize_prompt_default<'de, D>(
deserializer: D,
) -> std::result::Result<Option<crate::PermissionAction>, D::Error>
where
D: serde::Deserializer<'de>,
{
let action = crate::PermissionAction::deserialize(deserializer)?;
match action {
crate::PermissionAction::AllowOnce | crate::PermissionAction::Deny => Ok(Some(action)),
_ => Err(serde::de::Error::custom(
"prompt_default must be allow_once or deny",
)),
}
}
fn deserialize_mcp_net_prompt_default<'de, D>(
deserializer: D,
) -> std::result::Result<crate::PermissionAction, D::Error>
where
D: serde::Deserializer<'de>,
{
let action = crate::PermissionAction::deserialize(deserializer)?;
if matches!(
action,
crate::PermissionAction::Back | crate::PermissionAction::Exit
) {
return Err(serde::de::Error::custom(
"mcp_net_prompt_default must name a permission decision, not back or exit",
));
}
Ok(action)
}
impl Default for ToolPermissions {
fn default() -> Self {
Self {
preset: PermissionPreset::WorkspaceDev,
extra_exec: Vec::new(),
net: Vec::new(),
prompt: false,
prompt_default: None,
mcp_net_prompt_default: crate::PermissionAction::AllowOnce,
}
}
}
impl ToolPermissions {
const WORKSPACE_DEV_EXEC: &'static [&'static str] = &[
"cargo",
"rustc",
"just",
"git",
"grep",
"rg",
"ripgrep",
"fd",
"find",
"cat",
"ls",
"echo",
"pwd",
"true",
"false",
"head",
"tail",
"wc",
"tr",
"sort",
"uniq",
"diff",
"patch",
"rustfmt",
"clippy-driver",
"rustup",
"gh",
"python",
"python3",
"pip",
"npm",
"node",
"make",
"jq",
"curl",
"awk",
"sed",
"cut",
"xargs",
"which",
"env",
];
pub fn to_caveats(&self, workspace: &str) -> crate::caveats::Caveats {
use crate::caveats::{Caveats, CountBound, Scope};
let ws = workspace.to_string();
let net = self.net_scope();
match self.preset {
PermissionPreset::ReadOnly => Caveats {
fs_read: Scope::All,
fs_write: Scope::none(),
exec: Scope::none(),
net,
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
},
PermissionPreset::WorkspaceEdit => Caveats {
fs_read: Scope::All,
fs_write: Scope::only([ws]),
exec: Scope::none(),
net,
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
},
PermissionPreset::WorkspaceDev | PermissionPreset::Custom => {
let mut allowed: std::collections::BTreeSet<String> = Self::WORKSPACE_DEV_EXEC
.iter()
.map(|s| s.to_string())
.collect();
for cmd in &self.extra_exec {
allowed.insert(cmd.clone());
}
Caveats {
fs_read: Scope::All,
fs_write: Scope::only([ws]),
exec: Scope::Only(allowed),
net,
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
}
}
PermissionPreset::WorkspaceFullAccess => {
let mut caveats = crate::confined_exec::workspace_confined_caveats(Path::new(&ws));
caveats.net = net;
caveats
}
PermissionPreset::FullAccess => Caveats::top(),
}
}
pub(super) fn net_scope(&self) -> crate::caveats::Scope<String> {
use crate::caveats::Scope;
if self.net.is_empty() {
Scope::none()
} else if self.net.iter().any(|h| h == "*") {
Scope::All
} else {
Scope::only(self.net.iter().cloned())
}
}
}
impl Config {
#[must_use]
pub fn mcp_probe_caveats(&self, workspace: &Path) -> crate::caveats::Caveats {
let ws = workspace.to_string_lossy();
self.tui
.as_ref()
.map(|t| t.permissions.to_caveats(&ws))
.unwrap_or_else(|| {
ToolPermissions {
preset: PermissionPreset::ReadOnly,
extra_exec: Vec::new(),
net: Vec::new(),
prompt: false,
..Default::default()
}
.to_caveats(&ws)
})
}
pub fn with_net_host(text: &str, host: &str) -> Result<String> {
Self::update_permissions_text(text, |perms_tbl| {
let net =
perms_tbl
.entry("net")
.or_insert(toml_edit::Item::Value(toml_edit::Value::Array(
toml_edit::Array::new(),
)));
let arr = net.as_array_mut().ok_or_else(|| {
NewtError::Config("[tui.permissions] net is not an array".to_string())
})?;
if !arr.iter().any(|v| v.as_str() == Some(host)) {
arr.push(host);
}
Ok(())
})
}
fn update_permissions_text(
text: &str,
update: impl FnOnce(&mut toml_edit::Table) -> Result<()>,
) -> Result<String> {
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let tui = doc
.as_table_mut()
.entry("tui")
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
let tui_tbl = tui
.as_table_mut()
.ok_or_else(|| NewtError::Config("[tui] is not a table".to_string()))?;
let perms = tui_tbl
.entry("permissions")
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
let perms_tbl = perms
.as_table_mut()
.ok_or_else(|| NewtError::Config("[tui.permissions] is not a table".to_string()))?;
update(perms_tbl)?;
Ok(doc.to_string())
}
pub fn append_permission_net_host(path: &Path, host: &str) -> Result<()> {
Self::update_permissions_file(path, |text| Self::with_net_host(text, host))
}
pub fn set_permission_prompt_default(
path: &Path,
action: crate::PermissionAction,
) -> Result<()> {
if !matches!(
action,
crate::PermissionAction::AllowOnce | crate::PermissionAction::Deny
) {
return Err(NewtError::Config(
"permission prompt default must be allow_once or deny".into(),
));
}
Self::update_permissions_file(path, |text| {
Self::update_permissions_text(text, |permissions| {
let mut value = toml_edit::Value::from(action.as_str());
if let Some(previous) = permissions
.get("prompt_default")
.and_then(toml_edit::Item::as_value)
{
*value.decor_mut() = previous.decor().clone();
}
permissions.insert("prompt_default", toml_edit::Item::Value(value));
Ok(())
})
})
}
fn update_permissions_file(
path: &Path,
update: impl FnOnce(&str) -> Result<String>,
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
}
let destination = crate::atomic_fs::ResolvedPath::resolve(path).map_err(|error| {
NewtError::Config(format!(
"resolve config destination for {}: {error:#}",
path.display()
))
})?;
let _lock = crate::atomic_fs::acquire_lock(&destination.lock_path())
.map_err(|error| NewtError::Config(format!("lock {}: {error:#}", path.display())))?;
let text = match std::fs::read_to_string(destination.as_path()) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(NewtError::Io(error)),
};
let updated = update(&text)?;
destination
.atomic_write(updated.as_bytes())
.map_err(|error| NewtError::Config(format!("write {}: {error:#}", path.display())))
}
}