Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ─────────────────────────────────────────────────────────────────────────────────
Expand Down
46 changes: 46 additions & 0 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::lsp::DiagnosticBlock>,
/// File access policy loaded from `~/.deepseek/execpolicy.toml`.
file_policy: Option<Arc<crate::execpolicy::ExecPolicyConfig>>,
}

// === Internal tool helpers ===
Expand Down Expand Up @@ -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<ToolError> {
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<ToolError> {
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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -564,6 +609,7 @@ impl Engine {
pending_lsp_blocks: Vec::new(),
workshop_vars,
sandbox_backend,
file_policy,
};
engine.rehydrate_latest_canonical_state();

Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/core/engine/capacity_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ impl Engine {
tool_registry,
mcp_pool.clone(),
None,
None,
)
.await;

Expand Down
257 changes: 257 additions & 0 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
15 changes: 15 additions & 0 deletions crates/tui/src/core/engine/tool_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -222,6 +223,7 @@ impl Engine {
Some(registry_ref),
mcp_pool,
None,
file_policy,
)
.await;
(tool_name, result)
Expand Down Expand Up @@ -270,6 +272,7 @@ impl Engine {
registry: Option<&crate::tools::ToolRegistry>,
mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
context_override: Option<crate::tools::ToolContext>,
file_policy: Option<Arc<crate::execpolicy::ExecPolicyConfig>>,
) -> Result<ToolResult, ToolError> {
let started_at = std::time::Instant::now();
let dispatch = if McpPool::is_mcp_tool(&tool_name) {
Expand Down Expand Up @@ -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
Expand Down
Loading