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

+58 -6
View File
@@ -188,13 +188,13 @@ fn normalize_tool_name(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_lowercase()
}
pub fn available_tools(app: &App, agent: &Agent) -> Vec<Arc<dyn Tool>> {
pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::User>) -> Vec<Arc<dyn Tool>> {
let cfg = app.config();
let mut all = tools::builtin_tools();
if !cfg.images.enabled {
all.retain(|t| t.name() != "generate_image");
}
let (direct, deferred) = app.mcp.tools(&cfg);
let (direct, deferred) = app.mcp.tools(&cfg, owner);
all.extend(direct);
if !deferred.is_empty() {
all.push(Arc::new(crate::mcp::McpSearch));
@@ -203,7 +203,7 @@ pub fn available_tools(app: &App, agent: &Agent) -> Vec<Arc<dyn Tool>> {
all.into_iter()
.filter(|t| !agent.read_only || t.read_only())
// Craft tools only where they are useful.
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare") || agent.tools.is_some())
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile") || agent.tools.is_some())
.filter(|t| match &agent.tools {
None => true,
Some(list) => {
@@ -244,7 +244,12 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
let cwd = PathBuf::from(&session.cwd);
let roots = app.user_roots(session.owner_id.as_deref());
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
let mut agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
let clean_room = session.clean_room(app.config().craft.clean_room);
// The craft prompt follows the session's clean room / deep switch.
if agent.name == "craft" && agent.source == "builtin" {
agent.prompt = prompts::craft_prompt(clean_room);
}
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
let (provider, model) = app.registry().resolve(&model_spec)?;
@@ -257,10 +262,11 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
}
}
let tools = available_tools(&app, &agent);
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
let tools = available_tools(&app, &agent, owner.as_ref());
let history = active_history(app.store.messages(session_id)?);
let req = ChatRequest {
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode),
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()),
messages: history,
tools: tools.iter().map(|t| t.def()).collect(),
max_tokens: model.max_output.unwrap_or(16_000),
@@ -320,6 +326,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
cancel: cancel.clone(),
agent: agent.name.clone(),
roots: roots.clone(),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
clean_room,
};
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only()));
@@ -387,6 +395,8 @@ async fn execute_call(
cancel: base.cancel.clone(),
agent: base.agent.clone(),
roots: base.roots.clone(),
sandbox: base.sandbox.clone(),
clean_room: base.clean_room,
};
// mcp_call is judged (and approved) as the MCP tool it calls.
let call_name = name;
@@ -443,6 +453,15 @@ async fn execute_call(
Decision::Allow => {}
}
// Checkpoint: remember files before the agent changes them (rewind can restore them).
if tool.is_edit() {
if let Some(path) = input["path"].as_str().and_then(|p| ctx.resolve(p).ok()) {
let content = std::fs::read(&path).ok().filter(|c| c.len() <= 5 * 1024 * 1024);
if path.exists() == content.is_some() {
let _ = app.store.add_checkpoint(&session.id, &path.display().to_string(), content.as_deref());
}
}
}
let res = if name == "task" {
// Pass depth through the agent name channel: subagents run with depth+1.
run_task(app, &ctx, input, depth).await
@@ -551,6 +570,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
updated_at: now,
todos: Value::Null,
last_usage: None,
clean_room: parent.clean_room,
};
let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
@@ -586,6 +606,29 @@ async fn run_child(app: Arc<App>, child: &Session, agent: &Agent, cancel: Cancel
Ok(if text.trim().is_empty() { "(subagent returned no text)".into() } else { text })
}
/// Rough prompt size (≈ 3.8 characters per token, ~1,000 tokens per image).
fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
let mut chars = req.system.len();
let mut images = 0u64;
for m in &req.messages {
for p in &m.parts {
match p {
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text } => chars += text.len(),
Part::ToolCall { input, .. } => chars += input.to_string().len(),
Part::ToolResult { content, images: imgs, .. } => {
chars += content.len();
images += imgs.len() as u64;
}
Part::Image { .. } => images += 1,
}
}
}
for t in &req.tools {
chars += t.description.len() + t.input_schema.to_string().len() + t.name.len();
}
(chars as f64 / 3.8) as u64 + images * 1000
}
fn is_retryable(e: &anyhow::Error) -> bool {
let s = format!("{e:#}");
s.contains("HTTP 429") || s.contains("HTTP 5") || s.contains("overloaded") || s.contains("connection") || s.contains("timed out")
@@ -601,6 +644,8 @@ async fn stream_step(
req: ChatRequest,
cancel: &CancellationToken,
) -> Result<Step> {
let provider_name = model_spec.split_once('/').map(|(p, _)| p).unwrap_or(model_spec);
crate::budget::check(app, provider_name, session.owner_id.as_deref())?;
let mut attempt = 0;
loop {
attempt += 1;
@@ -619,6 +664,13 @@ async fn stream_step(
_ = cancel.cancelled() => bail!("aborted"),
};
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
app.emit(Event::StepStarted {
session_id: session.id.clone(),
message_id: msg_id.clone(),
started_at: now_ms(),
prompt_tokens: estimate_prompt_tokens(&req),
estimated: true,
});
let handle = tokio::spawn(async move {
let _slot = slot;
p.stream(r, tx, c).await