From 3df018994fc62e21a452a2eff18d22d29e297651 Mon Sep 17 00:00:00 2001 From: greyfreedom Date: Sun, 31 May 2026 20:13:40 +0800 Subject: [PATCH] feat(config): load typed ask permissions file (cherry picked from commit fb77cf1e0946a061376e5e9a8fc9422dddd98419) --- Cargo.lock | 1 + config.example.toml | 15 +++ crates/config/Cargo.toml | 1 + crates/config/src/lib.rs | 208 +++++++++++++++++++++++++++++++++-- crates/execpolicy/src/lib.rs | 1 + docs/CONFIGURATION.md | 6 + 6 files changed, 220 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 328d1930..50d4b04e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -866,6 +866,7 @@ name = "codewhale-config" version = "0.8.49" dependencies = [ "anyhow", + "codewhale-execpolicy", "codewhale-secrets", "dirs", "serde", diff --git a/config.example.toml b/config.example.toml index b4d21c15..562fa14e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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) # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 442d3666..726c0630 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -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 diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index a09569a3..866ab5a5 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -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, +} + +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) -> Result { 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) -> Result { 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) -> Result { + Ok(permissions_path_for_config_path(&resolve_config_path( + config_path, + )?)) +} + +fn load_sibling_permissions(config_path: &Path) -> Result { + 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 { // 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::( + 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, deepseek_base_url: Option, @@ -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"); diff --git a/crates/execpolicy/src/lib.rs b/crates/execpolicy/src/lib.rs index 4489b0eb..8a600304 100644 --- a/crates/execpolicy/src/lib.rs +++ b/crates/execpolicy/src/lib.rs @@ -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, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index de9a03a6..41251e7a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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`.