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:
@@ -12,8 +12,9 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
color-eyre = "0.6"
|
||||
llm-ollama = { path = "../../llm/ollama" }
|
||||
tools-fs = {path = "../../tools/fs" }
|
||||
tools-fs = { path = "../../tools/fs" }
|
||||
config-agent = { package = "config-agent", path = "../../platform/config" }
|
||||
permissions = { path = "../../platform/permissions" }
|
||||
futures-util = "0.3.31"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -21,3 +22,4 @@ assert_cmd = "2.0"
|
||||
predicates = "3.1"
|
||||
httpmock = "0.7"
|
||||
tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] }
|
||||
tempfile = "3.23.0"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use clap::Parser;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use config_agent::load_settings;
|
||||
use futures_util::TryStreamExt;
|
||||
use llm_ollama::{OllamaClient, OllamaOptions, types::ChatMessage};
|
||||
use permissions::{PermissionDecision, Tool};
|
||||
use std::io::{self, Write};
|
||||
|
||||
#[derive(clap::Subcommand, Debug)]
|
||||
@@ -23,6 +24,9 @@ struct Args {
|
||||
api_key: Option<String>,
|
||||
#[arg(long)]
|
||||
print: bool,
|
||||
/// Override the permission mode (plan, acceptEdits, code)
|
||||
#[arg(long)]
|
||||
mode: Option<String>,
|
||||
#[arg()]
|
||||
prompt: Vec<String>,
|
||||
#[command(subcommand)]
|
||||
@@ -33,26 +37,73 @@ struct Args {
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
let args = Args::parse();
|
||||
let settings = load_settings(None).unwrap_or_default();
|
||||
let mut settings = load_settings(None).unwrap_or_default();
|
||||
|
||||
// Override mode if specified via CLI
|
||||
if let Some(mode) = args.mode {
|
||||
settings.mode = mode;
|
||||
}
|
||||
|
||||
// Create permission manager from settings
|
||||
let perms = settings.create_permission_manager();
|
||||
|
||||
if let Some(cmd) = args.cmd {
|
||||
match cmd {
|
||||
Cmd::Read { path } => {
|
||||
let s = tools_fs::read_file(&path)?;
|
||||
println!("{}", s);
|
||||
return Ok(());
|
||||
// Check permission
|
||||
match perms.check(Tool::Read, None) {
|
||||
PermissionDecision::Allow => {
|
||||
let s = tools_fs::read_file(&path)?;
|
||||
println!("{}", s);
|
||||
return Ok(());
|
||||
}
|
||||
PermissionDecision::Ask => {
|
||||
return Err(eyre!(
|
||||
"Permission denied: Read operation requires approval. Use --mode code to allow."
|
||||
));
|
||||
}
|
||||
PermissionDecision::Deny => {
|
||||
return Err(eyre!("Permission denied: Read operation is blocked."));
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::Glob { pattern } => {
|
||||
for p in tools_fs::glob_list(&pattern)? {
|
||||
println!("{}", p);
|
||||
// Check permission
|
||||
match perms.check(Tool::Glob, None) {
|
||||
PermissionDecision::Allow => {
|
||||
for p in tools_fs::glob_list(&pattern)? {
|
||||
println!("{}", p);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
PermissionDecision::Ask => {
|
||||
return Err(eyre!(
|
||||
"Permission denied: Glob operation requires approval. Use --mode code to allow."
|
||||
));
|
||||
}
|
||||
PermissionDecision::Deny => {
|
||||
return Err(eyre!("Permission denied: Glob operation is blocked."));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Cmd::Grep { root, pattern } => {
|
||||
for (path, line_number, text) in tools_fs::grep(&root, &pattern)? {
|
||||
println!("{path}:{line_number}:{text}")
|
||||
// Check permission
|
||||
match perms.check(Tool::Grep, None) {
|
||||
PermissionDecision::Allow => {
|
||||
for (path, line_number, text) in tools_fs::grep(&root, &pattern)? {
|
||||
println!("{path}:{line_number}:{text}")
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
PermissionDecision::Ask => {
|
||||
return Err(eyre!(
|
||||
"Permission denied: Grep operation requires approval. Use --mode code to allow."
|
||||
));
|
||||
}
|
||||
PermissionDecision::Deny => {
|
||||
return Err(eyre!("Permission denied: Grep operation is blocked."));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
crates/app/cli/tests/permissions.rs
Normal file
56
crates/app/cli/tests/permissions.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use assert_cmd::Command;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn plan_mode_allows_read_operations() {
|
||||
// Create a temp file to read
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("test.txt");
|
||||
fs::write(&file, "hello world").unwrap();
|
||||
|
||||
// Read operation should work in plan mode (default)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("read").arg(file.to_str().unwrap());
|
||||
cmd.assert().success().stdout("hello world\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_mode_allows_glob_operations() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("a.txt"), "test").unwrap();
|
||||
fs::write(dir.path().join("b.txt"), "test").unwrap();
|
||||
|
||||
let pattern = format!("{}/*.txt", dir.path().display());
|
||||
|
||||
// Glob operation should work in plan mode (default)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("glob").arg(&pattern);
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_mode_allows_grep_operations() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("test.txt"), "hello world\nfoo bar").unwrap();
|
||||
|
||||
// Grep operation should work in plan mode (default)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("grep").arg(dir.path().to_str().unwrap()).arg("hello");
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_override_via_cli_flag() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("test.txt");
|
||||
fs::write(&file, "content").unwrap();
|
||||
|
||||
// Test with --mode code (should also allow read)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("--mode")
|
||||
.arg("code")
|
||||
.arg("read")
|
||||
.arg(file.to_str().unwrap());
|
||||
cmd.assert().success().stdout("content\n");
|
||||
}
|
||||
Reference in New Issue
Block a user