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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
if: runner.os != 'Linux'
with:
cache-bin: false
cache-bin: "false"
- name: Run tests
if: runner.os != 'Linux'
run: cargo test --workspace --all-features --locked
Expand Down Expand Up @@ -90,7 +90,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
if: runner.os != 'Linux'
with:
cache-bin: false
cache-bin: "false"
- name: Build wrapper binaries
if: runner.os != 'Linux'
run: cargo build --release --locked -p codewhale-cli -p codewhale-tui
Expand Down Expand Up @@ -120,7 +120,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
2 changes: 2 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 = "file_edit"
# 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 codewhale_agent::ModelRegistry;
use codewhale_config::{CliRuntimeOverrides, ConfigStore};
use codewhale_core::Runtime;
use codewhale_execpolicy::ExecPolicyEngine;
use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink};
use codewhale_mcp::McpManager;
use codewhale_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
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.44" }
codewhale-secrets = { path = "../secrets", version = "0.8.44" }
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 codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
pub use codewhale_execpolicy::{PermissionDecision, ToolPermissionRule};
use codewhale_secrets::SecretSource;
pub use codewhale_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;
}
Comment thread
greyfreedom marked this conversation as resolved.
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 @@ -1621,6 +1668,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 @@ -1814,6 +1868,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 = "file_edit"
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("file_edit", 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(codewhale_execpolicy::ExecPolicyContext {
command: "git status --porcelain",
cwd: ".",
ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
sandbox_mode: Some("workspace-write"),
})
.expect("policy decision");
assert!(allowed.allow);
assert!(!allowed.requires_approval);

let denied = engine
.check(codewhale_execpolicy::ExecPolicyContext {
command: "git status --dangerous",
cwd: ".",
ask_for_approval: codewhale_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
codewhale-protocol = { path = "../protocol", version = "0.8.44" }
globset = "0.4.18"
serde.workspace = true
Loading
Loading