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
+153
-4
@@ -75,6 +75,9 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/{id}/abort", post(abort))
|
||||
.route("/sessions/{id}/compact", post(compact))
|
||||
.route("/sessions/{id}/rewind", post(rewind))
|
||||
.route("/sessions/{id}/changes", get(changed_files))
|
||||
.route("/sessions/{id}/shells", get(list_shells))
|
||||
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
|
||||
.route("/sessions/{id}/stats", get(session_stats))
|
||||
.route("/sessions/{id}/shares", get(get_shares).put(put_shares))
|
||||
.route("/sessions/{id}/queue", get(get_queue))
|
||||
@@ -95,10 +98,19 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/files/delete", delete(crate::files::remove))
|
||||
.route("/files/upload", put(crate::files::upload))
|
||||
.route("/files/search", get(crate::files::search))
|
||||
.route("/files/preview/pdf-info", get(crate::files::pdf_info))
|
||||
.route("/files/preview/pdf-page", get(crate::files::pdf_page))
|
||||
.route("/files/preview/doc", get(crate::files::doc_preview))
|
||||
.route("/files/preview/module", get(crate::files::module_audio))
|
||||
.route("/git", post(crate::git::handle))
|
||||
.route("/fs/dirs", get(list_dirs))
|
||||
.route("/stats", get(stats))
|
||||
.route("/budget", get(budget))
|
||||
.route("/audit", get(audit_log))
|
||||
.route("/backup", get(backup))
|
||||
.route("/health", get(health))
|
||||
.route("/tools", get(crate::reverse::status))
|
||||
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
|
||||
.route("/providers/{name}/prices", get(provider_prices))
|
||||
.route("/images", get(list_images).post(create_images))
|
||||
.route("/images/config", get(images_config))
|
||||
@@ -166,7 +178,9 @@ async fn patch_config(Extension(user): Extension<User>, State(app): State<AppSta
|
||||
require_admin(&user)?;
|
||||
// Masked secrets coming back from the UI must not overwrite real values.
|
||||
let patches: Vec<ConfigPatch> = body.patches.into_iter().filter(|p| p.value.as_str() != Some("••••••••")).collect();
|
||||
let paths: Vec<String> = patches.iter().map(|p| p.path.join(".")).collect();
|
||||
app.patch_config(&patches).await?;
|
||||
app.audit(Some(&user), "config.patch", paths.join(", "), "");
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
@@ -183,6 +197,7 @@ struct RawBody {
|
||||
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
app.save_config(&body.text).await?;
|
||||
app.audit(Some(&user), "config.raw", format!("{} bytes", body.text.len()), "");
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
@@ -251,6 +266,7 @@ async fn create_folder(Extension(user): Extension<User>, State(app): State<AppSt
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "folder must be inside the workspace".into()));
|
||||
}
|
||||
let id = app.store.add_folder(&path.display().to_string())?;
|
||||
app.audit(Some(&user), "folder.create", path.display().to_string(), "");
|
||||
app.store.set_folder_access(&id, &body.users)?;
|
||||
Ok(Json(json!({"id": id, "path": path})))
|
||||
}
|
||||
@@ -270,6 +286,7 @@ struct AccessBody {
|
||||
async fn set_folder_access(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<AccessBody>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
app.store.set_folder_access(&id, &body.users)?;
|
||||
app.audit(Some(&user), "folder.access", format!("{id}: {} user(s)", body.users.len()), "");
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
@@ -343,6 +360,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
updated_at: now,
|
||||
todos: Value::Null,
|
||||
last_usage: None,
|
||||
clean_room: None,
|
||||
};
|
||||
let s = app.save_session(s)?;
|
||||
Ok(Json(json!(s)))
|
||||
@@ -354,7 +372,8 @@ async fn get_session(Extension(user): Extension<User>, State(app): State<AppStat
|
||||
let busy = app.is_busy(&id);
|
||||
let can_write = app.access(&user, &id) == Access::Full;
|
||||
let queue = app.store.queue_list(&id)?;
|
||||
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue})))
|
||||
let step = app.with_runtime(&id, |r| r.step.clone());
|
||||
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue, "step": step})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -363,6 +382,7 @@ struct UpdateSession {
|
||||
model: Option<String>,
|
||||
agent: Option<String>,
|
||||
permission_mode: Option<String>,
|
||||
clean_room: Option<bool>,
|
||||
}
|
||||
|
||||
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||
@@ -377,6 +397,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
if let Some(a) = body.agent {
|
||||
s.agent = a;
|
||||
}
|
||||
if let Some(c) = body.clean_room {
|
||||
s.clean_room = Some(c);
|
||||
}
|
||||
if let Some(p) = body.permission_mode {
|
||||
if !crate::permission::MODES.contains(&p.as_str()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
@@ -391,6 +414,7 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
// Notify before deleting: visibility is checked against the stored session.
|
||||
app.emit(Event::SessionDeleted { session_id: id.clone() });
|
||||
runner::abort(&app, &id);
|
||||
crate::shells::kill_session(&app, &id);
|
||||
app.store.delete_session(&id)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
@@ -417,11 +441,16 @@ async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, P
|
||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||
let mut prompt_text = text.clone();
|
||||
if text.starts_with('/') {
|
||||
if let Some((expanded, agent)) = prompts::expand_command(&app, &root, &cwd, &text) {
|
||||
if let Some((expanded, agent, clean_room)) = prompts::expand_command(&app, &root, &cwd, &text) {
|
||||
prompt_text = expanded;
|
||||
if let Some(a) = agent {
|
||||
if agent.is_some() || clean_room.is_some() {
|
||||
let mut s = session.clone();
|
||||
s.agent = a;
|
||||
if let Some(a) = agent {
|
||||
s.agent = a;
|
||||
}
|
||||
if clean_room.is_some() {
|
||||
s.clean_room = clean_room;
|
||||
}
|
||||
app.save_session(s)?;
|
||||
}
|
||||
}
|
||||
@@ -485,6 +514,9 @@ async fn compact(Extension(user): Extension<User>, State(app): State<AppState>,
|
||||
#[derive(Deserialize)]
|
||||
struct RewindBody {
|
||||
message_id: String,
|
||||
/// Also restore files the agent changed since that message.
|
||||
#[serde(default)]
|
||||
restore_files: bool,
|
||||
}
|
||||
|
||||
/// Remove a message and everything after it (edit & resend). Files are not restored.
|
||||
@@ -493,12 +525,73 @@ async fn rewind(Extension(user): Extension<User>, State(app): State<AppState>, P
|
||||
if app.is_busy(&id) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||
}
|
||||
let since = app.store.message_created_at(&id, &body.message_id)?.ok_or_else(|| not_found("message"))?;
|
||||
let mut restored = Vec::new();
|
||||
if body.restore_files {
|
||||
let roots = app.user_roots(s.owner_id.as_deref());
|
||||
for (path, content) in app.store.checkpoints_since(&id, since)? {
|
||||
let p = PathBuf::from(&path);
|
||||
// Only inside the owner's folders (they may have changed since).
|
||||
if !roots.iter().any(|r| p.starts_with(r)) {
|
||||
continue;
|
||||
}
|
||||
match content {
|
||||
Some(bytes) => {
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
std::fs::write(&p, bytes)?;
|
||||
}
|
||||
None => {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
restored.push(path);
|
||||
}
|
||||
}
|
||||
app.store.delete_checkpoints_since(&id, since)?;
|
||||
app.store.truncate_from(&id, &body.message_id)?;
|
||||
s.last_usage = None;
|
||||
app.save_session(s)?;
|
||||
Ok(Json(json!({"ok": true, "restored": restored})))
|
||||
}
|
||||
|
||||
/// Background shells of a session (and its subagents).
|
||||
async fn list_shells(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let root = app.root_session_id(&id);
|
||||
let list: Vec<Value> = app
|
||||
.shells
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|s| app.root_session_id(&s.session_id) == root)
|
||||
.map(|s| s.json(20_000))
|
||||
.collect();
|
||||
Ok(Json(json!(list)))
|
||||
}
|
||||
|
||||
async fn kill_shell(Extension(user): Extension<User>, State(app): State<AppState>, Path((id, shell)): Path<(String, String)>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
let root = app.root_session_id(&id);
|
||||
let s = app.shells.lock().unwrap().get(&shell).cloned().filter(|s| app.root_session_id(&s.session_id) == root).ok_or_else(|| not_found("shell"))?;
|
||||
s.kill();
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChangesQuery {
|
||||
since_message: String,
|
||||
}
|
||||
|
||||
/// Files the agent changed since a message (for the rewind dialog).
|
||||
async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX);
|
||||
let files: Vec<Value> = app.store.checkpoints_since(&id, since)?.into_iter().map(|(p, c)| json!({"path": p, "created": c.is_none()})).collect();
|
||||
Ok(Json(json!(files)))
|
||||
}
|
||||
|
||||
async fn session_stats(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let mut v = app.store.session_stats(&id)?;
|
||||
@@ -518,6 +611,7 @@ async fn put_shares(Extension(user): Extension<User>, State(app): State<AppState
|
||||
}
|
||||
let users: Vec<String> = body.users.into_iter().filter(|u| Some(u) != s.owner_id.as_ref()).collect();
|
||||
app.set_shares(&id, &users)?;
|
||||
app.audit(Some(&user), "session.share", format!("{id}: {} user(s)", users.len()), "");
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
@@ -540,7 +634,9 @@ struct PermissionBody {
|
||||
|
||||
async fn reply_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
|
||||
check_pending(&app, &user, &id)?;
|
||||
let what = app.pending_title(&id).unwrap_or_default();
|
||||
app.reply_permission(&id, body.reply, body.message)?;
|
||||
app.audit(Some(&user), &format!("permission.{}", serde_json::to_value(body.reply).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default()), what, "");
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
@@ -618,6 +714,59 @@ async fn list_dirs(Extension(user): Extension<User>, State(app): State<AppState>
|
||||
})))
|
||||
}
|
||||
|
||||
// --- audit & backup -------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuditQuery {
|
||||
limit: Option<i64>,
|
||||
action: Option<String>,
|
||||
}
|
||||
|
||||
async fn audit_log(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<AuditQuery>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
Ok(Json(json!(app.store.audit_list(q.limit.unwrap_or(300).min(5000), q.action.as_deref().filter(|a| !a.is_empty()))?)))
|
||||
}
|
||||
|
||||
/// ZIP with config.toml and a consistent database snapshot.
|
||||
async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -> Result<Response, ApiError> {
|
||||
require_admin(&user)?;
|
||||
let tmp = std::env::temp_dir().join(format!("cc-backup-{}.db", new_id()));
|
||||
app.store.snapshot(&tmp)?;
|
||||
let config = app.config_text()?;
|
||||
let db = tokio::fs::read(&tmp).await?;
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
let zip_bytes = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
|
||||
use std::io::Write;
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
z.start_file("config.toml", opts)?;
|
||||
z.write_all(config.as_bytes())?;
|
||||
z.start_file("codecraft.db", opts)?;
|
||||
z.write_all(&db)?;
|
||||
z.finish()?;
|
||||
}
|
||||
Ok(buf.into_inner())
|
||||
})
|
||||
.await??;
|
||||
app.audit(Some(&user), "backup.download", format!("{} bytes", zip_bytes.len()), "");
|
||||
let name = format!("codecraft-backup-{}.zip", chrono::Local::now().format("%Y%m%d-%H%M"));
|
||||
Ok((
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, "application/zip".to_string()),
|
||||
(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\"")),
|
||||
],
|
||||
zip_bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Liveness for monitoring (no auth needed – see auth::is_public).
|
||||
async fn health(State(app): State<AppState>) -> Json<Value> {
|
||||
Json(json!({"ok": true, "version": env!("CARGO_PKG_VERSION"), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
|
||||
}
|
||||
|
||||
// --- budget -------------------------------------------------------------------
|
||||
|
||||
/// Budgets concern the whole installation: admins only.
|
||||
|
||||
Reference in new issue
Block a user