Skip to content
Closed
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
cache-bin: "false"
- name: Check formatting
run: cargo fmt --all -- --check
# Mirror the release-workflow `parity` gate exactly. Anything that
Expand Down Expand Up @@ -86,7 +86,7 @@ jobs:
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
cache-bin: "false"
- name: Run tests
run: cargo test --workspace --all-features --locked
- name: Lockfile drift guard
Expand Down Expand Up @@ -118,7 +118,7 @@ jobs:
node-version: 20
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
cache-bin: "false"
- name: Build wrapper binaries
run: cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui
- name: Smoke wrapper install and delegated entrypoints
Expand All @@ -143,7 +143,7 @@ jobs:
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
cache-bin: "false"
- name: Build docs
run: cargo doc --workspace --no-deps
env:
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,21 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc
# auto_allow = ["cargo check", "npm run"]
#
# auto_allow = []
# auto_deny = ["rm -rf"]
#
# Typed persistent permission rules. These are intentionally conservative:
# shell commands use the same arity-aware matching as auto_allow, and file
# paths are workspace-relative globs.
#
# [[permissions.rules]]
# tool = "exec_shell"
# decision = "allow" # "allow", "deny", or "ask"
# command = "cargo test"
#
# [[permissions.rules]]
# tool = "edit_file"
# decision = "ask"
# path = "src/**"
max_subagents = 10 # optional (1-20)

# Optional sub-agent tuning. max_concurrent overrides top-level max_subagents.
Expand Down
3 changes: 1 addition & 2 deletions crates/app-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use axum::{Json, Router};
use deepseek_agent::ModelRegistry;
use deepseek_config::{CliRuntimeOverrides, ConfigStore};
use deepseek_core::Runtime;
use deepseek_execpolicy::ExecPolicyEngine;
use deepseek_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink};
use deepseek_mcp::McpManager;
use deepseek_protocol::{
Expand Down Expand Up @@ -285,7 +284,7 @@ fn build_state(config_path: Option<PathBuf>) -> Result<AppState> {
state_store,
Arc::new(ToolRegistry::default()),
Arc::new(McpManager::default()),
ExecPolicyEngine::new(Vec::new(), Vec::new()),
config.exec_policy_engine(),
hooks,
);

Expand Down
1 change: 1 addition & 0 deletions crates/config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite

[dependencies]
anyhow.workspace = true
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.40" }
deepseek-secrets = { path = "../secrets", version = "0.8.40" }
dirs.workspace = true
serde.workspace = true
Expand Down
125 changes: 125 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use std::path::{Component, Path, PathBuf};
use std::sync::OnceLock;

use anyhow::{Context, Result, bail};
use deepseek_execpolicy::{ExecPolicyEngine, Ruleset};
pub use deepseek_execpolicy::{PermissionDecision, ToolPermissionRule};
use deepseek_secrets::SecretSource;
pub use deepseek_secrets::Secrets;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -182,6 +184,19 @@ impl ProvidersToml {
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct PermissionsToml {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<ToolPermissionRule>,
}

impl PermissionsToml {
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigToml {
/// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
Expand All @@ -205,6 +220,16 @@ pub struct ConfigToml {
pub telemetry: Option<bool>,
pub approval_policy: Option<String>,
pub sandbox_mode: Option<String>,
/// Legacy command allow-list. Entries become `exec_shell` allow rules.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub auto_allow: Vec<String>,
/// Legacy command deny-list. Entries become `exec_shell` deny rules.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub auto_deny: Vec<String>,
/// Typed tool permission rules. First version supports tool name, command
/// prefix, and workspace-relative path glob matching.
#[serde(default, skip_serializing_if = "PermissionsToml::is_empty")]
pub permissions: PermissionsToml,
#[serde(default)]
pub providers: ProvidersToml,
/// Per-domain network policy (#135). When absent, network tools fall back
Expand Down Expand Up @@ -371,6 +396,15 @@ impl ConfigToml {
if project.sandbox_mode.is_some() {
self.sandbox_mode = project.sandbox_mode;
}
if !project.auto_allow.is_empty() {
self.auto_allow = project.auto_allow;
}
if !project.auto_deny.is_empty() {
self.auto_deny = project.auto_deny;
}
if !project.permissions.is_empty() {
self.permissions = project.permissions;
}
// Provider is only overridden if explicitly set (non-default).
if project.provider != ProviderKind::Deepseek || has_api_key {
self.provider = project.provider;
Expand Down Expand Up @@ -418,6 +452,17 @@ impl ConfigToml {
}
}

#[must_use]
pub fn permission_ruleset(&self) -> Ruleset {
Ruleset::user(self.auto_allow.clone(), self.auto_deny.clone())
.with_rules(self.permissions.rules.clone())
}

#[must_use]
pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
ExecPolicyEngine::with_rulesets(vec![self.permission_ruleset()])
}

#[must_use]
pub fn get_value(&self, key: &str) -> Option<String> {
match key {
Expand All @@ -435,6 +480,8 @@ impl ConfigToml {
"telemetry" => self.telemetry.map(|v| v.to_string()),
"approval_policy" => self.approval_policy.clone(),
"sandbox_mode" => self.sandbox_mode.clone(),
"auto_allow" => serialize_string_list(&self.auto_allow),
"auto_deny" => serialize_string_list(&self.auto_deny),
"providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(),
"providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(),
"providers.deepseek.model" => self.providers.deepseek.model.clone(),
Expand Down Expand Up @@ -1507,6 +1554,13 @@ fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String>
)
}

fn serialize_string_list(values: &[String]) -> Option<String> {
if values.is_empty() {
return None;
}
Some(values.join(","))
}

fn redact_secret(secret: &str) -> String {
let chars: Vec<char> = secret.chars().collect();
if chars.len() <= 16 {
Expand Down Expand Up @@ -1700,6 +1754,77 @@ mod tests {
assert!(policy.audit);
}

#[test]
fn permissions_toml_deserializes_typed_rules_and_legacy_lists() {
let config: ConfigToml = toml::from_str(
r#"
auto_allow = ["git status"]
auto_deny = ["rm -rf"]

[[permissions.rules]]
tool = "exec_shell"
decision = "allow"
command = "cargo test"

[[permissions.rules]]
tool = "edit_file"
decision = "ask"
path = "src/**"
"#,
)
.expect("permissions toml");

assert_eq!(config.auto_allow, ["git status"]);
assert_eq!(config.auto_deny, ["rm -rf"]);
assert_eq!(config.permissions.rules.len(), 2);
assert_eq!(
config.permissions.rules[0],
ToolPermissionRule::exec_shell(PermissionDecision::Allow, "cargo test")
);
assert_eq!(
config.permissions.rules[1],
ToolPermissionRule::file_path("edit_file", PermissionDecision::Ask, "src/**")
);
}

#[test]
fn config_builds_exec_policy_engine_with_legacy_and_typed_rules() {
let config: ConfigToml = toml::from_str(
r#"
auto_allow = ["git status"]

[[permissions.rules]]
tool = "exec_shell"
decision = "deny"
command = "git status --dangerous"
"#,
)
.expect("permissions toml");
let engine = config.exec_policy_engine();

let allowed = engine
.check(deepseek_execpolicy::ExecPolicyContext {
command: "git status --porcelain",
cwd: ".",
ask_for_approval: deepseek_execpolicy::AskForApproval::UnlessTrusted,
sandbox_mode: Some("workspace-write"),
})
.expect("policy decision");
assert!(allowed.allow);
assert!(!allowed.requires_approval);

let denied = engine
.check(deepseek_execpolicy::ExecPolicyContext {
command: "git status --dangerous",
cwd: ".",
ask_for_approval: deepseek_execpolicy::AskForApproval::UnlessTrusted,
sandbox_mode: Some("workspace-write"),
})
.expect("policy decision");
assert!(!denied.allow);
assert_eq!(denied.requirement.phase(), "forbidden");
}

struct EnvGuard {
deepseek_api_key: Option<OsString>,
deepseek_base_url: Option<OsString>,
Expand Down
1 change: 1 addition & 0 deletions crates/execpolicy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
[dependencies]
anyhow.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.8.40" }
globset = "0.4.18"
serde.workspace = true
Loading
Loading