SSH servers β password or key, sealed in the vault; host keys pinned on the first connection
Work on servers β pick servers for a chat; the agent runs commands and edits files there
+
+
Mods β what each one hooks into, what it can reach and whose runs load it
+
Read and edit the code β a mod Godmode drafted waits for your review; Claude Code's validator checks every change
+
+
+
Options β set what a mod protects or refuses without touching its code
+
In the chat β mods refuse steps, mask secrets and post notes while the agent works
+
+
+
Gallery β guardrails, privacy, insight and workflow mods that work as they are
+
What it can do β told from the code by Claude Code's validator, before you switch it on
+
Cleanup β what the data folder holds, and a self check of everything Godmode relies on
Free up space β safe items are picked for you; whatever may still hold work stays
@@ -345,6 +358,30 @@ trusts only that key from then on. Then pick the server for a **chat** (the *SSH
| **Secrets** | The password, key and passphrase are sealed in the vault and never part of the prompt; transcripts and tool results mask them, also when a command prints them. The agent works in that account's shell, so give it an account with only the rights it needs β a narrow `NOPASSWD` sudo rule is safer than a saved sudo password. |
| **Checking in** | Each server card shows whether it was reachable, its OS and pinned host key, which agents and chats use it, and a **Run command** box for a quick look yourself. |
+### Mods β change how your agents work
+
+Open **Mods** in the sidebar. The **gallery** has mods that are ready to use β add one and it is on for every agent:
+
+| | |
+|---|---|
+| **Protect files** | Agents can't edit or overwrite the paths you name (`.env`, keys, a `secrets` folder), also not with a shell command that redirects into them or removes, moves or edits them β or, if you choose, can't read them or name them in a shell command at all. |
+| **Command guard** | Refuses shell commands that match a pattern: force pushes, `git reset --hard`, `rm -rf` on your home folder, piping a download into a shell β on this computer and in the shells of servers and virtual machines. The agent is told to explain instead of finding another way. |
+| **Step limit** | Caps the tool calls of one turn; past the limit the agent has to wrap up and say what is left. |
+| **Secret scrubber** | Masks AWS, GitHub, Stripe, Slack and Google keys, JWTs, private keys, bearer tokens, passwords in URLs and `password=β¦` values in tool output before the model reads it β also secrets that aren't in your vault. Code is left as it is. |
+| **Turn recap** | Posts a line after each long turn: duration, tools used, failed calls. |
+| **Prompt shortcuts** | `!brief`, `!plan` and shortcuts of your own, at the start of a line or as the last word, are expanded before the agent reads your message. |
+
+Each mod has **options** (the paths, the patterns, the limit), and **Runs for** decides whose runs load it: every agent
+or the ones you pick. For anything else, **New mod** opens an editor with a starting point, or **Ask Godmode to write
+one** and review its draft: a mod an agent wrote arrives switched off, and switching it on is your OK β for exactly
+the code you read.
+
+A mod is real code inside the agent's process, so Godmode shows what it is before it runs: Claude Code's validator
+checks every change, and the mod's page lists what it **hooks into** (Bash calls, prompts, β¦) and what it can **reach
+outside the conversation** (files, programs, the network). A mod that doesn't pass can't be switched on; one that stops
+passing after a Claude Code update is left out of runs and the chat says so. What a mod posts (`$.ui.log`, a toast, a
+status) shows in the chat as a note from that mod.
+
### Good to know
- **macOS β Chrome session import** reads your Chrome profile, which macOS protects: grant Godmode
@@ -473,6 +510,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
- [x] Agents working in a dedicated macOS VM (Tart / Virtualization.framework): shell, files and screen, assigned per agent, chat or workspace
- [x] API tools: any API with a key (Nano Banana, OpenAI, ElevenLabsβ¦) for agents, global / workspace / agent
- [x] SSH servers: agents run commands, edit files and copy files on remote machines β password or key, sudo, pinned host keys
+- [x] Mods: Claude Code mods for agents β a gallery of guardrails, options, an editor, drafts written by Godmode, checked before they run
- [ ] Windows / Linux VMs
- [x] Phone app (iOS / Android): chats, runs, automations, live browser, screen and VM views, paired over Tailscale
- [x] Runners: another Mac does the work of a chat while this one sleeps β one install command, encrypted link, setup copied, live view
diff --git a/SECURITY.md b/SECURITY.md
index 979417cc..29debee9 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -65,6 +65,18 @@ We aim to acknowledge reports within 72 hours and to ship a fix for critical iss
Godmode's own windows and dashboard tabs can't be shared. Unattended desktop access for routines is a human-only
agent setting; agents without it can't hand work to agents that have it, and backups never restore it. Shares and
their first use per run are audited (`computer.share`, `computer.unshare`, `computer.control`).
+- **Mods are code you switch on**: a mod runs inside Claude Code in every turn of the agents it is for and can reach
+ whatever its code asks for, so only you switch one on β and the switch counts for the code you saw: if it changed
+ meanwhile, Godmode asks you to read it again. Claude Code's validator checks every change; the mod's page lists what
+ it hooks and what it reaches outside the conversation (files, programs, the network, environment variables). A mod an
+ agent wrote arrives switched off and marked for review, an agent can only save over its own drafts β never a mod you
+ made, added or have had on β and a backup brings mods back switched off. Godmode keeps a mod's files in its database
+ and gives every run a copy of its own, so what one run does to its mods reaches no other run. Secret options are
+ sealed in the vault; a run that loads the mod is handed them, where an agent with full access to your computer could
+ read them β use a key made for the mod. The gallery's guardrails (*Protect files*, *Command guard*, *Secret
+ scrubber*) match patterns in tool calls and output: they catch mistakes and the obvious cases, not an agent that is
+ set on getting around them (a run with full permissions can rewrite its own copy of a guard) β isolation is what VMs
+ and permissions are for.
- **SSH servers are assigned by you**: an agent only reaches the servers you give its chat or the agent itself β agents
can't assign servers to themselves or others, and delegated work doesn't inherit a chat's servers. Godmode signs in
with the password or key sealed in the vault; they are never part of the prompt, and tool results mask them (the
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index 5ee3b723..1bd4251b 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -23,6 +23,7 @@ const AgentsPage = lazy(() => import("@/pages/agents/agents-page"));
const AgentNewPage = lazy(() => import("@/pages/agents/agent-new"));
const AgentDetailPage = lazy(() => import("@/pages/agents/agent-detail"));
const AutomationsPage = lazy(() => import("@/pages/automations/automations-page"));
+const ModsPage = lazy(() => import("@/pages/mods/mods-page"));
const ActivityPage = lazy(() => import("@/pages/activity/activity-page"));
const WorkspacesPage = lazy(() => import("@/pages/workspaces/workspaces-page"));
const TasksPage = lazy(() => import("@/pages/tasks/tasks-page"));
@@ -183,6 +184,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/desktop/src/components/chat/message-blocks.tsx b/apps/desktop/src/components/chat/message-blocks.tsx
index 2d0d9363..19abe846 100644
--- a/apps/desktop/src/components/chat/message-blocks.tsx
+++ b/apps/desktop/src/components/chat/message-blocks.tsx
@@ -6,7 +6,7 @@ import { format } from "date-fns";
import { AnimatePresence, motion } from "motion/react";
import type { Agent, Credential, MessageBlock, ToolTaskAgent } from "@godmode/shared";
import { WORKFLOW_TOOL } from "@godmode/shared";
-import { ArrowUpRight, Brain, CheckCircle2, ChevronRight, Circle, CircleDot, CornerDownRight, Info, Loader2, Lock, ShieldAlert, Square, SquareSlash, TriangleAlert, Workflow, XCircle } from "lucide-react";
+import { ArrowUpRight, Brain, CheckCircle2, ChevronRight, Circle, CircleDot, CornerDownRight, Info, Loader2, Lock, Puzzle, ShieldAlert, Square, SquareSlash, TriangleAlert, Workflow, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AgentAvatar } from "@/components/common";
import { ThinkingState } from "@/components/aicss/ThinkingState";
@@ -17,7 +17,7 @@ import { Orb } from "@/components/aicss/Orb";
import { formatDuration, formatTokens } from "@/components/runs/run-status";
import { api, errorMessage } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
-import { useAllAgents } from "@/lib/hooks";
+import { useAllAgents, useMods } from "@/lib/hooks";
import { cn } from "@/lib/utils";
import { useLive } from "@/stores/live";
import { Markdown } from "./markdown";
@@ -40,7 +40,7 @@ type Item =
| { kind: "text"; key: string; text: string }
| { kind: "thinking"; key: string; text: string }
| { kind: "error"; key: string; text: string }
- | { kind: "notice"; key: string; level: "info" | "warning" | "success"; text: string }
+ | { kind: "notice"; key: string; level: "info" | "warning" | "success"; text: string; mod?: string }
| { kind: "command"; key: string; name: string; args: string; output: string }
| { kind: "user-message"; key: string; block: UserMessageBlock }
| { kind: "pause"; key: string; block: PauseBlock }
@@ -120,7 +120,7 @@ function buildItems(blocks: MessageBlock[]): Item[] {
if (b.type === "text") {
if (b.text.trim()) items.push({ kind: "text", key, text: b.text });
} else if (b.type === "error") items.push({ kind: "error", key, text: b.text });
- else if (b.type === "notice") items.push({ kind: "notice", key, level: b.level, text: b.text });
+ else if (b.type === "notice") items.push({ kind: "notice", key, level: b.level, text: b.text, mod: b.mod });
else if (b.type === "command") items.push({ kind: "command", key, name: b.name, args: b.args, output: b.output });
else if (b.type === "user_message") items.push({ kind: "user-message", key: b.id, block: b });
// A run that stands still for a question: the card says so.
@@ -194,7 +194,11 @@ export function MessageBlocks({
);
case "notice":
- return ;
+ return item.mod ? (
+
+ ) : (
+
+ );
case "command":
return ;
case "user-message":
@@ -252,12 +256,14 @@ function PickedUpMessage({ block }: { block: UserMessageBlock }) {
);
}
+const NOTICE = {
+ info: { icon: Info, cls: "border-border bg-card text-muted-foreground" },
+ warning: { icon: TriangleAlert, cls: "border-warning/30 bg-warning/[0.07] text-warning" },
+ success: { icon: CheckCircle2, cls: "border-success/25 bg-success/[0.07] text-success" },
+} as const;
+
function NoticeItem({ level, text }: { level: "info" | "warning" | "success"; text: string }) {
- const meta = {
- info: { icon: Info, cls: "border-border bg-card text-muted-foreground" },
- warning: { icon: TriangleAlert, cls: "border-warning/30 bg-warning/[0.07] text-warning" },
- success: { icon: CheckCircle2, cls: "border-success/25 bg-success/[0.07] text-success" },
- }[level];
+ const meta = NOTICE[level];
const Icon = meta.icon;
return (
@@ -267,6 +273,37 @@ function NoticeItem({ level, text }: { level: "info" | "warning" | "success"; te
);
}
+/** A note a Claude Code mod posted: who says it comes first, as a chip that leads to the mod. */
+function ModNote({ level, text, name }: { level: "info" | "warning" | "success"; text: string; name: string }) {
+ const { data: mods } = useMods();
+ const mod = mods?.find((m) => m.name === name);
+ const chip = "inline-flex h-5 max-w-[14rem] shrink-0 items-center gap-1 rounded-[5px] border border-border bg-card px-1.5 text-[11px] font-medium text-foreground";
+ const label = (
+ <>
+
+ {mod?.title ?? name}
+ >
+ );
+ return (
+
+
+ );
+}
diff --git a/apps/desktop/src/components/mods/mod-code.tsx b/apps/desktop/src/components/mods/mod-code.tsx
new file mode 100644
index 00000000..c7d44545
--- /dev/null
+++ b/apps/desktop/src/components/mods/mod-code.tsx
@@ -0,0 +1,498 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { CircleAlert, CircleCheck, FileCode2, FileJson2, FileText, Pencil, Plus, Save, ShieldCheck, Trash2, TriangleAlert } from "lucide-react";
+import { toast } from "sonner";
+import { MAX_MOD_BYTES, MAX_MOD_FILE_BYTES, MAX_MOD_FILES, MOD_HOOKS_PATH, MOD_MANIFEST_PATH, modPathProblem, type Mod, type ModCheck } from "@godmode/shared";
+import { CopyButton } from "@/components/chat/copy-button";
+import { ConfirmDialog } from "@/components/integrations/confirm-dialog";
+import { Callout } from "@/components/settings/settings-kit";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { toastApiError } from "@/components/vault/vault-utils";
+import { api } from "@/lib/api";
+import { modKey } from "@/lib/desktop";
+import { cn } from "@/lib/utils";
+import { CodeEditor } from "./code-editor";
+import { languageLabel, languageOf } from "./highlight";
+import { HookChips, ModProblems, sortedPaths } from "./mod-parts";
+import type { ModActions } from "./use-mod-actions";
+
+type Files = Record;
+
+function sameFiles(a: Files, b: Files): boolean {
+ if (a === b) return true;
+ const keys = Object.keys(a);
+ return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k]);
+}
+
+/**
+ * The working copy of a mod's files. It follows the saved files while nothing is edited (an agent or another window
+ * may change them) and keeps the human's edits once there are some.
+ */
+export function useModDraft(mod: Mod) {
+ const [base, setBase] = useState(mod.files);
+ const [files, setFiles] = useState(mod.files);
+ const [revision, setRevision] = useState(0);
+ const isDirty = useMemo(() => !sameFiles(files, base), [files, base]);
+
+ useEffect(() => {
+ if (base === mod.files || isDirty) return;
+ setBase(mod.files);
+ setFiles(mod.files);
+ }, [mod.files, base, isDirty]);
+
+ const dirtyPaths = useMemo(() => new Set(Object.keys(files).filter((p) => files[p] !== base[p])), [files, base]);
+ const edit = (next: (files: Files) => Files) => {
+ setFiles(next);
+ setRevision((r) => r + 1);
+ };
+
+ return {
+ files,
+ isDirty,
+ dirtyPaths,
+ /** Counts edits, so a check result knows whether the code changed since. */
+ revision,
+ /** The saved files changed elsewhere while this copy was being edited. */
+ stale: isDirty && !sameFiles(base, mod.files),
+ write: (path: string, content: string) => edit((f) => ({ ...f, [path]: content })),
+ rename: (from: string, to: string) => edit((f) => Object.fromEntries(Object.entries(f).map(([p, c]) => [p === from ? to : p, c]))),
+ remove: (path: string) => edit((f) => Object.fromEntries(Object.entries(f).filter(([p]) => p !== path))),
+ adopt: (next: Files) => {
+ setBase(next);
+ setFiles(next);
+ },
+ };
+}
+
+export type ModDraft = ReturnType;
+
+const FIXED = new Set([MOD_MANIFEST_PATH, MOD_HOOKS_PATH]);
+
+function tooLarge(files: Files): string | null {
+ const encoder = new TextEncoder();
+ let total = 0;
+ for (const [path, content] of Object.entries(files)) {
+ const bytes = encoder.encode(content).length;
+ if (bytes > MAX_MOD_FILE_BYTES) return `${path} is larger than ${MAX_MOD_FILE_BYTES / 1000} kB.`;
+ total += bytes;
+ }
+ return total > MAX_MOD_BYTES ? `Together the files are larger than ${MAX_MOD_BYTES / 1000} kB.` : null;
+}
+
+/** The Code tab: the mod's files on the left, an editor on the right, and what Claude Code's validator says below. */
+export function ModCode({
+ mod,
+ draft,
+ actions,
+ author,
+ activePath,
+ jump,
+ onOpenFile,
+}: {
+ mod: Mod;
+ draft: ModDraft;
+ actions: ModActions;
+ /** The agent that drafted the code, while the mod waits for review. */
+ author: string;
+ activePath: string | null;
+ jump: { line: number } | null;
+ onOpenFile: (path: string, line?: number | null) => void;
+}) {
+ const qc = useQueryClient();
+ const [result, setResult] = useState<{ check: ModCheck | null; revision: number } | null>(null);
+ const paths = useMemo(() => sortedPaths(draft.files), [draft.files]);
+ const path = activePath !== null && activePath in draft.files ? activePath : (paths[0] ?? null);
+
+ const check = useMutation({
+ mutationFn: ({ files }: { files: Files; revision: number }) => api.mods.checkFiles({ files }),
+ onSuccess: (res, { revision }) => setResult({ check: res.check, revision }),
+ onError: (e) => toastApiError(e, "Couldn't check the code", qc),
+ });
+
+ const save = useMutation({
+ mutationFn: (files: Files) => api.mods.update(mod.id, { files }),
+ onSuccess: (next) => {
+ draft.adopt(next.files);
+ actions.put(next);
+ setResult(null);
+ if (!next.check) toast.success("Code saved", { description: "It wasn't checked β Claude Code isn't installed on this computer." });
+ else if (next.check.ok) toast.success("Code saved", { description: "The check passed. It applies from the next message." });
+ else toast.warning("Code saved, but the check failed", { description: "Runs don't load the mod until it passes." });
+ },
+ onError: (e) => toastApiError(e, "Couldn't save the code", qc),
+ });
+
+ const busy = check.isPending || save.isPending;
+ const guard = (): boolean => {
+ const problem = tooLarge(draft.files);
+ if (problem) toast.error("Too large for a mod", { description: problem });
+ return !problem;
+ };
+ const runCheck = () => {
+ if (!busy && guard()) check.mutate({ files: draft.files, revision: draft.revision });
+ };
+ const runSave = () => {
+ if (!busy && draft.isDirty && guard()) save.mutate(draft.files);
+ };
+
+ const shown = result ? result.check : mod.check;
+ const note = result
+ ? result.revision !== draft.revision
+ ? "Checked before your last edit"
+ : draft.isDirty
+ ? "This draft, not saved yet"
+ : "The saved code"
+ : draft.isDirty
+ ? "The saved code β your changes aren't checked yet"
+ : "The saved code";
+
+ return (
+
{
+ // The editor answers the shortcut itself; this catches it from the file list and the buttons.
+ if (e.defaultPrevented || !(e.metaKey || e.ctrlKey) || e.altKey || e.key.toLowerCase() !== "s") return;
+ e.preventDefault();
+ runSave();
+ }}
+ >
+ {(mod.needsReview || draft.stale) && (
+
+ {mod.needsReview && (
+
+ Read it through before anything else. Switching the mod on is your approval: from then on it runs in every turn of the agents it is for.
+
+ )}
+ {draft.stale && (
+
+
+
+ {!check ? (
+ Not checked yet. Until Claude Code has checked it, read the code to see what this mod does.
+ ) : abilities.length === 0 ? (
+ Nothing yet β it doesn't hook into Claude Code.
+ ) : (
+ <>
+ {everyday.length > 0 && }
+ {sensitive.length > 0 && (
+
+
Reaches outside the conversation
+
+
+ )}
+ >
+ )}
+
+
+
+ {!check ? (
+ Not known until Claude Code checks the mod.
+ ) : check.hooks.length === 0 ? (
+ No hooks yet.
+ ) : (
+