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

+67 -22
View File
@@ -1,16 +1,19 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";
import { get, patch as apiPatch, post, put } from "../api";
import { useFetch, useModels } from "../hooks";
import { getLang, languages, setLang, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { applyTheme, getTheme, THEMES, type Theme } from "../theme";
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
import { ModelSelect } from "./ModelSelect";
import { Account } from "./settings/Account";
import { AgentsEditor } from "./settings/AgentsEditor";
import { Folders } from "./settings/Folders";
import { Users } from "./settings/Users";
import { Audit } from "./settings/Audit";
import { Tools } from "./settings/Tools";
import { confirmDialog, promptDialog, toast } from "../dialogs";
type Json = any;
interface ConfigResponse {
@@ -48,7 +51,9 @@ function useSaver(reload: () => void) {
function InterfaceSettings() {
const t = useT();
const reasoning = useReasoningMode();
const [theme, setThemeState] = useState<Theme>(getTheme());
const theme = useSyncExternalStore(onThemeChange, getTheme);
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const themeInfo = THEMES.find((th) => th.id === theme)!;
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
return (
<div className="panel">
@@ -63,20 +68,36 @@ function InterfaceSettings() {
))}
</select>
<label>{t("settings.theme")}</label>
<select
value={theme}
onChange={(e) => {
const v = e.target.value as Theme;
setThemeState(v);
applyTheme(v);
}}
>
{THEMES.map((th) => (
<option key={th.id} value={th.id}>
{th.id === "system" ? t("settings.themeSystem") : th.name}
</option>
<div className="stack">
<select
value={theme}
onChange={(e) => {
const v = e.target.value as Theme;
applyTheme(v, mode);
}}
>
{THEMES.map((th) => (
<option key={th.id} value={th.id}>
{th.name}
</option>
))}
</select>
<div className="hint">{t("settings.themeSource", { source: themeInfo.source, license: themeInfo.license })}</div>
</div>
<label>{t("settings.themeMode")}</label>
<div className="segmented">
{THEME_MODES.map((m) => (
<button
key={m}
className={`small ${m === mode ? "active" : "ghost"}`}
onClick={() => {
applyTheme(theme, m);
}}
>
{t(m === "auto" ? "settings.themeAuto" : m === "light" ? "settings.themeLight" : "settings.themeDark")}
</button>
))}
</select>
</div>
<label>{t("settings.reasoningDisplay")}</label>
<select value={reasoning} onChange={(e) => setReasoningMode(e.target.value as ReasoningMode)}>
<option value="collapsed">{t("settings.reasoningCollapsed")}</option>
@@ -114,6 +135,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
monthly_budget: s.monthly_budget ?? 0,
budget_warning: s.budget_warning ?? 0.8,
images_budget: cfg.config.images?.monthly_budget ?? "",
budget_action: s.budget_action ?? "warn",
sandbox_mode: cfg.config.sandbox?.mode ?? "auto",
clean_room: c.clean_room ?? true,
chromium: c.chromium ?? "chromium",
widths: (c.widths ?? []).join(", "),
@@ -134,6 +157,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
{ path: ["server", "budget_action"], value: form.budget_action },
{ path: ["sandbox", "mode"], value: form.sandbox_mode },
{ path: ["images", "monthly_budget"], value: form.images_budget === "" ? null : Number(form.images_budget) },
{ path: ["craft", "clean_room"], value: form.clean_room },
{ path: ["craft", "chromium"], value: form.chromium },
@@ -168,6 +193,18 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
<label>{t("settings.budgetWarning")}</label>
<input type="number" min={0} max={1} step={0.05} value={form.budget_warning} onChange={(e) => setForm({ ...form, budget_warning: e.target.value })} />
<label>{t("settings.budgetAction")}</label>
<select value={form.budget_action} onChange={(e) => setForm({ ...form, budget_action: e.target.value })}>
<option value="warn">{t("settings.budgetWarn")}</option>
<option value="block">{t("settings.budgetBlock")}</option>
</select>
<label>{t("settings.sandbox")}</label>
<select value={form.sandbox_mode} onChange={(e) => setForm({ ...form, sandbox_mode: e.target.value })}>
<option value="auto">{t("settings.sandboxAuto")}</option>
<option value="always">{t("settings.sandboxAlways")}</option>
<option value="off">{t("settings.sandboxOff")}</option>
</select>
<div className="hint">{t("settings.sandboxHint")}</div>
<label>{t("settings.maxParallelSubagents")}</label>
<input type="number" min={0} value={form.max_parallel_subagents} onChange={(e) => setForm({ ...form, max_parallel_subagents: e.target.value })} />
<div className="hint">{t("settings.maxParallelSubagentsHint")}</div>
@@ -305,8 +342,8 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
return savePatches(patches);
});
const remove = () => {
if (!confirm(t("settings.deleteProviderConfirm", { name }))) return;
const remove = async () => {
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return;
void run(() => savePatches([{ path: ["providers", name], value: null }]));
};
@@ -469,7 +506,7 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
const add = () => {
const n = name.trim() || preset;
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
const p = PRESETS[preset];
const value: Record<string, unknown> = { kind: p.kind, base_url: p.base_url, api_key: p.api_key, enabled: true, models: [] };
if (p.auth_prefix) value.auth_prefix = p.auth_prefix;
@@ -534,15 +571,18 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
headers: mapToLines(m.headers, ": "),
enabled: m.enabled ?? true,
load: m.load ?? "auto",
users: (m.users ?? []).join(", "),
});
const [open, setOpen] = useState(!!isNew);
const { run, view } = useSaver(reload);
const save = () => {
const n = form.name.trim();
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
const value: Record<string, unknown> = { type: form.type, enabled: form.enabled };
if (form.load !== "auto") value.load = form.load;
const users = form.users.split(/[\s,]+/).filter(Boolean);
if (users.length) value.users = users;
if (form.type === "stdio") {
value.command = form.command;
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
@@ -612,6 +652,9 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
<option value="on_demand">{t("mcp.loadOnDemand")}</option>
</select>
<div className="hint">{t("mcp.loadHint")}</div>
<label>{t("mcp.users")}</label>
<input value={form.users} onChange={(e) => setForm({ ...form, users: e.target.value })} placeholder={t("mcp.usersPlaceholder")} />
<div className="hint">{t("mcp.usersHint")}</div>
</div>
{status?.tools?.length ? (
<details style={{ marginTop: 10 }}>
@@ -632,7 +675,7 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
</button>
<span className="spacer" />
{!isNew && (
<button className="danger" onClick={() => confirm(t("settings.deleteMcpConfirm", { name })) && run(() => savePatches([{ path: ["mcp", name], value: null }]))}>
<button className="danger" onClick={() => void confirmDialog({ message: t("settings.deleteMcpConfirm", { name }), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && run(() => savePatches([{ path: ["mcp", name], value: null }])))}>
{t("common.delete")}
</button>
)}
@@ -773,7 +816,7 @@ function Raw({ reload }: { reload: () => void }) {
// --- page -------------------------------------------------------------------
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "account", "raw"] as const;
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
const USER_TABS = ["general", "account"] as const;
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
@@ -808,6 +851,8 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
{active === "account" && <Account />}
{cfg && active === "users" && <Users />}
{cfg && active === "folders" && <Folders />}
{cfg && active === "audit" && <Audit />}
{cfg && active === "tools" && <Tools />}
{cfg && active === "general" && <General key={JSON.stringify(cfg.config.server)} cfg={cfg} reload={reload} />}
{cfg && active === "providers" && <Providers cfg={cfg} reload={reload} />}
{cfg && active === "mcp" && <Mcp cfg={cfg} reload={reload} />}