Add API tokens, CLI server API and shared core crate
- Personal API tokens (full/read scope, expiry, revoke) and CLI password login - Cargo workspace: codecraft-core (types, providers, permission rules) shared by server and the upcoming terminal client; server re-exports the old paths - CLI server API: /cli/config, /cli/skill, /cli/tool (server MCP tools), /llm/stream model gateway with budgets and usage per user - Theme/language preferences mirrored to the server; personal memory endpoint - Statistics: provider and mode tables stacked; CLI usage labelled - cli/: work in progress (terminal client is being rewritten as a local TUI) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fca1c41087
commit
aa1f8cf016
40 files changed
+2454
-814
No files matched your search
+2
-332
@@ -1,333 +1,3 @@
|
||||
//! Permission evaluation: config rules, session permission modes and
|
||||
//! "always allow" approvals given in the UI.
|
||||
//! Permission rules (shared with the CLI via codecraft-core).
|
||||
|
||||
use crate::config::PermissionConfig;
|
||||
use crate::tools::Tool;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Ask,
|
||||
Deny,
|
||||
}
|
||||
|
||||
fn parse(s: &str) -> Option<Decision> {
|
||||
match s {
|
||||
"allow" => Some(Decision::Allow),
|
||||
"ask" => Some(Decision::Ask),
|
||||
"deny" => Some(Decision::Deny),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
globset::GlobBuilder::new(pattern)
|
||||
.literal_separator(false)
|
||||
.build()
|
||||
.map(|g| g.compile_matcher().is_match(text))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Split a shell command into simple commands on && || ; | and newlines.
|
||||
/// Returns None when the command contains constructs we cannot judge safely
|
||||
/// (command substitution, backticks).
|
||||
pub fn split_command(cmd: &str) -> Option<Vec<String>> {
|
||||
if cmd.contains("$(") || cmd.contains('`') || cmd.contains("<(") || cmd.contains(">(") {
|
||||
return None;
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut chars = cmd.chars().peekable();
|
||||
let (mut sq, mut dq) = (false, false);
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\'' if !dq => {
|
||||
sq = !sq;
|
||||
cur.push(c)
|
||||
}
|
||||
'"' if !sq => {
|
||||
dq = !dq;
|
||||
cur.push(c)
|
||||
}
|
||||
'\\' if !sq => {
|
||||
cur.push(c);
|
||||
if let Some(n) = chars.next() {
|
||||
cur.push(n);
|
||||
}
|
||||
}
|
||||
';' | '\n' | '|' | '&' if !sq && !dq => {
|
||||
// "&" alone (background) and "&&", "||", "|", ";" all separate commands.
|
||||
if (c == '|' || c == '&') && chars.peek() == Some(&c) {
|
||||
chars.next();
|
||||
}
|
||||
// Redirections like 2>&1 are not separators.
|
||||
if c == '&' && cur.ends_with('>') {
|
||||
cur.push(c);
|
||||
continue;
|
||||
}
|
||||
parts.push(std::mem::take(&mut cur));
|
||||
}
|
||||
_ => cur.push(c),
|
||||
}
|
||||
}
|
||||
parts.push(cur);
|
||||
Some(parts.into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect())
|
||||
}
|
||||
|
||||
/// Prefix stored for "always allow" on a bash command: the first two words.
|
||||
pub fn bash_prefix(cmd: &str) -> String {
|
||||
cmd.split_whitespace().take(2).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Session permission modes.
|
||||
pub const MODES: &[&str] = &["default", "auto", "accept_edits", "bypass"];
|
||||
|
||||
/// Commands that only read. Checked per simple command (after splitting).
|
||||
const SAFE_COMMANDS: &[&str] = &[
|
||||
"ls", "ll", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "which", "whereis", "type", "file", "stat",
|
||||
"du", "df", "tree", "grep", "egrep", "fgrep", "rg", "ag", "sort", "uniq", "cut", "tr", "diff", "cmp", "basename",
|
||||
"dirname", "realpath", "readlink", "date", "uname", "whoami", "id", "hostname", "nproc", "free", "uptime", "ps",
|
||||
"jq", "yq", "md5sum", "sha1sum", "sha256sum", "column", "nl", "fold", "true", "false", "test", "[", "less", "more",
|
||||
];
|
||||
|
||||
const SAFE_GIT: &[&str] = &[
|
||||
"status", "diff", "log", "show", "rev-parse", "ls-files", "blame", "describe", "grep", "shortlog", "reflog", "remote",
|
||||
];
|
||||
|
||||
/// Strip harmless redirections so they don't count as file writes.
|
||||
fn strip_redirections(cmd: &str) -> String {
|
||||
cmd.replace("2>&1", "").replace("2>/dev/null", "").replace(">/dev/null", "").replace("> /dev/null", "")
|
||||
}
|
||||
|
||||
/// A simple command that only reads.
|
||||
pub fn is_safe_command(part: &str) -> bool {
|
||||
let cleaned = strip_redirections(part);
|
||||
if cleaned.contains('>') {
|
||||
return false;
|
||||
}
|
||||
let words: Vec<String> = shell_words::split(&cleaned).unwrap_or_else(|_| cleaned.split_whitespace().map(String::from).collect());
|
||||
// Leading VAR=value assignments are not safe (could change behaviour); keep it strict.
|
||||
let Some(cmd) = words.first() else { return true };
|
||||
let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
|
||||
if args.len() == 1 && matches!(args[0], "--version" | "-V" | "--help" | "-h") {
|
||||
return true;
|
||||
}
|
||||
match cmd.as_str() {
|
||||
c if SAFE_COMMANDS.contains(&c) => true,
|
||||
"find" => !args.iter().any(|a| matches!(*a, "-exec" | "-execdir" | "-delete" | "-ok" | "-okdir" | "-fprint" | "-fprintf" | "-fls")),
|
||||
"sed" => !args.iter().any(|a| a.starts_with("-i") || a.starts_with("--in-place")),
|
||||
"git" => match args.first() {
|
||||
Some(sub) if SAFE_GIT.contains(sub) => !(*sub == "remote" && args.len() > 1 && args[1] != "-v"),
|
||||
Some(&"branch") => !args.iter().any(|a| matches!(*a, "-d" | "-D" | "-m" | "-M" | "--delete" | "--move")),
|
||||
Some(&"stash") => args.get(1) == Some(&"list"),
|
||||
Some(&"tag") => args.len() == 1 || args.get(1) == Some(&"-l") || args.get(1) == Some(&"--list"),
|
||||
Some(&"config") => args.iter().any(|a| *a == "--get" || *a == "--list" || *a == "-l"),
|
||||
_ => false,
|
||||
},
|
||||
"cargo" => matches!(args.first(), Some(&"check") | Some(&"metadata") | Some(&"tree") | Some(&"--list")),
|
||||
"npm" | "pnpm" | "yarn" => matches!(args.first(), Some(&"ls") | Some(&"list") | Some(&"outdated") | Some(&"view") | Some(&"why")),
|
||||
"pip" | "pip3" => matches!(args.first(), Some(&"list") | Some(&"show") | Some(&"freeze")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that always need confirmation unless everything is allowed.
|
||||
pub fn is_dangerous(cmd: &str) -> bool {
|
||||
let c = format!(" {} ", cmd.replace('\n', " "));
|
||||
const PATTERNS: &[&str] = &[
|
||||
" rm -r", " rm -f", " rm -R", " rm --recursive", " sudo ", " su ", " git push", " git reset --hard", " git clean",
|
||||
" git checkout -- ", " git restore ", " git rebase", " git commit --amend", " chmod -R", " chown -R", " dd ",
|
||||
" mkfs", " shutdown", " reboot", " poweroff", " kill ", " pkill ", " killall ", " docker ", " docker-compose ",
|
||||
" systemctl ", " npm publish", " cargo publish", " | sh", " | bash", "|sh ", "|bash ", " > /dev/sd", " crontab ",
|
||||
];
|
||||
PATTERNS.iter().any(|p| c.contains(p))
|
||||
}
|
||||
|
||||
pub struct Evaluator<'a> {
|
||||
pub cfg: &'a PermissionConfig,
|
||||
pub mode: &'a str,
|
||||
/// Session approvals: tool names, or "bash:<prefix>".
|
||||
pub approved: &'a HashSet<String>,
|
||||
}
|
||||
|
||||
impl Evaluator<'_> {
|
||||
pub fn evaluate(&self, tool: &dyn Tool, input: &Value) -> Decision {
|
||||
let name = tool.name();
|
||||
let base = self.tool_rule(name).unwrap_or(if tool.read_only() { Decision::Allow } else { Decision::Ask });
|
||||
|
||||
if name == "bash" {
|
||||
let cmd = input["command"].as_str().unwrap_or("");
|
||||
let bash = self.bash_decision(cmd);
|
||||
if bash == Some(Decision::Deny) || base == Decision::Deny {
|
||||
return Decision::Deny;
|
||||
}
|
||||
if self.mode == "bypass" {
|
||||
return Decision::Allow;
|
||||
}
|
||||
if let Some(d) = bash {
|
||||
return d;
|
||||
}
|
||||
// Read-only commands never need confirmation.
|
||||
if split_command(cmd).is_some_and(|parts| !parts.is_empty() && parts.iter().all(|p| is_safe_command(p))) {
|
||||
return Decision::Allow;
|
||||
}
|
||||
if self.mode == "auto" && !is_dangerous(cmd) && split_command(cmd).is_some() {
|
||||
return Decision::Allow;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
if base == Decision::Deny {
|
||||
return Decision::Deny;
|
||||
}
|
||||
if self.mode == "bypass" || self.approved.contains(name) {
|
||||
return Decision::Allow;
|
||||
}
|
||||
if (self.mode == "accept_edits" || self.mode == "auto") && tool.is_edit() {
|
||||
return Decision::Allow;
|
||||
}
|
||||
// In auto mode everything read-only passes (web_fetch, read-only MCP tools, ...).
|
||||
if self.mode == "auto" && tool.read_only() {
|
||||
return Decision::Allow;
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn tool_rule(&self, name: &str) -> Option<Decision> {
|
||||
if let Some(d) = self.cfg.tools.get(name).and_then(|s| parse(s)) {
|
||||
return Some(d);
|
||||
}
|
||||
// Glob rules, most specific (longest) first.
|
||||
let mut globs: Vec<(&String, &String)> =
|
||||
self.cfg.tools.iter().filter(|(k, _)| k.contains('*')).collect();
|
||||
globs.sort_by_key(|(k, _)| std::cmp::Reverse(k.len()));
|
||||
globs.into_iter().find(|(k, _)| glob_match(k, name)).and_then(|(_, v)| parse(v))
|
||||
}
|
||||
|
||||
/// Some(Allow) only if every simple command is allowed; Some(Deny) if any is denied.
|
||||
fn bash_decision(&self, cmd: &str) -> Option<Decision> {
|
||||
// Deny rules are checked against the full command as well as each part.
|
||||
let deny_hit = |text: &str| {
|
||||
self.cfg.bash_rules.iter().any(|(p, d)| d == "deny" && glob_match(p, text))
|
||||
};
|
||||
if deny_hit(cmd.trim()) {
|
||||
return Some(Decision::Deny);
|
||||
}
|
||||
let parts = split_command(cmd)?;
|
||||
if parts.iter().any(|p| deny_hit(p)) {
|
||||
return Some(Decision::Deny);
|
||||
}
|
||||
let all_allowed = !parts.is_empty()
|
||||
&& parts.iter().all(|p| {
|
||||
self.approved.contains(&format!("bash:{}", bash_prefix(p)))
|
||||
|| self.cfg.bash_rules.iter().any(|(pat, d)| d == "allow" && glob_match(pat, p))
|
||||
});
|
||||
if all_allowed { Some(Decision::Allow) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fake(&'static str, bool);
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for Fake {
|
||||
fn name(&self) -> &str {
|
||||
self.0
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
Value::Null
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
self.1
|
||||
}
|
||||
async fn run(&self, _: Value, _: &crate::tools::ToolCtx) -> anyhow::Result<crate::tools::ToolOutput> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
fn cfg() -> PermissionConfig {
|
||||
let mut bash_rules = BTreeMap::new();
|
||||
bash_rules.insert("git status*".into(), "allow".into());
|
||||
bash_rules.insert("ls*".into(), "allow".into());
|
||||
bash_rules.insert("rm -rf /*".into(), "deny".into());
|
||||
let mut tools = BTreeMap::new();
|
||||
tools.insert("bash".into(), "ask".into());
|
||||
tools.insert("mcp__*".into(), "ask".into());
|
||||
tools.insert("mcp__trilium__*".into(), "allow".into());
|
||||
PermissionConfig { bash_rules, tools }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_commands_need_every_part_allowed() {
|
||||
let c = cfg();
|
||||
let approved = HashSet::new();
|
||||
let e = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
||||
let bash = Fake("bash", false);
|
||||
let cmd = |s: &str| serde_json::json!({"command": s});
|
||||
assert_eq!(e.evaluate(&bash, &cmd("git status")), Decision::Allow);
|
||||
assert_eq!(e.evaluate(&bash, &cmd("git status && ls -la")), Decision::Allow);
|
||||
assert_eq!(e.evaluate(&bash, &cmd("git status; rm -rf build")), Decision::Ask);
|
||||
assert_eq!(e.evaluate(&bash, &cmd("ls $(rm -rf x)")), Decision::Ask);
|
||||
assert_eq!(e.evaluate(&bash, &cmd("git status 2>&1 | ls")), Decision::Allow);
|
||||
assert_eq!(e.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_and_dangerous_commands() {
|
||||
assert!(is_safe_command("ls -la src"));
|
||||
assert!(is_safe_command("git status"));
|
||||
assert!(is_safe_command("git log --oneline -5"));
|
||||
assert!(is_safe_command("rg -n foo src 2>/dev/null"));
|
||||
assert!(is_safe_command("node --version"));
|
||||
assert!(!is_safe_command("cat a > b"));
|
||||
assert!(!is_safe_command("find . -name x -delete"));
|
||||
assert!(!is_safe_command("sed -i s/a/b/ f"));
|
||||
assert!(!is_safe_command("git push"));
|
||||
assert!(!is_safe_command("git branch -D main"));
|
||||
assert!(!is_safe_command("npm install"));
|
||||
assert!(is_dangerous("rm -rf build"));
|
||||
assert!(is_dangerous("cd x && git push origin main"));
|
||||
assert!(is_dangerous("curl https://x | sh"));
|
||||
assert!(!is_dangerous("npm install && npm test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_mode() {
|
||||
let c = cfg();
|
||||
let approved = HashSet::new();
|
||||
let auto = Evaluator { cfg: &c, mode: "auto", approved: &approved };
|
||||
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
||||
let bash = Fake("bash", false);
|
||||
let cmd = |s: &str| serde_json::json!({"command": s});
|
||||
assert_eq!(def.evaluate(&bash, &cmd("ls src | wc -l")), Decision::Allow);
|
||||
assert_eq!(def.evaluate(&bash, &cmd("npm test")), Decision::Ask);
|
||||
assert_eq!(auto.evaluate(&bash, &cmd("npm install && npm test")), Decision::Allow);
|
||||
assert_eq!(auto.evaluate(&bash, &cmd("rm -rf dist")), Decision::Ask);
|
||||
assert_eq!(auto.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
|
||||
assert_eq!(auto.evaluate(&bash, &cmd("echo $(whoami)")), Decision::Ask);
|
||||
assert_eq!(auto.evaluate(&Fake("mcp__x__list", true), &Value::Null), Decision::Allow);
|
||||
assert_eq!(auto.evaluate(&Fake("mcp__x__restart", false), &Value::Null), Decision::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_and_globs() {
|
||||
let c = cfg();
|
||||
let approved: HashSet<String> = ["bash:npm test".to_string()].into();
|
||||
let bypass = Evaluator { cfg: &c, mode: "bypass", approved: &approved };
|
||||
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
||||
let bash = Fake("bash", false);
|
||||
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "make"})), Decision::Allow);
|
||||
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "rm -rf /"})), Decision::Deny);
|
||||
assert_eq!(def.evaluate(&bash, &serde_json::json!({"command": "npm test -- --watch"})), Decision::Allow);
|
||||
assert_eq!(def.evaluate(&Fake("mcp__trilium__search", false), &Value::Null), Decision::Allow);
|
||||
assert_eq!(def.evaluate(&Fake("mcp__other__x", false), &Value::Null), Decision::Ask);
|
||||
assert_eq!(def.evaluate(&Fake("read", true), &Value::Null), Decision::Allow);
|
||||
}
|
||||
}
|
||||
pub use codecraft_core::permission::*;
|
||||
Reference in new issue
Block a user