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

+128 -3
View File
@@ -97,6 +97,25 @@ impl Store {
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
user_id TEXT,
username TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS audit_ts ON audit(ts);
CREATE TABLE IF NOT EXISTS checkpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
ts INTEGER NOT NULL,
path TEXT NOT NULL,
existed INTEGER NOT NULL,
content BLOB
);
CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts);
CREATE TABLE IF NOT EXISTS session_shares (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -107,6 +126,7 @@ impl Store {
for (table, column, ddl) in [
("sessions", "owner_id", "ALTER TABLE sessions ADD COLUMN owner_id TEXT"),
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
] {
let exists: bool = conn
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
@@ -299,7 +319,19 @@ impl Store {
Ok(v)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(json!({"total": total, "by_model": by_model}))
let mut stmt = conn.prepare(&format!(
"SELECT agent || ' · ' || provider || '/' || model, {AGG} FROM usage WHERE root_session_id = ?1 GROUP BY 1 ORDER BY 1"
))?;
let by_agent = stmt
.query_map([root_session_id], |r| {
let mut v = agg_row(r, 1)?;
v["key"] = json!(r.get::<_, String>(0)?);
Ok(v)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let (first, last): (Option<i64>, Option<i64>) =
conn.query_row("SELECT MIN(ts), MAX(ts) FROM usage WHERE root_session_id = ?1", [root_session_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
}
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
@@ -407,6 +439,8 @@ pub struct User {
pub oidc: bool,
pub disabled: bool,
pub created_at: i64,
/// Monthly cost limit for this user (None = unlimited).
pub monthly_quota: Option<f64>,
}
impl User {
@@ -416,7 +450,7 @@ impl User {
}
const USER_COLS: &str =
"id, username, display_name, role, password_hash IS NOT NULL, oidc_sub IS NOT NULL, disabled, created_at";
"id, username, display_name, role, password_hash IS NOT NULL, oidc_sub IS NOT NULL, disabled, created_at, monthly_quota";
fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
Ok(User {
@@ -428,6 +462,7 @@ fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
oidc: r.get(5)?,
disabled: r.get::<_, i64>(6)? != 0,
created_at: r.get(7)?,
monthly_quota: r.get(8)?,
})
}
@@ -461,7 +496,7 @@ impl Store {
.query_row(
&format!("SELECT {USER_COLS}, password_hash FROM users WHERE username = ?1"),
[username],
|r| Ok((user_row(r)?, r.get::<_, Option<String>>(8)?)),
|r| Ok((user_row(r)?, r.get::<_, Option<String>>(9)?)),
)
.optional()?)
}
@@ -770,3 +805,93 @@ impl Store {
Ok(items)
}
}
impl Store {
pub fn set_user_quota(&self, id: &str, quota: Option<f64>) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("UPDATE users SET monthly_quota = ?2 WHERE id = ?1", params![id, quota])?;
Ok(())
}
/// Cost of a user's requests since `from` (ms).
pub fn user_cost_since(&self, user_id: &str, from: i64) -> Result<f64> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT COALESCE(SUM(cost), 0) FROM usage WHERE user_id = ?1 AND ts >= ?2", params![user_id, from], |r| r.get(0))?)
}
}
// --- audit log & backup ----------------------------------------------------------
impl Store {
pub fn audit(&self, user_id: Option<&str>, username: &str, action: &str, detail: &str, ip: &str) {
let conn = self.conn.lock().unwrap();
let _ = conn.execute(
"INSERT INTO audit (ts, user_id, username, action, detail, ip) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![now_ms(), user_id, username, action, detail, ip],
);
}
pub fn audit_list(&self, limit: i64, action: Option<&str>) -> Result<Vec<Value>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT ts, username, action, detail, ip FROM audit WHERE ?2 IS NULL OR action LIKE ?2 || '%' ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit, action], |r| {
Ok(json!({"ts": r.get::<_, i64>(0)?, "user": r.get::<_, String>(1)?, "action": r.get::<_, String>(2)?, "detail": r.get::<_, String>(3)?, "ip": r.get::<_, String>(4)?}))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
/// Consistent copy of the database (safe while the server runs).
pub fn snapshot(&self, target: &std::path::Path) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("VACUUM INTO ?1", [target.to_string_lossy()])?;
Ok(())
}
}
// --- file checkpoints ------------------------------------------------------------
impl Store {
/// Remember a file's content before the agent changes it.
pub fn add_checkpoint(&self, session_id: &str, path: &str, content: Option<&[u8]>) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO checkpoints (session_id, ts, path, existed, content) VALUES (?1, ?2, ?3, ?4, ?5)",
params![session_id, now_ms(), path, content.is_some() as i64, content],
)?;
Ok(())
}
/// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore.
pub fn checkpoints_since(&self, session_id: &str, since: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2
AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND path = c.path)",
)?;
let rows = stmt
.query_map(params![session_id, since], |r| {
let existed: i64 = r.get(1)?;
Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None }))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn delete_checkpoints_since(&self, session_id: &str, since: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM checkpoints WHERE session_id = ?1 AND ts >= ?2", params![session_id, since])?;
Ok(())
}
pub fn message_created_at(&self, session_id: &str, message_id: &str) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap();
let data: Option<String> = conn
.query_row("SELECT data FROM messages WHERE session_id = ?1 AND id = ?2", params![session_id, message_id], |r| r.get(0))
.optional()?;
Ok(data.and_then(|d| serde_json::from_str::<Value>(&d).ok()).and_then(|v| v["created_at"].as_i64()))
}
}