Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes

- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas,
  login rate limiting, audit log and backups
- Ghidra headless decompilation (ARM64 natives built from source), binary_info tool,
  clean room / deep workflow per session
- Recreate skill and commands for analysing and re-building old software
- File checkpoints with rewind/restore, background shells (bash_output, kill_shell)
- web_search tool (DuckDuckGo, SearXNG, Brave, Tavily)
- In-app dialogs and toasts instead of browser dialogs
- 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One,
  Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 19:12:30 +01:00
1 parent fab9add5d5
commit 5b569b6835
108 files changed
+6785 -279

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
//! Per-user sandbox for shell commands (and git) using bubblewrap.
//!
//! A sandboxed command sees the system read-only, only the user's folders
//! writable, its own home directory, a private /tmp, its own process
//! namespace and an empty environment (no server secrets). The network is
//! shared so package managers keep working.
use crate::app::App;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use tokio::process::Command;
/// What a sandboxed process may access.
#[derive(Debug, Clone)]
pub struct SandboxSpec {
pub username: String,
/// Writable directories (the user's roots).
pub roots: Vec<PathBuf>,
/// Host directory mounted as the user's home.
pub home: PathBuf,
}
impl SandboxSpec {
pub fn home_inside(&self) -> String {
format!("/home/{}", self.username)
}
}
static AVAILABLE: OnceLock<Result<(), String>> = OnceLock::new();
/// Check once whether bubblewrap works in this environment.
pub async fn probe() -> Result<(), String> {
if let Some(r) = AVAILABLE.get() {
return r.clone();
}
let res = match Command::new("bwrap")
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
.env_clear()
.output()
.await
{
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
Err(e) => Err(format!("bubblewrap is not installed: {e}")),
};
match &res {
Ok(()) => tracing::info!("sandbox: bubblewrap available"),
Err(e) => tracing::warn!("sandbox: bubblewrap unavailable – shell commands of non-admin users are disabled ({e})"),
}
let _ = AVAILABLE.set(res.clone());
res
}
pub fn available() -> bool {
matches!(AVAILABLE.get(), Some(Ok(())))
}
/// The sandbox for a session owner, or None if commands run unsandboxed
/// (admins in "auto" mode, or sandbox mode "off").
pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
let mode = app.config().sandbox.mode.clone();
if mode == "off" {
return None;
}
let user = owner_id.and_then(|id| app.store.user(id).ok().flatten())?;
if user.is_admin() && mode != "always" {
return None;
}
let home = app.data_dir().join("users").join(&user.username).join("home");
let _ = std::fs::create_dir_all(&home);
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home })
}
/// Build a command that runs `program args` inside the sandbox in `cwd`.
pub fn command(spec: &SandboxSpec, cwd: &Path, program: &str, args: &[&str]) -> Command {
command_with(spec, cwd, &[], program, args)
}
/// Like `command`, with extra host directories mounted read-only (e.g. tools).
pub fn command_with(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], program: &str, args: &[&str]) -> Command {
let mut c = Command::new("bwrap");
// Empty environment: bubblewrap's own helper process must not carry secrets.
c.env_clear();
for dir in ["/usr", "/bin", "/lib", "/etc", "/sbin", "/opt"] {
if Path::new(dir).is_dir() && !Path::new(dir).is_symlink() {
c.args(["--ro-bind", dir, dir]);
} else if let Ok(target) = std::fs::read_link(dir) {
c.arg("--symlink").arg(target).arg(dir);
}
}
for dir in ["/lib64", "/lib32"] {
let p = Path::new(dir);
if let Ok(target) = std::fs::read_link(p) {
c.arg("--symlink").arg(target).arg(dir);
} else if p.is_dir() {
c.args(["--ro-bind", dir, dir]);
}
}
for root in &spec.roots {
c.arg("--bind").arg(root).arg(root);
}
// Built-in skills and templates are readable (not writable) in the sandbox.
let builtin = crate::prompts::builtin_resources();
for dir in extra_ro.iter().copied().chain(std::iter::once(builtin.as_path())) {
if dir.exists() {
c.arg("--ro-bind").arg(dir).arg(dir);
}
}
let home = spec.home_inside();
c.arg("--bind").arg(&spec.home).arg(&home);
c.args(["--tmpfs", "/tmp", "--dev", "/dev", "--proc", "/proc"]);
c.args(["--unshare-all", "--share-net", "--die-with-parent", "--new-session", "--clearenv"]);
let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin");
for (k, v) in [
("HOME", home.as_str()),
("USER", spec.username.as_str()),
("PATH", path.as_str()),
("LANG", "C.UTF-8"),
("TERM", "xterm-256color"),
("CODECRAFT", "1"),
("GIT_TERMINAL_PROMPT", "0"),
("PAGER", "cat"),
("PIP_BREAK_SYSTEM_PACKAGES", "1"),
] {
c.args(["--setenv", k, v]);
}
c.arg("--setenv").arg("NPM_CONFIG_PREFIX").arg(format!("{home}/.npm-global"));
c.arg("--setenv").arg("PIPX_HOME").arg(format!("{home}/.local/pipx"));
c.arg("--setenv").arg("PIPX_BIN_DIR").arg(format!("{home}/.local/bin"));
c.arg("--chdir").arg(cwd);
c.arg(program).args(args);
c
}