feat(permissions): implement permission system with plan mode enforcement (M1 complete)
This commit implements the complete M1 milestone (Config & Permissions) including: - New permissions crate with Tool, Action, Mode, and PermissionManager - Three permission modes: Plan (read-only default), AcceptEdits, Code - Pattern matching for permission rules (exact match and prefix with *) - Integration with config-agent for mode-based permission management - CLI integration with --mode flag to override configured mode - Permission checks for Read, Glob, and Grep operations - Comprehensive test suite (10 tests in permissions, 4 in config, 4 in CLI) Also fixes: - Fixed failing test in tools-fs (glob pattern issue) - Improved glob_list() root extraction to handle patterns like "/*.txt" All 21 workspace tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
10
crates/platform/permissions/Cargo.toml
Normal file
10
crates/platform/permissions/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "permissions"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "1"
|
||||
212
crates/platform/permissions/src/lib.rs
Normal file
212
crates/platform/permissions/src/lib.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Tool {
|
||||
Read,
|
||||
Write,
|
||||
Edit,
|
||||
Bash,
|
||||
Grep,
|
||||
Glob,
|
||||
WebFetch,
|
||||
WebSearch,
|
||||
NotebookRead,
|
||||
NotebookEdit,
|
||||
SlashCommand,
|
||||
Task,
|
||||
TodoWrite,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Action {
|
||||
Allow,
|
||||
Ask,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Mode {
|
||||
Plan, // Read-only: Read/Grep/Glob allowed, others Ask
|
||||
AcceptEdits, // Auto-allow Edit/Write, Bash still Ask
|
||||
Code, // Full access (all allowed)
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"plan" => Some(Mode::Plan),
|
||||
"acceptedits" | "accept_edits" => Some(Mode::AcceptEdits),
|
||||
"code" => Some(Mode::Code),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PermissionDecision {
|
||||
Allow,
|
||||
Ask,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermissionRule {
|
||||
pub tool: Tool,
|
||||
pub pattern: Option<String>,
|
||||
pub action: Action,
|
||||
}
|
||||
|
||||
impl PermissionRule {
|
||||
fn matches(&self, tool: Tool, context: Option<&str>) -> bool {
|
||||
if self.tool != tool {
|
||||
return false;
|
||||
}
|
||||
|
||||
match (&self.pattern, context) {
|
||||
(None, _) => true, // No pattern means match all
|
||||
(Some(_), None) => false, // Pattern specified but no context
|
||||
(Some(pattern), Some(ctx)) => {
|
||||
// Support prefix matching with wildcard
|
||||
if pattern.ends_with('*') {
|
||||
let prefix = pattern.trim_end_matches('*');
|
||||
ctx.starts_with(prefix)
|
||||
} else {
|
||||
// Exact match
|
||||
pattern == ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PermissionManager {
|
||||
mode: Mode,
|
||||
rules: Vec<PermissionRule>,
|
||||
}
|
||||
|
||||
impl PermissionManager {
|
||||
pub fn new(mode: Mode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
rules: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_rule(&mut self, tool: Tool, pattern: Option<String>, action: Action) {
|
||||
self.rules.push(PermissionRule {
|
||||
tool,
|
||||
pattern,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn check(&self, tool: Tool, context: Option<&str>) -> PermissionDecision {
|
||||
// Check explicit rules first (most specific to least specific)
|
||||
// Deny rules take precedence
|
||||
for rule in &self.rules {
|
||||
if rule.matches(tool, context) {
|
||||
return match rule.action {
|
||||
Action::Allow => PermissionDecision::Allow,
|
||||
Action::Ask => PermissionDecision::Ask,
|
||||
Action::Deny => PermissionDecision::Deny,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to mode-based defaults
|
||||
self.check_mode_default(tool)
|
||||
}
|
||||
|
||||
fn check_mode_default(&self, tool: Tool) -> PermissionDecision {
|
||||
match self.mode {
|
||||
Mode::Plan => match tool {
|
||||
// Read-only tools are allowed in plan mode
|
||||
Tool::Read | Tool::Grep | Tool::Glob | Tool::NotebookRead => {
|
||||
PermissionDecision::Allow
|
||||
}
|
||||
// Everything else requires asking
|
||||
_ => PermissionDecision::Ask,
|
||||
},
|
||||
Mode::AcceptEdits => match tool {
|
||||
// Read operations allowed
|
||||
Tool::Read | Tool::Grep | Tool::Glob | Tool::NotebookRead => {
|
||||
PermissionDecision::Allow
|
||||
}
|
||||
// Edit/Write operations allowed
|
||||
Tool::Edit | Tool::Write | Tool::NotebookEdit => PermissionDecision::Allow,
|
||||
// Bash and other dangerous operations still require asking
|
||||
Tool::Bash | Tool::WebFetch | Tool::WebSearch => PermissionDecision::Ask,
|
||||
// Utility tools allowed
|
||||
Tool::TodoWrite | Tool::SlashCommand | Tool::Task => PermissionDecision::Allow,
|
||||
},
|
||||
Mode::Code => {
|
||||
// Everything allowed in code mode
|
||||
PermissionDecision::Allow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: Mode) {
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> Mode {
|
||||
self.mode
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pattern_exact_match() {
|
||||
let rule = PermissionRule {
|
||||
tool: Tool::Bash,
|
||||
pattern: Some("npm test".to_string()),
|
||||
action: Action::Allow,
|
||||
};
|
||||
|
||||
assert!(rule.matches(Tool::Bash, Some("npm test")));
|
||||
assert!(!rule.matches(Tool::Bash, Some("npm install")));
|
||||
assert!(!rule.matches(Tool::Read, Some("npm test")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_prefix_match() {
|
||||
let rule = PermissionRule {
|
||||
tool: Tool::Bash,
|
||||
pattern: Some("npm test:*".to_string()),
|
||||
action: Action::Allow,
|
||||
};
|
||||
|
||||
assert!(rule.matches(Tool::Bash, Some("npm test:unit")));
|
||||
assert!(rule.matches(Tool::Bash, Some("npm test:integration")));
|
||||
assert!(!rule.matches(Tool::Bash, Some("npm install")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_no_context() {
|
||||
let rule = PermissionRule {
|
||||
tool: Tool::Bash,
|
||||
pattern: Some("npm test".to_string()),
|
||||
action: Action::Allow,
|
||||
};
|
||||
|
||||
// Pattern specified but no context provided
|
||||
assert!(!rule.matches(Tool::Bash, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pattern_matches_all() {
|
||||
let rule = PermissionRule {
|
||||
tool: Tool::Read,
|
||||
pattern: None,
|
||||
action: Action::Allow,
|
||||
};
|
||||
|
||||
assert!(rule.matches(Tool::Read, Some("any context")));
|
||||
assert!(rule.matches(Tool::Read, None));
|
||||
}
|
||||
}
|
||||
85
crates/platform/permissions/tests/plan_mode.rs
Normal file
85
crates/platform/permissions/tests/plan_mode.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use permissions::{PermissionManager, Mode, Tool, PermissionDecision};
|
||||
|
||||
#[test]
|
||||
fn plan_mode_blocks_write_bash_by_default() {
|
||||
let mgr = PermissionManager::new(Mode::Plan);
|
||||
|
||||
// Plan mode should allow read operations
|
||||
assert_eq!(mgr.check(Tool::Read, None), PermissionDecision::Allow);
|
||||
assert_eq!(mgr.check(Tool::Grep, None), PermissionDecision::Allow);
|
||||
assert_eq!(mgr.check(Tool::Glob, None), PermissionDecision::Allow);
|
||||
|
||||
// Plan mode should ask for write operations
|
||||
assert_eq!(mgr.check(Tool::Write, None), PermissionDecision::Ask);
|
||||
assert_eq!(mgr.check(Tool::Edit, None), PermissionDecision::Ask);
|
||||
|
||||
// Plan mode should ask for Bash
|
||||
assert_eq!(mgr.check(Tool::Bash, None), PermissionDecision::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_edits_mode_allows_edit_write() {
|
||||
let mgr = PermissionManager::new(Mode::AcceptEdits);
|
||||
|
||||
// AcceptEdits mode should allow read operations
|
||||
assert_eq!(mgr.check(Tool::Read, None), PermissionDecision::Allow);
|
||||
|
||||
// AcceptEdits mode should allow edit/write
|
||||
assert_eq!(mgr.check(Tool::Edit, None), PermissionDecision::Allow);
|
||||
assert_eq!(mgr.check(Tool::Write, None), PermissionDecision::Allow);
|
||||
|
||||
// But still ask for Bash
|
||||
assert_eq!(mgr.check(Tool::Bash, None), PermissionDecision::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_allows_everything() {
|
||||
let mgr = PermissionManager::new(Mode::Code);
|
||||
|
||||
assert_eq!(mgr.check(Tool::Read, None), PermissionDecision::Allow);
|
||||
assert_eq!(mgr.check(Tool::Write, None), PermissionDecision::Allow);
|
||||
assert_eq!(mgr.check(Tool::Edit, None), PermissionDecision::Allow);
|
||||
assert_eq!(mgr.check(Tool::Bash, None), PermissionDecision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_pattern_matching() {
|
||||
let mut mgr = PermissionManager::new(Mode::Plan);
|
||||
|
||||
// Add a rule to allow "npm test"
|
||||
mgr.add_rule(Tool::Bash, Some("npm test".to_string()), permissions::Action::Allow);
|
||||
|
||||
// Should allow the exact command
|
||||
assert_eq!(mgr.check(Tool::Bash, Some("npm test")), PermissionDecision::Allow);
|
||||
|
||||
// Should still ask for other commands
|
||||
assert_eq!(mgr.check(Tool::Bash, Some("rm -rf /")), PermissionDecision::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_prefix_matching() {
|
||||
let mut mgr = PermissionManager::new(Mode::Plan);
|
||||
|
||||
// Add a rule to allow "npm test:*" (prefix match)
|
||||
mgr.add_rule(Tool::Bash, Some("npm test:*".to_string()), permissions::Action::Allow);
|
||||
|
||||
// Should allow commands matching the prefix
|
||||
assert_eq!(mgr.check(Tool::Bash, Some("npm test:unit")), PermissionDecision::Allow);
|
||||
assert_eq!(mgr.check(Tool::Bash, Some("npm test:integration")), PermissionDecision::Allow);
|
||||
|
||||
// Should not allow non-matching commands
|
||||
assert_eq!(mgr.check(Tool::Bash, Some("npm install")), PermissionDecision::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_rules_take_precedence() {
|
||||
let mut mgr = PermissionManager::new(Mode::Code);
|
||||
|
||||
// Even in Code mode, we can deny specific operations
|
||||
mgr.add_rule(Tool::Bash, Some("rm -rf*".to_string()), permissions::Action::Deny);
|
||||
|
||||
assert_eq!(mgr.check(Tool::Bash, Some("rm -rf /")), PermissionDecision::Deny);
|
||||
|
||||
// But other commands are still allowed
|
||||
assert_eq!(mgr.check(Tool::Bash, Some("ls")), PermissionDecision::Allow);
|
||||
}
|
||||
Reference in New Issue
Block a user