From 782f3a1180e5916f83187fb0180265f3caa7491a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 00:16:58 +0800 Subject: [PATCH] Add file policy engine --- config.example.toml | 1 + crates/tui/src/core/engine.rs | 46 ++++ crates/tui/src/core/engine/capacity_flow.rs | 1 + crates/tui/src/core/engine/tests.rs | 257 ++++++++++++++++++ crates/tui/src/core/engine/tool_execution.rs | 15 ++ crates/tui/src/core/engine/turn_loop.rs | 43 ++- crates/tui/src/execpolicy/mod.rs | 3 +- crates/tui/src/execpolicy/rules.rs | 262 ++++++++++++++++++- crates/tui/src/features.rs | 8 + docs/file-policy-design.md | 64 +++++ refs/execpolicy.example.toml | 27 ++ 11 files changed, 723 insertions(+), 4 deletions(-) create mode 100644 docs/file-policy-design.md create mode 100644 refs/execpolicy.example.toml diff --git a/config.example.toml b/config.example.toml index 08f8fff0f..fd6740f5e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -319,6 +319,7 @@ web_search = true # enables canonical web.run plus the compatibility web_search apply_patch = true mcp = true exec_policy = true +file_policy = true # vision_model = false # enable vision model for image_analyze tool # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 022604105..b161c4bb8 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -331,6 +331,8 @@ pub struct Engine { /// Diagnostics collected during the current step's tool calls. Drained /// and forwarded as a synthetic user message before the next API call. pending_lsp_blocks: Vec, + /// File access policy loaded from `~/.deepseek/execpolicy.toml`. + file_policy: Option>, } // === Internal tool helpers === @@ -397,6 +399,40 @@ impl Engine { format!("{message}\n\n{hint}") } + /// Check a file-tool call against the loaded file policy. + /// Returns `Some(ToolError)` when the call should be blocked. + fn check_file_policy( + &self, + tool_name: &str, + tool_input: &serde_json::Value, + ) -> Option { + let policy = self.file_policy.as_ref()?; + Self::evaluate_file_policy(policy, tool_name, tool_input) + } + + /// Stand-alone file-policy evaluator so `execute_tool_with_lock` can reuse + /// the same guard in paths that do not have `&self`. + pub(super) fn evaluate_file_policy( + policy: &crate::execpolicy::ExecPolicyConfig, + tool_name: &str, + tool_input: &serde_json::Value, + ) -> Option { + if !crate::execpolicy::is_file_tool(tool_name) { + return None; + } + + for path in crate::execpolicy::extract_file_paths(tool_input, tool_name) { + if let crate::execpolicy::ExecPolicyDecision::Deny(reason) = + policy.evaluate_file(tool_name, &path) + { + return Some(ToolError::permission_denied(format!( + "File policy blocked: {reason}" + ))); + } + } + None + } + /// Create a new engine with the given configuration pub fn new(config: EngineConfig, api_config: &Config) -> (Self, EngineHandle) { let (tx_op, rx_op) = mpsc::channel(32); @@ -536,6 +572,15 @@ impl Engine { }) .map(std::sync::Arc::from); + let file_policy = if config.features.enabled(Feature::FilePolicy) { + crate::execpolicy::load_default_policy() + .ok() + .flatten() + .map(Arc::new) + } else { + None + }; + let mut engine = Engine { config, deepseek_client, @@ -564,6 +609,7 @@ impl Engine { pending_lsp_blocks: Vec::new(), workshop_vars, sandbox_backend, + file_policy, }; engine.rehydrate_latest_canonical_state(); diff --git a/crates/tui/src/core/engine/capacity_flow.rs b/crates/tui/src/core/engine/capacity_flow.rs index cee5fb76e..80178489c 100644 --- a/crates/tui/src/core/engine/capacity_flow.rs +++ b/crates/tui/src/core/engine/capacity_flow.rs @@ -528,6 +528,7 @@ impl Engine { tool_registry, mcp_pool.clone(), None, + None, ) .await; diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index ca3c410aa..d71cf5796 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -2388,3 +2388,260 @@ async fn post_edit_hook_skips_unknown_tool_names() { assert!(engine.pending_lsp_blocks.is_empty()); assert_eq!(fake.call_count(), 0); } + +// === File policy engine tests ============================================== + +fn make_file_policy(deny: Vec<&str>, allow: Vec<&str>) -> crate::execpolicy::ExecPolicyConfig { + use crate::execpolicy::rules::FileRuleSet; + use std::collections::BTreeMap; + + let file_rules = BTreeMap::from([( + "write_file".to_string(), + FileRuleSet { + allow: allow.into_iter().map(String::from).collect(), + deny: deny.into_iter().map(String::from).collect(), + }, + )]); + + crate::execpolicy::ExecPolicyConfig { + rules: BTreeMap::new(), + file_rules, + } +} + +#[test] +fn file_policy_blocks_denied_write_file() { + let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + engine.file_policy = Some(Arc::new(make_file_policy( + vec!["src/secrets.rs", ".env"], + vec!["src/**/*.rs"], + ))); + + let input = json!({ "path": "src/secrets.rs" }); + let err = engine.check_file_policy("write_file", &input); + assert!(err.is_some(), "denied path must return an error"); + let msg = err.unwrap().to_string(); + assert!( + msg.contains("File policy blocked"), + "error must mention policy block: {msg}" + ); + assert!( + msg.contains("src/secrets.rs"), + "error must contain the denied path: {msg}" + ); +} + +#[test] +fn file_policy_allows_permitted_write_file() { + let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + engine.file_policy = Some(Arc::new(make_file_policy( + vec!["src/secrets.rs"], + vec!["src/**/*.rs", "*.md"], + ))); + + let input = json!({ "path": "src/main.rs" }); + assert!( + engine.check_file_policy("write_file", &input).is_none(), + "allowed path must not be blocked" + ); +} + +#[test] +fn file_policy_allows_when_no_file_rules_configured() { + let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + engine.file_policy = None; + + let input = json!({ "path": "anything.txt" }); + assert!( + engine.check_file_policy("write_file", &input).is_none(), + "missing policy must not block" + ); +} + +#[test] +fn file_policy_skips_non_file_tools() { + let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + engine.file_policy = Some(Arc::new(make_file_policy(vec!["*"], vec![]))); + + let input = json!({ "command": "rm -rf /" }); + assert!( + engine.check_file_policy("exec_shell", &input).is_none(), + "exec_shell must never be checked by file policy" + ); +} + +#[test] +fn file_policy_skips_when_path_field_is_missing() { + let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + engine.file_policy = Some(Arc::new(make_file_policy( + vec!["src/secrets.rs"], + vec!["src/**/*.rs"], + ))); + + let input = json!({ "content": "hello" }); + assert!( + engine.check_file_policy("write_file", &input).is_none(), + "missing path must not trigger policy block" + ); +} + +#[test] +fn file_policy_blocks_read_file_when_category_has_deny_rule() { + use crate::execpolicy::rules::FileRuleSet; + use std::collections::BTreeMap; + + let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + let file_rules = BTreeMap::from([( + "read_file".to_string(), + FileRuleSet { + allow: vec!["*.md".to_string()], + deny: vec![".env".to_string()], + }, + )]); + engine.file_policy = Some(Arc::new(crate::execpolicy::ExecPolicyConfig { + rules: BTreeMap::new(), + file_rules, + })); + + let input = json!({ "path": ".env" }); + let err = engine.check_file_policy("read_file", &input); + assert!(err.is_some(), "read_file to denied path must be blocked"); +} + +#[test] +fn file_policy_falls_back_to_default_category() { + use crate::execpolicy::rules::FileRuleSet; + use std::collections::BTreeMap; + + let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + let file_rules = BTreeMap::from([( + "default".to_string(), + FileRuleSet { + allow: vec!["*".to_string()], + deny: vec![".env".to_string()], + }, + )]); + engine.file_policy = Some(Arc::new(crate::execpolicy::ExecPolicyConfig { + rules: BTreeMap::new(), + file_rules, + })); + + let input = json!({ "path": ".env" }); + let err = engine.check_file_policy("edit_file", &input); + assert!( + err.is_some(), + "edit_file to .env must be blocked via default category" + ); + + let input = json!({ "path": "README.md" }); + assert!( + engine.check_file_policy("edit_file", &input).is_none(), + "edit_file to allowed path must pass via default category" + ); +} + +#[test] +fn file_policy_feature_enabled_by_default() { + let features = Features::with_defaults(); + assert!( + features.enabled(Feature::FilePolicy), + "FilePolicy must be enabled by default" + ); +} + +#[test] +fn file_policy_not_loaded_when_feature_disabled() { + let mut features = Features::with_defaults(); + features.disable(Feature::FilePolicy); + + let config = EngineConfig { + features, + ..Default::default() + }; + let (engine, _handle) = Engine::new(config, &Config::default()); + assert!( + engine.file_policy.is_none(), + "file_policy must be None when FilePolicy feature is disabled" + ); +} + +#[test] +fn evaluate_file_policy_static_method_blocks_denied_paths() { + let policy = crate::execpolicy::ExecPolicyConfig { + rules: std::collections::BTreeMap::new(), + file_rules: std::collections::BTreeMap::from([( + "write_file".to_string(), + crate::execpolicy::rules::FileRuleSet { + allow: vec!["src/**/*.rs".to_string()], + deny: vec![".env".to_string()], + }, + )]), + }; + + let allowed = json!({ "path": "src/main.rs" }); + assert!( + Engine::evaluate_file_policy(&policy, "write_file", &allowed).is_none(), + "allowed path must not be blocked by static evaluator" + ); + + let denied = json!({ "path": ".env" }); + let err = Engine::evaluate_file_policy(&policy, "write_file", &denied); + assert!( + err.is_some(), + "denied path must be blocked by static evaluator" + ); + let msg = err.unwrap().to_string(); + assert!( + msg.contains("File policy blocked"), + "error must mention policy block: {msg}" + ); +} + +#[test] +fn evaluate_file_policy_blocks_denied_apply_patch_paths() { + let policy = crate::execpolicy::ExecPolicyConfig { + rules: std::collections::BTreeMap::new(), + file_rules: std::collections::BTreeMap::from([( + "apply_patch".to_string(), + crate::execpolicy::rules::FileRuleSet { + allow: vec!["src/**/*.rs".to_string()], + deny: vec![".env".to_string()], + }, + )]), + }; + let input = json!({ + "patch": "diff --git a/.env b/.env\n--- a/.env\n+++ b/.env\n@@ -1 +1 @@\n-old\n+new\n" + }); + + let err = Engine::evaluate_file_policy(&policy, "apply_patch", &input); + assert!( + err.is_some(), + "denied path inside apply_patch must be blocked" + ); + let msg = err.unwrap().to_string(); + assert!( + msg.contains(".env"), + "error must contain denied path: {msg}" + ); +} + +#[test] +fn file_policy_feature_switch_controls_engine_loading() { + let enabled_config = EngineConfig { + features: Features::with_defaults(), + ..Default::default() + }; + let (_engine_enabled, _handle) = Engine::new(enabled_config, &Config::default()); + + let mut disabled_features = Features::with_defaults(); + disabled_features.disable(Feature::FilePolicy); + let disabled_config = EngineConfig { + features: disabled_features, + ..Default::default() + }; + let (engine_disabled, _handle) = Engine::new(disabled_config, &Config::default()); + assert!( + engine_disabled.file_policy.is_none(), + "disabling FilePolicy feature must prevent policy loading" + ); +} diff --git a/crates/tui/src/core/engine/tool_execution.rs b/crates/tui/src/core/engine/tool_execution.rs index e24aae5b9..7d4f42644 100644 --- a/crates/tui/src/core/engine/tool_execution.rs +++ b/crates/tui/src/core/engine/tool_execution.rs @@ -211,6 +211,7 @@ impl Engine { let lock = tool_exec_lock.clone(); let tx_event = self.tx_event.clone(); let mcp_pool = mcp_pool.clone(); + let file_policy = self.file_policy.clone(); tasks.push(async move { let result = Engine::execute_tool_with_lock( lock, @@ -222,6 +223,7 @@ impl Engine { Some(registry_ref), mcp_pool, None, + file_policy, ) .await; (tool_name, result) @@ -270,6 +272,7 @@ impl Engine { registry: Option<&crate::tools::ToolRegistry>, mcp_pool: Option>>, context_override: Option, + file_policy: Option>, ) -> Result { let started_at = std::time::Instant::now(); let dispatch = if McpPool::is_mcp_tool(&tool_name) { @@ -298,6 +301,18 @@ impl Engine { ToolExecGuard::Write(lock.write().await) }; + if let Some(policy) = file_policy.as_ref() + && let Some(err) = Engine::evaluate_file_policy(policy, &tool_name, &tool_input) + { + emit_tool_audit(json!({ + "event": "tool.file_policy_denied", + "tool_name": tool_name.clone(), + "error": err.to_string(), + "enforcement": "execute_tool_with_lock", + })); + return Err(err); + } + // RAII pause/resume: ensures `Event::ResumeEvents` always fires on // drop, even if the tool future is cancelled mid-await. See // `InteractiveTerminalGuard` doc-comment for the regression this diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index e69104e3b..d48791971 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1386,11 +1386,38 @@ impl Engine { }); continue; } + if let Some(err) = self.check_file_policy(&plan.name, &plan.input) { + emit_tool_audit(json!({ + "event": "tool.file_policy_denied", + "tool_id": plan.id.clone(), + "tool_name": plan.name.clone(), + "error": err.to_string(), + })); + let result = Err(err); + let _ = self + .tx_event + .send(Event::ToolCallComplete { + id: plan.id.clone(), + name: plan.name.clone(), + result: result.clone(), + }) + .await; + outcomes[plan.index] = Some(ToolExecOutcome { + index: plan.index, + id: plan.id, + name: plan.name, + input: plan.input, + started_at: Instant::now(), + result, + }); + continue; + } let registry = tool_registry; let lock = tool_exec_lock.clone(); let mcp_pool = mcp_pool.clone(); let tx_event = self.tx_event.clone(); let session_id = self.session.id.clone(); + let file_policy = self.file_policy.clone(); let started_at = Instant::now(); tool_tasks.push(async move { @@ -1404,6 +1431,7 @@ impl Engine { registry, mcp_pool, None, + file_policy, ) .await; @@ -1645,7 +1673,7 @@ impl Engine { } // Handle approval flow: returns (result_override, context_override) - let (result_override, context_override): ( + let (mut result_override, context_override): ( Option>, Option, ) = if plan.approval_required { @@ -1722,6 +1750,18 @@ impl Engine { (None, None) }; + if result_override.is_none() + && let Some(err) = self.check_file_policy(&tool_name, &tool_input) + { + emit_tool_audit(json!({ + "event": "tool.file_policy_denied", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "error": err.to_string(), + })); + result_override = Some(Err(err)); + } + // Per-tool snapshot for surgical undo (#384): capture workspace // state before file-modifying tools execute so `/undo` can // revert the most recent write_file/edit_file/apply_patch. @@ -1754,6 +1794,7 @@ impl Engine { tool_registry, mcp_pool.clone(), context_override, + self.file_policy.clone(), ) .await }; diff --git a/crates/tui/src/execpolicy/mod.rs b/crates/tui/src/execpolicy/mod.rs index 00ced1aaa..8f9584064 100644 --- a/crates/tui/src/execpolicy/mod.rs +++ b/crates/tui/src/execpolicy/mod.rs @@ -24,5 +24,6 @@ pub use rule::Rule; pub use rule::RuleMatch; pub use rule::RuleRef; pub use rules::{ - ExecPolicyConfig, ExecPolicyDecision, default_execpolicy_path, load_default_policy, + ExecPolicyConfig, ExecPolicyDecision, default_execpolicy_path, extract_file_path, + extract_file_paths, is_file_tool, load_default_policy, }; diff --git a/crates/tui/src/execpolicy/rules.rs b/crates/tui/src/execpolicy/rules.rs index 9e36e5459..898913205 100644 --- a/crates/tui/src/execpolicy/rules.rs +++ b/crates/tui/src/execpolicy/rules.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; use anyhow::{Context, Result}; use serde::Deserialize; @@ -9,6 +10,9 @@ use serde::Deserialize; use super::matcher::pattern_matches; use crate::command_safety::prefix_allow_matches; +static PATH_REGEX_CACHE: LazyLock>>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExecPolicyDecision { Allow, @@ -16,13 +20,16 @@ pub enum ExecPolicyDecision { AskUser(String), } -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Clone, Deserialize, Default)] pub struct ExecPolicyConfig { #[serde(default)] pub rules: BTreeMap, + /// File access policy rules keyed by operation category (e.g. "read_file"). + #[serde(default, rename = "file")] + pub file_rules: BTreeMap, } -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Clone, Deserialize, Default)] pub struct RuleSet { #[serde(default)] pub allow: Vec, @@ -30,6 +37,15 @@ pub struct RuleSet { pub deny: Vec, } +/// File-specific rule set using glob-style path patterns. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct FileRuleSet { + #[serde(default)] + pub allow: Vec, + #[serde(default)] + pub deny: Vec, +} + impl ExecPolicyConfig { pub fn from_str(contents: &str) -> Result { toml::from_str(contents).context("failed to parse execpolicy.toml") @@ -67,12 +83,151 @@ impl ExecPolicyConfig { ExecPolicyDecision::AskUser("execpolicy: no matching allow rule".to_string()) } + + /// Evaluate a file path against the configured file policy rules. + pub fn evaluate_file(&self, category: &str, path: &str) -> ExecPolicyDecision { + let mut rule_sets = Vec::new(); + if let Some(rules) = self.file_rules.get(category) { + rule_sets.push(rules); + } + if category != "default" + && let Some(rules) = self.file_rules.get("default") + { + rule_sets.push(rules); + } + + if rule_sets.is_empty() { + return ExecPolicyDecision::Allow; + } + + for rules in &rule_sets { + for pattern in &rules.deny { + if pattern_matches_path(pattern, path) { + return ExecPolicyDecision::Deny(format!( + "file policy denied by {category}: {pattern}" + )); + } + } + } + + for rules in &rule_sets { + for pattern in &rules.allow { + if pattern_matches_path(pattern, path) { + return ExecPolicyDecision::Allow; + } + } + } + + ExecPolicyDecision::AskUser(format!( + "file policy: no matching allow rule for {category}" + )) + } } pub fn default_execpolicy_path() -> Option { dirs::home_dir().map(|home| home.join(".deepseek").join("execpolicy.toml")) } +fn pattern_matches_path(pattern: &str, path: &str) -> bool { + if pattern == "*" { + return true; + } + + if let Ok(cache) = PATH_REGEX_CACHE.lock() + && let Some(re) = cache.get(pattern) + { + return re.as_ref().is_some_and(|re| re.is_match(path)); + } + + let mut escaped = regex::escape(pattern); + escaped = escaped.replace("\\*\\*/", "(?:.*/)?"); + escaped = escaped.replace("\\*\\*", ".*"); + escaped = escaped.replace("\\*", "[^/\\\\]*"); + + let re_str = format!("^{escaped}$"); + let compiled = regex::Regex::new(&re_str).ok(); + let matched = compiled.as_ref().is_some_and(|re| re.is_match(path)); + if let Ok(mut cache) = PATH_REGEX_CACHE.lock() { + cache.insert(pattern.to_string(), compiled); + } + matched +} + +pub fn is_file_tool(tool_name: &str) -> bool { + matches!( + tool_name, + "read_file" | "write_file" | "edit_file" | "apply_patch" + ) +} + +pub fn extract_file_path<'a>( + tool_input: &'a serde_json::Value, + tool_name: &str, +) -> Option<&'a str> { + match tool_name { + "read_file" | "write_file" | "edit_file" | "apply_patch" => { + tool_input.get("path")?.as_str() + } + _ => None, + } +} + +pub fn extract_file_paths(tool_input: &serde_json::Value, tool_name: &str) -> Vec { + match tool_name { + "read_file" | "write_file" | "edit_file" => extract_file_path(tool_input, tool_name) + .map(|path| vec![path.to_string()]) + .unwrap_or_default(), + "apply_patch" => extract_apply_patch_paths(tool_input), + _ => Vec::new(), + } +} + +fn extract_apply_patch_paths(tool_input: &serde_json::Value) -> Vec { + if let Some(path) = tool_input.get("path").and_then(serde_json::Value::as_str) { + return vec![path.to_string()]; + } + + let Some(patch) = tool_input.get("patch").and_then(serde_json::Value::as_str) else { + return Vec::new(); + }; + + let mut paths = Vec::new(); + for line in patch.lines() { + let candidate = line + .strip_prefix("+++ ") + .or_else(|| line.strip_prefix("--- ")); + let Some(candidate) = candidate else { + continue; + }; + if let Some(path) = normalize_diff_path(candidate) { + push_unique_path(&mut paths, path); + } + } + paths +} + +fn normalize_diff_path(raw: &str) -> Option { + let path = raw.split_whitespace().next()?.trim_matches('"'); + if path == "/dev/null" { + return None; + } + let normalized = path + .strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } +} + +fn push_unique_path(paths: &mut Vec, path: String) { + if !paths.iter().any(|existing| existing == &path) { + paths.push(path); + } +} + pub fn load_default_policy() -> Result> { let Some(path) = default_execpolicy_path() else { return Ok(None); @@ -106,6 +261,7 @@ mod tests { }, ), ]), + ..Default::default() }; assert!(matches!( @@ -137,6 +293,7 @@ mod tests { deny: vec![], }, )]), + ..Default::default() }; assert!(matches!( @@ -164,6 +321,7 @@ mod tests { deny: vec![], }, )]), + ..Default::default() }; assert!(matches!( @@ -179,4 +337,104 @@ mod tests { ExecPolicyDecision::AskUser(_) )); } + + #[test] + fn test_file_policy_allows_and_denies() { + let config = ExecPolicyConfig { + rules: BTreeMap::new(), + file_rules: BTreeMap::from([( + "write_file".to_string(), + FileRuleSet { + allow: vec!["src/**/*.rs".to_string(), "*.md".to_string()], + deny: vec!["src/secrets.rs".to_string(), ".env".to_string()], + }, + )]), + }; + + assert!(matches!( + config.evaluate_file("write_file", "src/main.rs"), + ExecPolicyDecision::Allow + )); + assert!(matches!( + config.evaluate_file("write_file", "README.md"), + ExecPolicyDecision::Allow + )); + assert!(matches!( + config.evaluate_file("write_file", "src/secrets.rs"), + ExecPolicyDecision::Deny(_) + )); + assert!(matches!( + config.evaluate_file("write_file", ".env"), + ExecPolicyDecision::Deny(_) + )); + assert!(matches!( + config.evaluate_file("write_file", "Cargo.lock"), + ExecPolicyDecision::AskUser(_) + )); + } + + #[test] + fn test_file_policy_fallback_to_default_category() { + let config = ExecPolicyConfig { + rules: BTreeMap::new(), + file_rules: BTreeMap::from([( + "default".to_string(), + FileRuleSet { + allow: vec!["*".to_string()], + deny: vec![".env".to_string()], + }, + )]), + }; + + assert!(matches!( + config.evaluate_file("read_file", "README.md"), + ExecPolicyDecision::Allow + )); + assert!(matches!( + config.evaluate_file("edit_file", ".env"), + ExecPolicyDecision::Deny(_) + )); + } + + #[test] + fn test_file_policy_no_rules_configured() { + let config: ExecPolicyConfig = Default::default(); + assert!(matches!( + config.evaluate_file("write_file", "anything.txt"), + ExecPolicyDecision::Allow + )); + } + + #[test] + fn test_pattern_matches_path_directly() { + assert!(pattern_matches_path("*.md", "README.md")); + assert!(pattern_matches_path("src/**/*.rs", "src/main.rs")); + assert!(pattern_matches_path("src/**/*.rs", "src/a/b.rs")); + assert!(!pattern_matches_path("*.md", "src/README.txt")); + } + + #[test] + fn test_extract_apply_patch_path_override() { + let input = serde_json::json!({ + "path": "src/main.rs", + "patch": "--- a/other.rs\n+++ b/other.rs\n@@ -1 +1 @@\n-old\n+new\n" + }); + + assert_eq!( + extract_file_paths(&input, "apply_patch"), + vec!["src/main.rs".to_string()] + ); + } + + #[test] + fn test_extract_apply_patch_paths_from_diff_headers() { + let input = serde_json::json!({ + "patch": "diff --git a/src/a.rs b/src/a.rs\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n-old\n+new\ndiff --git a/.env b/.env\n--- a/.env\n+++ b/.env\n@@ -1 +1 @@\n-old\n+new\n" + }); + + assert_eq!( + extract_file_paths(&input, "apply_patch"), + vec!["src/a.rs".to_string(), ".env".to_string()] + ); + } } diff --git a/crates/tui/src/features.rs b/crates/tui/src/features.rs index ffe363448..cbeefaebe 100644 --- a/crates/tui/src/features.rs +++ b/crates/tui/src/features.rs @@ -44,6 +44,8 @@ pub enum Feature { Mcp, /// Enable execpolicy integration/tooling. ExecPolicy, + /// Enable file access policy checks for file tools. + FilePolicy, /// Enable vision model for image analysis. VisionModel, } @@ -209,6 +211,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: true, }, + FeatureSpec { + id: Feature::FilePolicy, + key: "file_policy", + stage: Stage::Experimental, + default_enabled: true, + }, FeatureSpec { id: Feature::VisionModel, key: "vision_model", diff --git a/docs/file-policy-design.md b/docs/file-policy-design.md new file mode 100644 index 000000000..0204c452c --- /dev/null +++ b/docs/file-policy-design.md @@ -0,0 +1,64 @@ +# File Policy Engine + +The file policy engine extends `execpolicy.toml` with path-level allow and +deny rules for built-in file tools. It is controlled by the `file_policy` +feature flag and is enabled by default. + +## Configuration + +Policies are loaded from `~/.deepseek/execpolicy.toml`. Command rules continue +to use the existing `[rules.]` tables. File rules use `[file.]` +tables keyed by tool name, with an optional `[file.default]` fallback. + +```toml +[file.write_file] +allow = ["src/**/*.rs", "*.md"] +deny = [".env", "src/secrets.rs"] + +[file.read_file] +allow = ["docs/**/*.md", "README.md"] +deny = [".env", "**/*.key"] + +[file.default] +allow = ["*"] +deny = [".env", "**/*.pem", "**/*.key"] +``` + +Deny rules are evaluated before allow rules. If no file rules are configured, +file tools keep the existing permissive behavior. If rules are configured and a +path matches neither allow nor deny, the evaluator returns `AskUser`; current +engine integration treats that as non-blocking and only blocks explicit deny +matches. + +## Matching + +Path patterns are glob-style strings: + +- `*` matches within one path component. +- `**` matches across directory boundaries. +- `src/**/*.rs` matches both `src/main.rs` and `src/bin/tool.rs`. + +## Enforcement Points + +The engine checks file policy in three places: + +- Serial tool execution before snapshotting or running a file tool. +- Parallel tool batches before scheduling each tool task. +- `execute_tool_with_lock` as a defensive guard for alternate execution paths. + +Denied calls return a `ToolError::permission_denied` and emit a +`tool.file_policy_denied` audit event. + +## Covered Tools + +The current file-tool set is: + +- `read_file` +- `write_file` +- `edit_file` +- `apply_patch` + +`apply_patch` is classified as a file tool, but its structured input does not +always expose a single primary path. When `path` is present, that override is +checked. Otherwise, policy extraction scans unified diff headers and checks each +touched file path. diff --git a/refs/execpolicy.example.toml b/refs/execpolicy.example.toml new file mode 100644 index 000000000..8dd451069 --- /dev/null +++ b/refs/execpolicy.example.toml @@ -0,0 +1,27 @@ +# Example execpolicy.toml with command and file rules. +# +# Place this at ~/.deepseek/execpolicy.toml and adjust paths for your project. + +[rules.safe] +allow = ["git status", "git log *", "cargo check"] +deny = ["git push --force", "rm -rf /"] + +[file.read_file] +allow = ["README.md", "docs/**/*.md", "src/**/*.rs"] +deny = [".env", "**/*.key", "**/*.pem", "**/id_rsa"] + +[file.write_file] +allow = ["src/**/*.rs", "tests/**/*.rs", "*.md"] +deny = [".env", "**/*.key", "**/*.pem", "**/secrets.*"] + +[file.edit_file] +allow = ["src/**/*.rs", "tests/**/*.rs", "*.md"] +deny = [".env", "**/*.key", "**/*.pem", "**/secrets.*"] + +[file.apply_patch] +allow = ["src/**/*.rs", "tests/**/*.rs", "*.md"] +deny = [".env", "**/*.key", "**/*.pem", "**/secrets.*"] + +[file.default] +allow = ["*"] +deny = [".env", "**/*.key", "**/*.pem", "**/id_rsa", "**/secrets.*"]