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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 23:18:50 +01:00
1 parent fca1c41087
commit aa1f8cf016
40 files changed
+2454 -814

No files matched your search

+2 -332
View File
@@ -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::*;