Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ We aim to acknowledge reports within 72 hours and to ship a fix for critical iss
turning on "remember this device" need a short-lived grant confirmed with your vault passphrase — a stolen API token
alone is not enough.
- **No privilege escalation through agents**: agents that create or edit agents cannot grant reveal access, change
workspaces, browser profiles or out-of-scope integrations; fill-only agents cannot delegate to reveal-mode agents.
workspaces, browser profiles or out-of-scope integrations. A task an agent hands to a reveal-mode agent runs
without raw secrets unless the caller reads raw secrets itself and the target is global or in the caller's workspace
(logins are still filled into pages), and that chat stays fill-only; scheduling such an agent, putting tasks on the
board for it or changing its settings stays with you. The task can still write to that agent's memory and files.
- **Redaction**: known secret values (logins, 2FA, API keys, MCP env/header values) are masked in transcripts, run logs
and the UI.
- **Audit log**: every secret access (fill, reveal, export) is recorded with agent and run id.
Expand Down
10 changes: 8 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,13 @@ ask within the same moment share a request.
credentials; see [Godmode Cloud](#godmode-cloud).
* **MCP gateway**: each run gets a random bearer token scoped to that run/agent; expires when the run ends.
Management tools cannot grant reveal access, move agents between workspaces or attach out-of-scope profiles/MCP
servers; fill-only agents cannot delegate to reveal-mode agents.
servers. `agent_delegate` to a reveal-mode agent works for every caller, but unless the caller's run reads raw
secrets itself (reveal mode, its own chat not fill-only) and the target is global or in its workspace, the task's
chat is marked fill-only (`conversations.secret_access = 'fill'`): `vault_get_login` / `vault_get_totp` are off in it
for good, and the agent counts as fill-only there for what it hands on or sets up — for itself too. What outlives
the task (automations, assigning board tasks, settings of a reveal-mode agent, giving that agent a VM) is still
refused for such callers. Login allow-lists are not compared, and the task can still write to the agent's memory and
files.
* **Token hand-off**: the desktop shell starts the core with `--token-stdin` and writes the token as the first stdin
line; the core strips `GODMODE_*` from every child process environment.

Expand Down Expand Up @@ -1006,7 +1012,7 @@ a global one. Every change is pushed as `task.updated` / `task.deleted` and patc
filters by agent, `task_message` sends feedback into a ticket (it arrives marked as coming from that agent, not the
human, is on the timeline, and is refused for the caller's own ticket and for a ticket whose run stands still — only
the human continues those), `task_note` leaves a note (a working agent on its own ticket, managers on any).
`task_create` / `task_update` follow the delegation rules (no reveal-mode or unattended
`task_create` / `task_update` follow the rules for scheduling an agent (no reveal-mode or unattended
computer agents from callers that couldn't use them, VM-kept runs stay off the host); coding tasks created by agents
use the workspace's repositories; and a run working on a task — or delegated from one — can't start a manager agent
(itself included), so tasks can't spawn tasks without end. Follow-ups wait while Godmode prepares or publishes a task. Task numbers are never reused.
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,15 @@ CREATE INDEX IF NOT EXISTS idx_task_dependencies_waits_for ON task_dependencies(
-- Goals of workspaces deleted before they went along with them.
UPDATE tasks SET goal_id = NULL WHERE goal_id IN (SELECT id FROM goals WHERE workspace_id IS NOT NULL AND workspace_id NOT IN (SELECT id FROM workspaces));
DELETE FROM goals WHERE workspace_id IS NOT NULL AND workspace_id NOT IN (SELECT id FROM workspaces);
`,
},
{
id: 31,
name: "chat_secret_access",
sql: /* sql */ `
-- 'fill': no raw secrets in this chat, whatever its agent may read — its task came from an agent that could not
-- read them itself. NULL = the agent's own secret access.
ALTER TABLE conversations ADD COLUMN secret_access TEXT;
`,
},
];
70 changes: 42 additions & 28 deletions packages/core/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import { get } from "../db";
import { config } from "../config";
import { fixRunner, runnerExec, runnerHealth } from "../remote/runners";
import { loginFillScope } from "../browser/fill";
import { createConversation, sendMessage } from "../services/conversations";
import { chatFillOnly, createConversation, sendMessage } from "../services/conversations";
import { assignVm, createVm, getVm, listVms, sharedDirOf, startVm, stopVm, suspendVm, vmInUse, vmOfRun, vmStatus } from "../vm/service";
import { resolveVmId } from "../vm/assignments";
import { getSettings } from "../services/settings";
Expand Down Expand Up @@ -131,6 +131,8 @@ const toolsRunner = (ctx: RunContext): string | null =>
get<{ runner_tools_id: string | null }>("SELECT runner_tools_id FROM conversations WHERE id = ?", ctx.conversationId)?.runner_tools_id ?? null;
const canDelegate = (a: Agent) => a.permissions.allowDelegation || a.permissions.canManageAgents;
const canReveal = (a: Agent) => a.permissions.secretAccess === "reveal";
/** Raw secrets in this run: the agent may read them, and its chat's task didn't come from an agent that may not. */
const revealsHere = (a: Agent, ctx: RunContext) => canReveal(a) && !chatFillOnly(ctx.conversationId);

const json = (v: unknown) => JSON.stringify(v, null, 2);
const fail = (text: string): ToolOutput => ({ text, isError: true });
Expand Down Expand Up @@ -183,15 +185,15 @@ function delegationHeader(from: Agent, to: Agent): string {
* A lead the caller may not set: one it couldn't hand work to itself (it reads secrets in plain text and the caller
* doesn't). Its reports are introduced to it as its team, so that would put the caller's agent next to it.
*/
function protectedLeadRefusal(agent: Agent, leadId: string | null | undefined): string | null {
function protectedLeadRefusal(agent: Agent, ctx: RunContext, leadId: string | null | undefined): string | null {
if (!leadId) return null;
let lead: Agent;
try {
lead = getAgent(leadId);
} catch {
return null;
}
return lead.id === agent.id ? null : revealTargetRefusal(agent, lead, "make it a lead");
return lead.id === agent.id ? null : revealTargetRefusal(agent, ctx, lead, "make it a lead");
}

/** Agents the caller may see/delegate to. */
Expand Down Expand Up @@ -223,20 +225,30 @@ function offHostRefusal(ctx: RunContext, target: Agent, what: string): string |
return `This task runs in a virtual machine and is kept off the human's computer, and ${target.name} works on the computer — only the human can ${what}.`;
}

/**
* A reveal-mode agent gets plaintext secrets, so it only takes work (tasks, schedules, instructions) from a
* caller that could reveal them itself — and never from another workspace. Returns the refusal, or null.
*/
function revealTargetRefusal(caller: Agent, target: Agent, what: string): string | null {
// An agent that may control this computer on its own only takes work from callers that may too.
/** An agent that may control this computer on its own only takes work from callers that may too. Returns the refusal, or null. */
function computerTargetRefusal(caller: Agent, target: Agent, what: string): string | null {
if (target.computer.enabled && !caller.computer.enabled && target.id !== caller.id) {
return `${target.name} can control this computer on its own; only the human can ${what}.`;
}
if (target.permissions.secretAccess !== "reveal") return null;
if (caller.permissions.secretAccess !== "reveal") return `Target agent can reveal secrets; only the human can ${what} from here.`;
if (target.workspaceId !== null && target.workspaceId !== caller.workspaceId) {
return `${target.name} can reveal secrets and belongs to another workspace; only the human can ${what}.`;
}
return null;
}

/** The caller's run reads raw secrets itself, and `target` is global or in its workspace (login allow-lists are not compared). */
function revealsFor(caller: Agent, ctx: RunContext, target: Agent): boolean {
return revealsHere(caller, ctx) && (target.workspaceId === null || target.workspaceId === caller.workspaceId);
}

/**
* A reveal-mode agent gets plaintext secrets, so what stays with it or starts it later (schedules, board tasks,
* instructions, its VM) only comes from a caller whose run reveals secrets itself — and never from another
* workspace. That holds for the agent itself too while it works in a fill-only chat. Returns the refusal, or null.
* A delegated task is not refused: it runs without raw secrets (`agent_delegate`).
*/
function revealTargetRefusal(caller: Agent, ctx: RunContext, target: Agent, what: string): string | null {
const computer = computerTargetRefusal(caller, target, what);
if (computer || !canReveal(target)) return computer;
if (!revealsHere(caller, ctx)) return `Target agent can reveal secrets; only the human can ${what} from here.`;
if (!revealsFor(caller, ctx, target)) return `${target.name} can reveal secrets and belongs to another workspace; only the human can ${what}.`;
return null;
}

Expand Down Expand Up @@ -637,7 +649,7 @@ function taskAssignRefusal(caller: Agent, ctx: RunContext, agentId: string | nul
if (inTaskChain(ctx) && target.permissions.canManageAgents) {
return `${target.id === caller.id ? "You are" : `${target.name} is`} working on tasks already — only the human can start another manager from here. Assign a specialist agent, or leave it in the backlog.`;
}
return offHostRefusal(ctx, target, "give it tasks") ?? revealTargetRefusal(caller, target, "give it tasks");
return offHostRefusal(ctx, target, "give it tasks") ?? revealTargetRefusal(caller, ctx, target, "give it tasks");
}

function localTimezone(): string {
Expand Down Expand Up @@ -766,7 +778,7 @@ const TOOLS: ToolDef[] = [
description:
"Reveal the username and password of a saved login. Only for secrets that must go to an API/CLI and cannot be filled in the browser — every call is audited. Never write the values into files, memory or your answer.",
schema: z.object({ credentialId: z.string() }),
when: canReveal,
when: revealsHere,
run: ({ credentialId }, { agent, ctx }) => {
const secret = revealForAgent(agent, credentialId);
audit(`agent:${agent.id}`, "credential.reveal", credentialId, { field: "username+password", runId: ctx.runId });
Expand All @@ -779,7 +791,7 @@ const TOOLS: ToolDef[] = [
name: "vault_get_totp",
description: "Reveal the current 2FA code of an entry (or of a login's linked 2FA). Audited. Prefer vault_fill_totp for websites.",
schema: z.object({ totpId: z.string().optional(), credentialId: z.string().optional() }),
when: canReveal,
when: revealsHere,
run: ({ totpId, credentialId }, { agent, ctx }) => {
let id = totpId ?? null;
if (!id && credentialId) {
Expand Down Expand Up @@ -1065,7 +1077,7 @@ const TOOLS: ToolDef[] = [
defineTool({
name: "agent_delegate",
description:
"Hand a task to another agent — pick the one whose role fits. The task must be self-contained (goal, inputs, expected output); the agent is told it comes from you and its final answer comes back to you. wait:true (default) waits for the result and returns it; wait:false returns immediately with a run id you can check with delegation_status.",
"Hand a task to another agent — pick the one whose role fits. The task must be self-contained (goal, inputs, expected output); the agent is told it comes from you and its final answer comes back to you. wait:true (default) waits for the result and returns it; wait:false returns immediately with a run id you can check with delegation_status. An agent that can read raw secrets works on your task without them unless you can read them too (Godmode still fills its logins into pages).",
schema: z.object({
agentId: z.string(),
task: z.string().min(1),
Expand All @@ -1080,12 +1092,14 @@ const TOOLS: ToolDef[] = [
}
const target = requireReachable(agent, agentId);
if (!target.enabled) return fail(`${target.name} is disabled.`);
// Orchestrators too: only peers (respects delegateTo), and reveal-mode agents only for reveal-mode callers.
// Orchestrators too: only peers (respects delegateTo).
if (!peersFor(agent).some((p) => p.id === target.id)) {
return fail(`Agent ${agentId} is not one of your peers (use agents_list to see who you can work with).`);
}
const refusal = revealTargetRefusal(agent, target, "hand it tasks");
const refusal = computerTargetRefusal(agent, target, "hand it tasks");
if (refusal) return fail(refusal);
// A caller that reads no raw secrets itself gets none through the task: its chat is fill-only, for good.
const fillOnly = !revealsFor(agent, ctx, target);
// Unattended work doesn't spend past a used-up monthly budget by handing work on (a chat the human leads may).
const stop = runExempt(ctx.runId) ? null : exhaustedBudget(target);
if (stop) {
Expand All @@ -1102,7 +1116,7 @@ const TOOLS: ToolDef[] = [
const inherited = target.browser.profileId ? null : runChatBrowserProfile(ctx.runId);
const reach = target.workspaceId ?? workspaceId;
const browserProfileId = inherited && (!inherited.workspaceId || inherited.workspaceId === reach) ? inherited.id : null;
const conversation = createConversation({ agentId: target.id, title: `Task from ${agent.name}`, origin: "delegation", vmId, browserProfileId, workspaceId });
const conversation = createConversation({ agentId: target.id, title: `Task from ${agent.name}`, origin: "delegation", vmId, browserProfileId, workspaceId, fillOnly });
// The chat shows the bare task under "From <agent>"; Claude also learns who asked and where its answer goes.
const { run } = await sendMessage(conversation.id, {
content: task,
Expand Down Expand Up @@ -1158,7 +1172,7 @@ const TOOLS: ToolDef[] = [
when: managesSetup,
run: async ({ routine, ...input }, { agent, ctx }) => {
assertAgentPatchAllowed(null, input);
const leadRefusal = protectedLeadRefusal(agent, input.reportsTo);
const leadRefusal = protectedLeadRefusal(agent, ctx, input.reportsTo);
if (leadRefusal) return fail(leadRefusal);
// Secret access, management rights and login allow-lists stay human-only (enforced by createAgent for agent actors).
// Agents created from a VM work in that VM.
Expand All @@ -1183,8 +1197,8 @@ const TOOLS: ToolDef[] = [
const target = getAgent(agentId);
const refusal =
offHostRefusal(ctx, target, "change its settings") ??
(target.id === agent.id ? null : revealTargetRefusal(agent, target, "change its settings")) ??
protectedLeadRefusal(agent, patch.reportsTo);
revealTargetRefusal(agent, ctx, target, "change its settings") ??
protectedLeadRefusal(agent, ctx, patch.reportsTo);
if (refusal) return fail(refusal);
assertAgentPatchAllowed(target, patch);
const updated = await updateAgent(agentId, patch, `agent:${agent.id}`);
Expand Down Expand Up @@ -1283,7 +1297,7 @@ const TOOLS: ToolDef[] = [
when: managesSetup,
run: async ({ timezone, trigger, ...input }, { agent, ctx }) => {
const target = getAgent(input.agentId);
const refusal = offHostRefusal(ctx, target, "schedule its tasks") ?? revealTargetRefusal(agent, target, "schedule its tasks");
const refusal = offHostRefusal(ctx, target, "schedule its tasks") ?? revealTargetRefusal(agent, ctx, target, "schedule its tasks");
if (refusal) return fail(refusal);
const resolved = await resolveAppTrigger(trigger as RoutineTrigger | undefined, input.agentId);
const r = createRoutine({ ...input, trigger: resolved, timezone: timezone ?? localTimezone() });
Expand All @@ -1310,7 +1324,7 @@ const TOOLS: ToolDef[] = [
run: async ({ routineId, trigger, ...patch }, { agent, ctx }) => {
const current = getRoutine(routineId);
const target = getAgent(current.agentId);
const refusal = offHostRefusal(ctx, target, "change its automations") ?? revealTargetRefusal(agent, target, "schedule its tasks");
const refusal = offHostRefusal(ctx, target, "change its automations") ?? revealTargetRefusal(agent, ctx, target, "schedule its tasks");
if (refusal) return fail(refusal);
const resolved = trigger ? await resolveAppTrigger(trigger as RoutineTrigger, current.agentId) : undefined;
const r = updateRoutine(routineId, { ...patch, ...(resolved ? { trigger: resolved } : {}) });
Expand All @@ -1327,7 +1341,7 @@ const TOOLS: ToolDef[] = [
when: managesSetup,
run: async ({ routineId }, { agent, ctx }) => {
const target = getAgent(getRoutine(routineId).agentId);
const refusal = offHostRefusal(ctx, target, "run its tasks") ?? revealTargetRefusal(agent, target, "run its tasks");
const refusal = offHostRefusal(ctx, target, "run its tasks") ?? revealTargetRefusal(agent, ctx, target, "run its tasks");
if (refusal) return fail(refusal);
const started = await runRoutineNow(routineId);
audit(`agent:${agent.id}`, "routine.run", routineId, { runId: started.id });
Expand Down Expand Up @@ -1850,7 +1864,7 @@ const TOOLS: ToolDef[] = [
run: async ({ vmId, target, id }, { agent, ctx }) => {
if (target !== "this_chat" && !id) return fail(`Pass the ${target}'s id.`);
if (target === "agent") {
const refusal = id === agent.id ? null : revealTargetRefusal(agent, getAgent(id!), "move it into a VM");
const refusal = revealTargetRefusal(agent, ctx, getAgent(id!), "move it into a VM");
if (refusal) return fail(refusal);
}
const kind = target === "this_chat" ? "conversation" : target;
Expand Down
Loading
Loading