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
+128
-3
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user