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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 14:39:24 +01:00
commit f3a2fe3816
62 files changed
+21835

No files matched your search

+218
View File
@@ -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);
}
}