feat(config): load typed ask permissions file

(cherry picked from commit fb77cf1e0946a061376e5e9a8fc9422dddd98419)
This commit is contained in:
greyfreedom
2026-05-31 20:13:40 +08:00
committed by Hunter B
parent 18550339a5
commit 3df018994f
6 changed files with 220 additions and 12 deletions
Generated
+1
View File
@@ -866,6 +866,7 @@ name = "codewhale-config"
version = "0.8.49"
dependencies = [
"anyhow",
"codewhale-execpolicy",
"codewhale-secrets",
"dirs",
"serde",
+15
View File
@@ -133,6 +133,21 @@ allow_shell = true
approval_policy = "on-request" # on-request | untrusted | never
sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox
# Typed permission rules live in a sibling `permissions.toml` file, not in
# config.toml. This schema slice is ask-only and is parsed for follow-up
# approval-flow wiring; allow/deny records and UI persistence are intentionally
# out of scope here.
#
# Example ~/.codewhale/permissions.toml:
#
# [[rules]]
# tool = "exec_shell"
# command = "cargo test"
#
# [[rules]]
# tool = "read_file"
# path = "secrets/**"
# ─────────────────────────────────────────────────────────────────────────────────
# External Sandbox Backend (pluggable remote execution)
# ─────────────────────────────────────────────────────────────────────────────────
+1
View File
@@ -8,6 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite
[dependencies]
anyhow.workspace = true
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.49" }
codewhale-secrets = { path = "../secrets", version = "0.8.49" }
dirs.workspace = true
serde.workspace = true
+196 -12
View File
@@ -6,6 +6,7 @@ use std::path::{Component, Path, PathBuf};
use std::sync::OnceLock;
use anyhow::{Context, Result, bail};
pub use codewhale_execpolicy::ToolAskRule;
use codewhale_secrets::SecretSource;
pub use codewhale_secrets::Secrets;
use serde::{Deserialize, Serialize};
@@ -14,6 +15,7 @@ use serde::{Deserialize, Serialize};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
pub const CONFIG_FILE_NAME: &str = "config.toml";
pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
@@ -198,6 +200,25 @@ pub struct ProvidersToml {
pub ollama: ProviderConfigToml,
}
/// Sibling `permissions.toml` schema.
///
/// This slice is intentionally ask-only: each rule is a typed condition that
/// means "ask before this tool invocation." Typed allow/deny records and UI
/// persistence are expected to land in follow-up PRs.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PermissionsToml {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<ToolAskRule>,
}
impl PermissionsToml {
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
impl ProvidersToml {
#[must_use]
pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
@@ -1751,26 +1772,26 @@ pub struct ResolvedRuntimeOptions {
pub struct ConfigStore {
path: PathBuf,
pub config: ConfigToml,
permissions: PermissionsToml,
}
impl ConfigStore {
pub fn load(path: Option<PathBuf>) -> Result<Self> {
let path = resolve_config_path(path)?;
if !path.exists() {
return Ok(Self {
path,
config: ConfigToml::default(),
});
}
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read config at {}", path.display()))?;
let parsed: ConfigToml = toml::from_str(&raw)
.with_context(|| format!("failed to parse config at {}", path.display()))?;
let config = if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read config at {}", path.display()))?;
toml::from_str(&raw)
.with_context(|| format!("failed to parse config at {}", path.display()))?
} else {
ConfigToml::default()
};
let permissions = load_sibling_permissions(&path)?;
Ok(Self {
path,
config: parsed,
config,
permissions,
})
}
@@ -1812,6 +1833,16 @@ impl ConfigStore {
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn permissions(&self) -> &PermissionsToml {
&self.permissions
}
#[must_use]
pub fn permissions_path(&self) -> PathBuf {
permissions_path_for_config_path(&self.path)
}
}
/// Process-wide default [`Secrets`] façade. The first caller wins; the
@@ -1949,6 +1980,37 @@ pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
normalize_config_file_path(path)
}
#[must_use]
pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
config_path.with_file_name(PERMISSIONS_FILE_NAME)
}
pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
Ok(permissions_path_for_config_path(&resolve_config_path(
config_path,
)?))
}
fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
let permissions_path = permissions_path_for_config_path(config_path);
if !permissions_path.exists() {
return Ok(PermissionsToml::default());
}
let raw = fs::read_to_string(&permissions_path).with_context(|| {
format!(
"failed to read permissions at {}",
permissions_path.display()
)
})?;
toml::from_str(&raw).with_context(|| {
format!(
"failed to parse permissions at {}",
permissions_path.display()
)
})
}
pub fn default_config_path() -> Result<PathBuf> {
// Prefer ~/.codewhale/config.toml when it exists (fresh install or
// migrated), otherwise fall back to ~/.deepseek/config.toml.
@@ -2285,6 +2347,127 @@ mod tests {
assert!(policy.audit);
}
#[test]
fn permissions_toml_deserializes_typed_ask_rules() {
let permissions: PermissionsToml = toml::from_str(
r#"
[[rules]]
tool = "exec_shell"
command = "cargo test"
[[rules]]
tool = "read_file"
path = "secrets/**"
"#,
)
.expect("permissions toml");
assert_eq!(
permissions.rules,
vec![
ToolAskRule::exec_shell("cargo test"),
ToolAskRule::file_path("read_file", "secrets/**"),
]
);
}
#[test]
fn permissions_toml_rejects_typed_allow_deny_shape() {
let err = toml::from_str::<PermissionsToml>(
r#"
[[rules]]
tool = "exec_shell"
decision = "allow"
command = "cargo test"
"#,
)
.expect_err("permissions.toml should be ask-only in this slice");
assert!(err.message().contains("unknown field"));
}
#[test]
fn config_store_loads_sibling_permissions_toml() {
use std::time::{SystemTime, UNIX_EPOCH};
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"codewhale-permissions-schema-{}-{unique}",
std::process::id()
));
fs::create_dir_all(&dir).expect("mkdir");
let config_path = dir.join(CONFIG_FILE_NAME);
fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
fs::write(
dir.join(PERMISSIONS_FILE_NAME),
r#"
[[rules]]
tool = "exec_shell"
command = "cargo test"
[[rules]]
tool = "read_file"
path = "secrets/**"
"#,
)
.expect("write permissions");
let store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash"));
assert_eq!(
store.permissions().rules.as_slice(),
&[
ToolAskRule::exec_shell("cargo test"),
ToolAskRule::file_path("read_file", "secrets/**"),
]
);
assert_eq!(
store.permissions_path(),
config_path.with_file_name(PERMISSIONS_FILE_NAME)
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn config_store_loads_permissions_even_when_config_is_absent() {
use std::time::{SystemTime, UNIX_EPOCH};
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"codewhale-permissions-only-{}-{unique}",
std::process::id()
));
fs::create_dir_all(&dir).expect("mkdir");
let config_path = dir.join(CONFIG_FILE_NAME);
fs::write(
dir.join(PERMISSIONS_FILE_NAME),
r#"
[[rules]]
tool = "exec_shell"
command = "cargo check"
"#,
)
.expect("write permissions");
let store = ConfigStore::load(Some(config_path)).expect("load config store");
assert!(store.config.model.is_none());
assert_eq!(
store.permissions().rules.as_slice(),
&[ToolAskRule::exec_shell("cargo check")]
);
let _ = fs::remove_dir_all(dir);
}
struct EnvGuard {
deepseek_api_key: Option<OsString>,
deepseek_base_url: Option<OsString>,
@@ -3143,6 +3326,7 @@ unix_socket_path = "/tmp/cw-hooks.sock"
api_key: Some("new-secret".to_string()),
..ConfigToml::default()
},
permissions: PermissionsToml::default(),
};
store.save().expect("save");
+1
View File
@@ -75,6 +75,7 @@ impl Ruleset {
/// prefix behavior is preserved while typed ask records can make
/// `AskForApproval::Never` reject invocations that cannot be approved.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ToolAskRule {
/// Name of the tool this rule applies to (e.g. `"exec_shell"`, `"edit_file"`).
pub tool: String,
+6
View File
@@ -601,6 +601,12 @@ If you are upgrading from older releases:
with process-tree containment only and must not be described as read-only
filesystem isolation, workspace-write enforcement, network blocking,
registry isolation, or AppContainer isolation until those are implemented.
- `permissions.toml` (sibling file, optional): ask-only typed permission rule
records loaded next to `config.toml`, for example
`~/.codewhale/permissions.toml`. This schema foundation accepts
`[[rules]]` entries with `tool` plus optional `command` or `path` fields.
It intentionally does not accept typed allow/deny records or provide approval
UI persistence yet.
- `managed_config_path` (string, optional): managed config file loaded after user/env config.
- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.
- `max_subagents` (int, optional): defaults to `10` and is clamped to `1..=20`.