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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+117 -2
View File
@@ -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();