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:
1 parent
fab9add5d5
commit
5b569b6835
108 files changed
+6785
-279
No files matched your search
+38
-13
@@ -24,7 +24,9 @@ impl Tool for Bash {
|
||||
"Run a shell command (bash) in the session working directory. stdout and stderr are \
|
||||
combined. Default timeout 2 minutes, max 10 minutes (timeout_ms). The working directory \
|
||||
does not persist between calls – use absolute paths or `cd dir && ...`. Prefer the \
|
||||
dedicated read/edit/glob/grep tools over cat/sed/find/grep."
|
||||
dedicated read/edit/glob/grep tools over cat/sed/find/grep. For long-running processes \
|
||||
(dev servers, watchers, long builds) set run_in_background=true: you get a shell id, \
|
||||
read new output with bash_output and stop it with kill_shell."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
@@ -33,7 +35,8 @@ impl Tool for Bash {
|
||||
"properties": {
|
||||
"command": {"type": "string"},
|
||||
"description": {"type": "string", "description": "5-10 word description of what the command does"},
|
||||
"timeout_ms": {"type": "integer"}
|
||||
"timeout_ms": {"type": "integer"},
|
||||
"run_in_background": {"type": "boolean", "description": "Keep running; read output with bash_output"}
|
||||
},
|
||||
"required": ["command"]
|
||||
})
|
||||
@@ -49,17 +52,18 @@ impl Tool for Bash {
|
||||
let timeout = Duration::from_millis(
|
||||
input["timeout_ms"].as_u64().unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS),
|
||||
);
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg("-c")
|
||||
.arg(format!("{command} 2>&1"))
|
||||
.current_dir(&ctx.cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.env("CODECRAFT", "1")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("PAGER", "cat")
|
||||
.kill_on_drop(true);
|
||||
let mut cmd = match shell_command(ctx, command) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Ok(e),
|
||||
};
|
||||
if input["run_in_background"].as_bool().unwrap_or(false) {
|
||||
let id = crate::shells::spawn(&ctx.app, &ctx.session_id, command, cmd)?;
|
||||
return Ok(ToolOutput::text(format!(
|
||||
"Started in the background with shell id {id}. Read its output with bash_output (shell_id \"{id}\") and stop it with kill_shell."
|
||||
))
|
||||
.with_meta(json!({"shell_id": id})));
|
||||
}
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true);
|
||||
// Own process group so the whole tree can be killed on abort/timeout.
|
||||
cmd.process_group(0);
|
||||
let mut child = cmd.spawn()?;
|
||||
@@ -128,6 +132,27 @@ impl Tool for Bash {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bash command for a script, sandboxed for non-admin users.
|
||||
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
|
||||
// stderr of every command (not just the last one) goes into the output.
|
||||
let script = format!("exec 2>&1\n{command}");
|
||||
Ok(match &ctx.sandbox {
|
||||
Some(spec) => {
|
||||
if !crate::sandbox::available() {
|
||||
return Err(ToolOutput::error(
|
||||
"Shell commands are disabled for this account: the server's sandbox (bubblewrap) is not available. Ask an admin.",
|
||||
));
|
||||
}
|
||||
crate::sandbox::command(spec, &ctx.cwd, "bash", &["-c", &script])
|
||||
}
|
||||
None => {
|
||||
let mut c = Command::new("bash");
|
||||
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
||||
c
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tail(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
|
||||
@@ -100,6 +100,12 @@ impl Tool for BrowserSnapshot {
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let url = arg_str(&input, "url")?.to_string();
|
||||
// Local files only from the user's own folders.
|
||||
if let Some(path) = url.strip_prefix("file://") {
|
||||
ctx.resolve(path)?;
|
||||
} else if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
bail!("only http(s) and file:// URLs are supported");
|
||||
}
|
||||
let label = slug(input["label"].as_str().unwrap_or(&url));
|
||||
let widths: Vec<u32> = input["widths"]
|
||||
.as_array()
|
||||
|
||||
+14
-5
@@ -46,6 +46,8 @@ impl Tool for Read {
|
||||
fn description(&self) -> String {
|
||||
"Read a file. Returns numbered lines (cat -n style). Use offset/limit for large files \
|
||||
(default: first 2000 lines). Images (png, jpg, gif, webp) are returned as images. \
|
||||
Documents are converted to text: PDF (pages=\"3-7\", scanned pages come as images), \
|
||||
Excel/ODS spreadsheets (sheet=\"Name\"), Word .docx and PowerPoint .pptx. \
|
||||
Always read a file before editing or overwriting it."
|
||||
.into()
|
||||
}
|
||||
@@ -55,7 +57,9 @@ impl Tool for Read {
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path, absolute or relative to the working directory"},
|
||||
"offset": {"type": "integer", "description": "1-based line to start from"},
|
||||
"limit": {"type": "integer", "description": "Number of lines to read"}
|
||||
"limit": {"type": "integer", "description": "Number of lines to read"},
|
||||
"pages": {"type": "string", "description": "PDF only: page or range, e.g. \"2-5\""},
|
||||
"sheet": {"type": "string", "description": "Spreadsheets only: sheet name"}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
@@ -67,10 +71,15 @@ impl Tool for Read {
|
||||
format!("Read {}", input["path"].as_str().unwrap_or("?"))
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||||
let path = ctx.resolve_read(arg_str(&input, "path")?)?;
|
||||
if path.is_dir() {
|
||||
bail!("{} is a directory – use the list tool", path.display());
|
||||
}
|
||||
if crate::docs::is_document(&path) {
|
||||
ctx.app.mark_read(&ctx.session_id, &path);
|
||||
let (text, images) = crate::docs::extract(&path, input["pages"].as_str(), input["sheet"].as_str()).await?;
|
||||
return Ok(ToolOutput { content: truncate(&text, MAX_OUTPUT * 2), images, ..Default::default() });
|
||||
}
|
||||
let bytes = tokio::fs::read(&path).await?;
|
||||
ctx.app.mark_read(&ctx.session_id, &path);
|
||||
if let Some(mt) = image_type(&path) {
|
||||
@@ -273,7 +282,7 @@ impl Tool for List {
|
||||
true
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let dir = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
|
||||
let dir = ctx.resolve_read(input["path"].as_str().unwrap_or("."))?;
|
||||
let depth = input["depth"].as_u64().unwrap_or(1).clamp(1, 5) as usize;
|
||||
let cwd = ctx.cwd.clone();
|
||||
let out = tokio::task::spawn_blocking(move || {
|
||||
@@ -328,7 +337,7 @@ impl Tool for Glob {
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let pattern = arg_str(&input, "pattern")?.to_string();
|
||||
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
|
||||
let root = ctx.resolve_read(input["path"].as_str().unwrap_or("."))?;
|
||||
let out = tokio::task::spawn_blocking(move || -> Result<String> {
|
||||
let matcher = globset::GlobBuilder::new(&pattern).literal_separator(true).build()?.compile_matcher();
|
||||
let mut hits = Vec::new();
|
||||
@@ -394,7 +403,7 @@ impl Tool for Grep {
|
||||
let re = regex::RegexBuilder::new(arg_str(&input, "pattern")?)
|
||||
.case_insensitive(input["case_insensitive"].as_bool().unwrap_or(false))
|
||||
.build()?;
|
||||
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
|
||||
let root = ctx.resolve_read(input["path"].as_str().unwrap_or("."))?;
|
||||
let glob = input["glob"]
|
||||
.as_str()
|
||||
.map(|g| globset::Glob::new(g).map(|g| g.compile_matcher()))
|
||||
|
||||
@@ -49,6 +49,10 @@ pub struct ToolCtx {
|
||||
pub agent: String,
|
||||
/// Directories this session may access (the owner's roots).
|
||||
pub roots: Vec<PathBuf>,
|
||||
/// Shell sandbox of the session owner (None = run unsandboxed).
|
||||
pub sandbox: Option<crate::sandbox::SandboxSpec>,
|
||||
/// Craft clean room active for this session (no page sources, no binaries).
|
||||
pub clean_room: bool,
|
||||
}
|
||||
|
||||
impl ToolCtx {
|
||||
@@ -71,6 +75,17 @@ impl ToolCtx {
|
||||
bail!("path {} is outside your directories ({})", norm.display(), allowed.join(", "))
|
||||
}
|
||||
|
||||
/// Like `resolve`, but also allows the read-only built-in resources (skill templates).
|
||||
pub fn resolve_read(&self, p: &str) -> Result<PathBuf> {
|
||||
let builtin = normalize(&crate::prompts::builtin_resources());
|
||||
let raw = if Path::new(p.trim()).is_absolute() { PathBuf::from(p.trim()) } else { self.cwd.join(p.trim()) };
|
||||
let norm = normalize(&raw);
|
||||
if norm.starts_with(&builtin) {
|
||||
return Ok(norm);
|
||||
}
|
||||
self.resolve(p)
|
||||
}
|
||||
|
||||
/// The root directory containing the working directory.
|
||||
pub fn root(&self) -> PathBuf {
|
||||
let mut roots = self.roots.clone();
|
||||
@@ -140,7 +155,10 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||
Arc::new(fs::Glob),
|
||||
Arc::new(fs::Grep),
|
||||
Arc::new(bash::Bash),
|
||||
Arc::new(crate::shells::BashOutput),
|
||||
Arc::new(crate::shells::KillShell),
|
||||
Arc::new(web::WebFetch),
|
||||
Arc::new(web::WebSearch),
|
||||
Arc::new(agent::TodoWrite),
|
||||
Arc::new(agent::AskUser),
|
||||
Arc::new(agent::Task),
|
||||
@@ -148,6 +166,8 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||
Arc::new(craft::ImageCompare),
|
||||
Arc::new(crate::prompts::SkillTool),
|
||||
Arc::new(crate::images::GenerateImage),
|
||||
Arc::new(crate::reverse::BinaryInfo),
|
||||
Arc::new(crate::reverse::Decompile),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+153
-3
@@ -43,10 +43,10 @@ impl Tool for WebFetch {
|
||||
bail!("only http(s) URLs are supported");
|
||||
}
|
||||
let raw = input["raw"].as_bool().unwrap_or(false);
|
||||
if raw && ctx.agent == "craft" && ctx.app.config().craft.clean_room {
|
||||
if raw && ctx.agent == "craft" && ctx.clean_room {
|
||||
return Ok(ToolOutput::error(
|
||||
"Clean room mode: page source may not be read. Use browser_snapshot, or switch \
|
||||
craft.clean_room to false (deep mode) in the settings.",
|
||||
"Clean room mode: page source may not be read. Use browser_snapshot, or ask the user \
|
||||
to switch this session to Deep mode.",
|
||||
));
|
||||
}
|
||||
let resp = crate::provider::http_client()
|
||||
@@ -73,3 +73,153 @@ impl Tool for WebFetch {
|
||||
Ok(if status.is_success() { ToolOutput::text(out) } else { ToolOutput::error(out) })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct WebSearch;
|
||||
|
||||
struct Hit {
|
||||
title: String,
|
||||
url: String,
|
||||
snippet: String,
|
||||
}
|
||||
|
||||
fn strip_tags(s: &str) -> String {
|
||||
let re = regex::Regex::new(r"<[^>]+>").unwrap();
|
||||
html2text::from_read(re.replace_all(s, "").as_bytes(), 10_000).unwrap_or_default().trim().replace('\n', " ")
|
||||
}
|
||||
|
||||
async fn duckduckgo(q: &str, n: usize) -> Result<Vec<Hit>> {
|
||||
let html = crate::provider::http_client()
|
||||
.post("https://html.duckduckgo.com/html/")
|
||||
.form(&[("q", q)])
|
||||
.header("User-Agent", "Mozilla/5.0 (X11; Linux) CodeCraft")
|
||||
.timeout(Duration::from_secs(20))
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
let link = regex::Regex::new(r#"(?s)<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>"#).unwrap();
|
||||
let snip = regex::Regex::new(r#"(?s)<a[^>]+class="result__snippet"[^>]*>(.*?)</a>"#).unwrap();
|
||||
let snippets: Vec<String> = snip.captures_iter(&html).map(|c| strip_tags(&c[1])).collect();
|
||||
let mut hits = Vec::new();
|
||||
for (i, c) in link.captures_iter(&html).enumerate() {
|
||||
let mut url = c[1].replace("&", "&");
|
||||
// DuckDuckGo wraps results in a redirect: //duckduckgo.com/l/?uddg=<encoded>&...
|
||||
if let Some(enc) = url.split("uddg=").nth(1) {
|
||||
let enc = enc.split('&').next().unwrap_or(enc);
|
||||
url = urlencoding_decode(enc);
|
||||
}
|
||||
if url.contains("duckduckgo.com/y.js") {
|
||||
continue; // ads
|
||||
}
|
||||
hits.push(Hit { title: strip_tags(&c[2]), url, snippet: snippets.get(i).cloned().unwrap_or_default() });
|
||||
if hits.len() >= n {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if hits.is_empty() && html.contains("anomaly") {
|
||||
bail!("DuckDuckGo rejected the request (rate limit) – configure another search provider");
|
||||
}
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
fn urlencoding_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'%' if i + 2 < bytes.len() => {
|
||||
if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
||||
out.push(b);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(b'%');
|
||||
}
|
||||
b'+' => out.push(b' '),
|
||||
b => out.push(b),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
async fn search(cfg: &crate::config::SearchConfig, q: &str, n: usize) -> Result<Vec<Hit>> {
|
||||
let client = crate::provider::http_client();
|
||||
let key = crate::config::expand_env(&cfg.api_key);
|
||||
let pick = |v: &Value, title: &str, url: &str, snippet: &str| Hit {
|
||||
title: v[title].as_str().unwrap_or("").to_string(),
|
||||
url: v[url].as_str().unwrap_or("").to_string(),
|
||||
snippet: v[snippet].as_str().unwrap_or("").to_string(),
|
||||
};
|
||||
Ok(match cfg.provider.as_str() {
|
||||
"searxng" => {
|
||||
let base = crate::config::expand_env(&cfg.url);
|
||||
let url = reqwest::Url::parse_with_params(&format!("{}/search", base.trim_end_matches('/')), &[("q", q), ("format", "json")])?;
|
||||
let v: Value = client.get(url).timeout(Duration::from_secs(20)).send().await?.error_for_status()?.json().await?;
|
||||
v["results"].as_array().cloned().unwrap_or_default().iter().take(n).map(|r| pick(r, "title", "url", "content")).collect()
|
||||
}
|
||||
"brave" => {
|
||||
let v: Value = client
|
||||
.get(reqwest::Url::parse_with_params("https://api.search.brave.com/res/v1/web/search", &[("q", q), ("count", &n.to_string())])?)
|
||||
.header("X-Subscription-Token", key)
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(20))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
v["web"]["results"].as_array().cloned().unwrap_or_default().iter().take(n).map(|r| pick(r, "title", "url", "description")).collect()
|
||||
}
|
||||
"tavily" => {
|
||||
let v: Value = client
|
||||
.post("https://api.tavily.com/search")
|
||||
.json(&json!({"api_key": key, "query": q, "max_results": n}))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
v["results"].as_array().cloned().unwrap_or_default().iter().take(n).map(|r| pick(r, "title", "url", "content")).collect()
|
||||
}
|
||||
_ => duckduckgo(q, n).await?,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WebSearch {
|
||||
fn name(&self) -> &str {
|
||||
"web_search"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Search the web. Returns titles, URLs and snippets; read promising results with web_fetch. \
|
||||
Use for current information, documentation, error messages and libraries."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({"type": "object", "properties": {"query": {"type": "string"}, "max_results": {"type": "integer", "description": "1-20, default 8"}}, "required": ["query"]})
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("Search: {}", input["query"].as_str().unwrap_or(""))
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let q = arg_str(&input, "query")?;
|
||||
let n = input["max_results"].as_u64().unwrap_or(8).clamp(1, 20) as usize;
|
||||
let hits = search(&ctx.app.config().search, q, n).await?;
|
||||
if hits.is_empty() {
|
||||
return Ok(ToolOutput::text("No results."));
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (i, h) in hits.iter().enumerate() {
|
||||
out.push_str(&format!("{}. {}\n {}\n {}\n\n", i + 1, h.title, h.url, h.snippet));
|
||||
}
|
||||
Ok(ToolOutput::text(out))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user