Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
+117
-2
@@ -81,6 +81,70 @@ 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,
|
||||
@@ -102,7 +166,17 @@ impl Evaluator<'_> {
|
||||
if self.mode == "bypass" {
|
||||
return Decision::Allow;
|
||||
}
|
||||
return bash.unwrap_or(base);
|
||||
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;
|
||||
@@ -110,7 +184,11 @@ impl Evaluator<'_> {
|
||||
if self.mode == "bypass" || self.approved.contains(name) {
|
||||
return Decision::Allow;
|
||||
}
|
||||
if self.mode == "accept_edits" && tool.is_edit() {
|
||||
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
|
||||
@@ -201,6 +279,43 @@ mod tests {
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user