Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,218 @@
|
||||
//! Permission evaluation: config rules, session permission modes and
|
||||
//! "always allow" approvals given in the UI.
|
||||
|
||||
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(" ")
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return bash.unwrap_or(base);
|
||||
}
|
||||
if base == Decision::Deny {
|
||||
return Decision::Deny;
|
||||
}
|
||||
if self.mode == "bypass" || self.approved.contains(name) {
|
||||
return Decision::Allow;
|
||||
}
|
||||
if self.mode == "accept_edits" && tool.is_edit() {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user