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

+194
View File
@@ -0,0 +1,194 @@
//! Background shells: long-running commands started by agents (dev servers,
//! watchers). Output is buffered; agents read it incrementally.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
use anyhow::{Result, bail};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio_util::sync::CancellationToken;
const MAX_BUFFER: usize = 1024 * 1024;
const MAX_PER_SESSION: usize = 8;
pub struct Shell {
pub id: String,
pub session_id: String,
pub command: String,
pub started_at: i64,
/// Everything printed so far (the oldest part is dropped beyond 1 MB).
output: Mutex<String>,
/// Bytes dropped from the front of `output`.
dropped: Mutex<usize>,
/// Absolute position up to which the agent has read.
read_pos: Mutex<usize>,
exit: Mutex<Option<i32>>,
stop: CancellationToken,
}
impl Shell {
pub fn running(&self) -> bool {
self.exit.lock().unwrap().is_none()
}
pub fn json(&self, tail: usize) -> Value {
let out = self.output.lock().unwrap();
let start = out.len().saturating_sub(tail);
let start = (start..=out.len()).find(|i| out.is_char_boundary(*i)).unwrap_or(out.len());
json!({
"id": self.id, "command": self.command, "started_at": self.started_at,
"running": self.running(), "exit_code": *self.exit.lock().unwrap(), "output": &out[start..],
})
}
/// Output not yet seen by the agent.
fn take_new(&self) -> String {
let out = self.output.lock().unwrap();
let dropped = *self.dropped.lock().unwrap();
let mut pos = self.read_pos.lock().unwrap();
let from = pos.saturating_sub(dropped).min(out.len());
let from = (from..=out.len()).find(|i| out.is_char_boundary(*i)).unwrap_or(out.len());
let text = out[from..].to_string();
*pos = dropped + out.len();
text
}
pub fn kill(&self) {
self.stop.cancel();
}
}
pub type Shells = Mutex<HashMap<String, Arc<Shell>>>;
pub fn spawn(app: &Arc<App>, session_id: &str, command: &str, mut cmd: Command) -> Result<String> {
let running = app.shells.lock().unwrap().values().filter(|s| s.session_id == session_id && s.running()).count();
if running >= MAX_PER_SESSION {
bail!("too many background shells in this session ({running}) – stop some with kill_shell");
}
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true);
cmd.process_group(0);
let mut child = cmd.spawn()?;
let pid = child.id();
let shell = Arc::new(Shell {
id: format!("sh{}", &crate::types::new_id()[20..]),
session_id: session_id.to_string(),
command: command.to_string(),
started_at: crate::types::now_ms(),
output: Mutex::default(),
dropped: Mutex::default(),
read_pos: Mutex::default(),
exit: Mutex::default(),
stop: CancellationToken::new(),
});
app.shells.lock().unwrap().insert(shell.id.clone(), shell.clone());
let stdout = child.stdout.take().unwrap();
let s = shell.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).split(b'\n');
loop {
tokio::select! {
l = lines.next_segment() => match l {
Ok(Some(l)) => {
let mut out = s.output.lock().unwrap();
out.push_str(&String::from_utf8_lossy(&l));
out.push('\n');
if out.len() > MAX_BUFFER {
let mut cut = out.len() - MAX_BUFFER / 2;
while !out.is_char_boundary(cut) { cut += 1; }
out.drain(..cut);
*s.dropped.lock().unwrap() += cut;
}
}
_ => break,
},
_ = s.stop.cancelled() => {
if let Some(pid) = pid {
// SAFETY: plain syscall; negative pid targets the process group.
unsafe { libc::kill(-(pid as i32), libc::SIGTERM); }
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
unsafe { libc::kill(-(pid as i32), libc::SIGKILL); }
}
break;
}
}
}
let code = child.wait().await.ok().and_then(|st| st.code()).unwrap_or(-1);
*s.exit.lock().unwrap() = Some(code);
});
Ok(shell.id.clone())
}
/// Stop all shells of a session (on delete).
pub fn kill_session(app: &App, session_id: &str) {
for s in app.shells.lock().unwrap().values().filter(|s| s.session_id == session_id) {
s.kill();
}
}
fn find(ctx: &ToolCtx, id: &str) -> Result<Arc<Shell>> {
let shells = ctx.app.shells.lock().unwrap();
match shells.get(id) {
// Shells belong to the session tree that started them.
Some(s) if ctx.app.root_session_id(&s.session_id) == ctx.app.root_session_id(&ctx.session_id) => Ok(s.clone()),
_ => bail!("unknown shell id '{id}'"),
}
}
pub struct BashOutput;
#[async_trait]
impl Tool for BashOutput {
fn name(&self) -> &str {
"bash_output"
}
fn description(&self) -> String {
"Read new output of a background shell (started with bash run_in_background) since the last read, and whether it is still running.".into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {"shell_id": {"type": "string"}, "filter": {"type": "string", "description": "Only lines matching this regex"}}, "required": ["shell_id"]})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let shell = find(ctx, arg_str(&input, "shell_id")?)?;
let mut text = shell.take_new();
if let Some(f) = input["filter"].as_str() {
let re = regex::Regex::new(f)?;
text = text.lines().filter(|l| re.is_match(l)).collect::<Vec<_>>().join("\n");
}
let status = match *shell.exit.lock().unwrap() {
None => "running".to_string(),
Some(c) => format!("exited with code {c}"),
};
Ok(ToolOutput::text(format!("[{status}]\n{}", if text.is_empty() { "(no new output)".into() } else { truncate(&text, 30_000) })))
}
}
pub struct KillShell;
#[async_trait]
impl Tool for KillShell {
fn name(&self) -> &str {
"kill_shell"
}
fn description(&self) -> String {
"Stop a background shell.".into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {"shell_id": {"type": "string"}}, "required": ["shell_id"]})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let shell = find(ctx, arg_str(&input, "shell_id")?)?;
shell.kill();
Ok(ToolOutput::text(format!("Stopping shell {}.", shell.id)))
}
}