Skip to content

Commit 571d014

Browse files
committed
fix(opencode): lock down hidden subagent tool permissions
Historian, dreamer, and sidekick are registered with `mode: "subagent"` but their `permission` field was never set, so each spawned session inherited the full primary-agent tool surface (`task`, `bash`, `edit`, `webfetch`, `websearch`, `read`, `grep`, `glob`, all MCP tools, etc.). `mode: "subagent"` + `hidden: true` only control visibility in the agent picker — they do NOT restrict tool access. The auto-`task`-deny in OpenCode's `deriveSubagentSessionPermission` (subagent-permissions.ts) fires only when an agent is INVOKED via the parent's `task()` tool, not when spawned directly via `client.session.prompt(...)` like we do. Result observed in the wild: historian sometimes used `task(subagent_type=explore)` to fan out, which it should never do. Fix mirrors OpenCode's own `explore` agent (`packages/opencode/src/ agent/agent.ts:179-201`): set `permission: { "*": "deny", ...allows }` on each hidden-agent config. `Permission.fromConfig` converts the flat map to a `Rule[]` ruleset, and `evaluate` uses `findLast` so named allows defeat the wildcard deny. Allow-lists per agent: - historian / historian-editor: `read` (state-file offload only) - dreamer: `read` (key-files) + `ctx_memory`, `ctx_search`, `ctx_note` - sidekick: `ctx_search`, `ctx_memory` (read-only memory retrieval) User-supplied `pluginConfig.<agent>.permission` overrides still merge on top via object-spread, so advanced users can extend the allow-list in `magic-context.jsonc` if they really need to grant more tools. Pi side already restricts via `--no-extensions --no-skills` on the spawn command line, so no Pi-side change is needed. Tests: 16 new unit tests in agents/permissions.test.ts covering the helper's deny-first ordering, the per-agent allow-list shapes, and locking in the "no task / bash / edit" invariant. Full plugin suite: 1473 pass / 0 fail.
1 parent 1bf4a64 commit 571d014

3 files changed

Lines changed: 299 additions & 9 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
buildAllowOnlyPermission,
4+
DREAMER_ALLOWED_TOOLS,
5+
HISTORIAN_ALLOWED_TOOLS,
6+
SIDEKICK_ALLOWED_TOOLS,
7+
} from "./permissions";
8+
9+
describe("buildAllowOnlyPermission", () => {
10+
it("starts with wildcard deny so nothing is allowed by default", () => {
11+
const perm = buildAllowOnlyPermission([]);
12+
expect(perm["*"]).toBe("deny");
13+
});
14+
15+
it("layers the allow-list on top of the wildcard deny", () => {
16+
const perm = buildAllowOnlyPermission(["read", "ctx_search"]);
17+
expect(perm["*"]).toBe("deny");
18+
expect(perm.read).toBe("allow");
19+
expect(perm.ctx_search).toBe("allow");
20+
});
21+
22+
it("places named allows AFTER the wildcard deny so findLast-semantics make them win", () => {
23+
// OpenCode's Permission.evaluate uses `findLast` over the ruleset
24+
// built from this object's insertion order. If "*" appeared after a
25+
// named tool, the deny would clobber it — guard against accidental
26+
// regressions in the helper's ordering.
27+
const perm = buildAllowOnlyPermission(["read"]);
28+
const keys = Object.keys(perm);
29+
const wildcardIdx = keys.indexOf("*");
30+
const readIdx = keys.indexOf("read");
31+
expect(wildcardIdx).toBeLessThan(readIdx);
32+
});
33+
34+
it("never accidentally allows `task`, `bash`, or `edit` unless explicitly listed", () => {
35+
// The whole point of this helper is preventing historian / dreamer /
36+
// sidekick from inheriting the primary-agent surface. Lock that in.
37+
const perm = buildAllowOnlyPermission(["read"]);
38+
expect(perm.task).toBeUndefined();
39+
expect(perm.bash).toBeUndefined();
40+
expect(perm.edit).toBeUndefined();
41+
expect(perm.webfetch).toBeUndefined();
42+
expect(perm.websearch).toBeUndefined();
43+
// The wildcard deny covers them via findLast — verified above.
44+
});
45+
46+
it("returns an empty allow-list as just the wildcard deny", () => {
47+
const perm = buildAllowOnlyPermission([]);
48+
expect(Object.keys(perm)).toEqual(["*"]);
49+
});
50+
});
51+
52+
describe("HISTORIAN_ALLOWED_TOOLS", () => {
53+
it("includes only `read` (for state-file offload)", () => {
54+
// Historian's only tool need is reading the offloaded existing-state
55+
// XML the runner writes to a temp file. Nothing else.
56+
expect(HISTORIAN_ALLOWED_TOOLS).toEqual(["read"]);
57+
});
58+
59+
it("does NOT include `task` (the bug we're fixing — preventing subagent fanout)", () => {
60+
expect(HISTORIAN_ALLOWED_TOOLS).not.toContain("task");
61+
});
62+
63+
it("does NOT include any edit / bash / web tools", () => {
64+
for (const dangerous of ["bash", "edit", "write", "webfetch", "websearch"]) {
65+
expect(HISTORIAN_ALLOWED_TOOLS).not.toContain(dangerous);
66+
}
67+
});
68+
});
69+
70+
describe("DREAMER_ALLOWED_TOOLS", () => {
71+
it("includes read + ctx_memory + ctx_search + ctx_note", () => {
72+
// Dreamer needs: `read` (key-files identification), `ctx_memory`
73+
// (consolidate/verify/archive/merge/update), `ctx_search`
74+
// (smart-note evaluation, retrieval-count checks), `ctx_note`
75+
// (smart-note dismiss/update).
76+
expect(DREAMER_ALLOWED_TOOLS).toContain("read");
77+
expect(DREAMER_ALLOWED_TOOLS).toContain("ctx_memory");
78+
expect(DREAMER_ALLOWED_TOOLS).toContain("ctx_search");
79+
expect(DREAMER_ALLOWED_TOOLS).toContain("ctx_note");
80+
});
81+
82+
it("does NOT include `task` or edit/bash/web tools", () => {
83+
for (const denied of ["task", "bash", "edit", "write", "webfetch", "websearch"]) {
84+
expect(DREAMER_ALLOWED_TOOLS).not.toContain(denied);
85+
}
86+
});
87+
});
88+
89+
describe("SIDEKICK_ALLOWED_TOOLS", () => {
90+
it("includes only ctx_search + ctx_memory (read-only memory retrieval)", () => {
91+
// Sidekick is the /ctx-aug memory retriever. It augments the user
92+
// prompt with relevant memories — no edits, no subagents, no bash.
93+
expect(SIDEKICK_ALLOWED_TOOLS).toEqual(["ctx_search", "ctx_memory"]);
94+
});
95+
96+
it("does NOT include `read` (sidekick doesn't touch the filesystem)", () => {
97+
expect(SIDEKICK_ALLOWED_TOOLS).not.toContain("read");
98+
});
99+
100+
it("does NOT include `task` or any edit / bash / web tool", () => {
101+
for (const denied of ["task", "bash", "edit", "write", "webfetch", "websearch"]) {
102+
expect(SIDEKICK_ALLOWED_TOOLS).not.toContain(denied);
103+
}
104+
});
105+
});
106+
107+
describe("integration: full hidden-agent permission shape", () => {
108+
it("historian permission object: `*` denied, only `read` allowed", () => {
109+
const perm = buildAllowOnlyPermission(HISTORIAN_ALLOWED_TOOLS);
110+
expect(perm).toEqual({
111+
"*": "deny",
112+
read: "allow",
113+
});
114+
});
115+
116+
it("dreamer permission object: `*` denied + read + ctx_* allowed", () => {
117+
const perm = buildAllowOnlyPermission(DREAMER_ALLOWED_TOOLS);
118+
expect(perm).toEqual({
119+
"*": "deny",
120+
read: "allow",
121+
ctx_memory: "allow",
122+
ctx_search: "allow",
123+
ctx_note: "allow",
124+
});
125+
});
126+
127+
it("sidekick permission object: `*` denied + ctx_search + ctx_memory allowed", () => {
128+
const perm = buildAllowOnlyPermission(SIDEKICK_ALLOWED_TOOLS);
129+
expect(perm).toEqual({
130+
"*": "deny",
131+
ctx_search: "allow",
132+
ctx_memory: "allow",
133+
});
134+
});
135+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Permission rulesets for Magic Context's hidden subagents.
3+
*
4+
* # Why this exists
5+
*
6+
* Hidden agents (`historian`, `historian-editor`, `dreamer`, `sidekick`) are
7+
* registered with `mode: "subagent"` and `hidden: true`, but those flags
8+
* only control visibility in the UI picker — they do NOT restrict which
9+
* tools the spawned session can call. By default a registered subagent
10+
* inherits the FULL primary-agent tool surface: `task`, `bash`, `edit`,
11+
* `webfetch`, `websearch`, `read`, `grep`, `glob`, every MCP tool, etc.
12+
*
13+
* That default is wrong for our agents:
14+
* - Historian should be a pure XML-emitting summarizer. It must not
15+
* dispatch `task(subagent_type=explore)` to fan out, edit files,
16+
* run bash, or fetch the web — its job is to read offloaded state
17+
* files and emit `<compartment>` blocks.
18+
* - The `task` permission only gets auto-denied when an agent is
19+
* INVOKED via the parent's `task()` tool (see OpenCode's
20+
* `deriveSubagentSessionPermission`). Our hidden agents are spawned
21+
* directly via `client.session.prompt(...)` from the plugin
22+
* runtime, so that auto-deny never fires — they get the same
23+
* `task` permission as a primary `build` agent.
24+
*
25+
* # Design
26+
*
27+
* Each hidden agent's `permission` field starts with `{ "*": "deny" }`
28+
* and adds explicit `allow` entries for ONLY the tool ids it needs.
29+
* OpenCode's `Permission.fromConfig` converts this flat map into a
30+
* `Rule[]` ruleset where later entries override earlier ones, so the
31+
* named allows always win against the wildcard deny.
32+
*
33+
* This is the same pattern OpenCode's own `explore` subagent uses
34+
* (see `packages/opencode/src/agent/agent.ts:179-201`).
35+
*
36+
* User-supplied agent overrides (`pluginConfig.historian.permission`,
37+
* etc.) still merge on top via OpenCode's `Permission.merge`, so
38+
* advanced users can extend the allow-list without us blocking them.
39+
*
40+
* # What each agent needs
41+
*
42+
* - **historian / historian-editor / compressor**: just `read`. The
43+
* runner offloads large existing-state XML to a temp file under
44+
* `<project>/.opencode/magic-context/historian/` and the prompt
45+
* instructs the model to read that file. The model's only output
46+
* channel is its text response (the `<output>...</output>` XML).
47+
*
48+
* - **dreamer**: `read` (for the key-files identification task),
49+
* plus the Magic Context MCP tools `ctx_memory`, `ctx_search`,
50+
* `ctx_note`. Dreamer task prompts in
51+
* `features/magic-context/dreamer/task-prompts.ts` explicitly
52+
* call these tools to consolidate, verify, archive, merge, and
53+
* update memories, plus evaluate smart notes.
54+
*
55+
* - **sidekick**: `ctx_search` and `ctx_memory` (read-only ops).
56+
* Sidekick's job is augmenting user prompts via memory retrieval
57+
* — see `features/magic-context/sidekick/agent.ts`.
58+
*/
59+
60+
/**
61+
* Build a `permission` map suitable for `AgentConfig.permission`. Starts
62+
* with a wildcard deny, then layers in the named tool allows on top.
63+
* OpenCode's `Permission.fromConfig` preserves insertion order and its
64+
* `evaluate` uses `findLast`, so named allows defeat the wildcard deny.
65+
*
66+
* Returns `Record<string, "deny" | "allow">` which the SDK's
67+
* `AgentConfig.permission` type accepts via its `[key: string]: unknown`
68+
* index signature. The same pattern is used by OpenCode's built-in
69+
* `explore`/`scout`/`general` agents and by Alfonso for its static
70+
* agent profiles.
71+
*/
72+
export function buildAllowOnlyPermission(
73+
allowedTools: readonly string[],
74+
): Record<string, "deny" | "allow"> {
75+
const permission: Record<string, "deny" | "allow"> = { "*": "deny" };
76+
for (const tool of allowedTools) {
77+
permission[tool] = "allow";
78+
}
79+
return permission;
80+
}
81+
82+
/**
83+
* Tools the historian + historian-editor + compressor agents need.
84+
* Historian runners offload large `<existing_state>` XML to disk and
85+
* tell the model to `read` it before emitting the summary XML. Nothing
86+
* else is needed — no bash, no edits, no other subagents.
87+
*/
88+
export const HISTORIAN_ALLOWED_TOOLS = ["read"] as const;
89+
90+
/**
91+
* Tools the dreamer agent needs. Memory consolidation/verification/
92+
* archive/merge/update flows go through `ctx_memory`; smart-note
93+
* evaluation uses `ctx_search` and `ctx_note`; the key-files
94+
* identification task uses `read` to inspect candidate files before
95+
* picking which ones to pin.
96+
*/
97+
export const DREAMER_ALLOWED_TOOLS = ["read", "ctx_memory", "ctx_search", "ctx_note"] as const;
98+
99+
/**
100+
* Tools the sidekick agent needs. Sidekick is a read-only memory
101+
* retriever for `/ctx-aug` — it queries the project's memory store
102+
* via `ctx_search` and (rarely) reads specific memories with
103+
* `ctx_memory(action="list")`. It must NOT spawn subagents, edit
104+
* files, or run bash.
105+
*/
106+
export const SIDEKICK_ALLOWED_TOOLS = ["ctx_search", "ctx_memory"] as const;

packages/plugin/src/index.ts

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import type { Plugin } from "@opencode-ai/plugin";
22
import { DREAMER_AGENT } from "./agents/dreamer";
33
import { HISTORIAN_AGENT, HISTORIAN_EDITOR_AGENT } from "./agents/historian";
4+
import {
5+
buildAllowOnlyPermission,
6+
DREAMER_ALLOWED_TOOLS,
7+
HISTORIAN_ALLOWED_TOOLS,
8+
SIDEKICK_ALLOWED_TOOLS,
9+
} from "./agents/permissions";
410
import { SIDEKICK_AGENT } from "./agents/sidekick";
511
import { loadPluginConfig } from "./config";
612
import { getMagicContextBuiltinCommands } from "./features/builtin-commands/commands";
@@ -299,19 +305,58 @@ const plugin: Plugin = async (ctx) => {
299305
await hooks.magicContext?.["experimental.text.complete"]?.(input, output);
300306
},
301307
config: async (config) => {
308+
/**
309+
* Build a hidden-agent config with a deny-everything-by-default
310+
* permission baseline plus an explicit allow-list of tool ids the
311+
* agent actually needs. See `agents/permissions.ts` for the
312+
* rationale — without this, registered subagents inherit the full
313+
* primary-agent tool surface (`task`, `bash`, `edit`, `webfetch`,
314+
* etc.) because the auto-`task`-deny in
315+
* `deriveSubagentSessionPermission` only applies to subagents
316+
* INVOKED via the parent's `task()` tool, not to subagents spawned
317+
* directly via `client.session.prompt(...)` from plugin runtime.
318+
*
319+
* Permission precedence:
320+
* 1. `buildAllowOnlyPermission(allowedTools)` → wildcard deny +
321+
* our named allows (insertion order; `findLast` makes later
322+
* named allows defeat the wildcard deny).
323+
* 2. User-supplied `overrides.permission` is merged on top via
324+
* object-spread, so users CAN extend the allow-list in
325+
* `magic-context.jsonc` if they really need to grant more
326+
* tools to a hidden agent.
327+
* 3. OpenCode then merges the runtime defaults (`Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))` in
328+
* `packages/opencode/src/agent/agent.ts:306`) so OpenCode
329+
* user-config-level agent overrides win last.
330+
*/
302331
const buildHiddenAgentConfig = (
303332
agentId: string,
304333
prompt: string,
334+
allowedTools: readonly string[],
305335
overrides?: Record<string, unknown>,
306-
) => ({
307-
prompt,
308-
...(getAgentFallbackModels(agentId)
309-
? { fallback_models: getAgentFallbackModels(agentId) }
310-
: {}),
311-
...(overrides ?? {}),
312-
mode: "subagent" as const,
313-
hidden: true,
314-
});
336+
) => {
337+
const { permission: overridePermission, ...restOverrides } = (overrides ?? {}) as {
338+
permission?: Record<string, unknown>;
339+
[key: string]: unknown;
340+
};
341+
const basePermission = buildAllowOnlyPermission(allowedTools);
342+
return {
343+
prompt,
344+
...(getAgentFallbackModels(agentId)
345+
? { fallback_models: getAgentFallbackModels(agentId) }
346+
: {}),
347+
...restOverrides,
348+
// Permission baseline goes after `restOverrides` so that
349+
// accidental `permission` keys in user overrides we DIDN'T
350+
// explicitly destructure can't bypass the deny. The explicit
351+
// override (destructured above) is then layered on top.
352+
permission: {
353+
...basePermission,
354+
...(overridePermission ?? {}),
355+
},
356+
mode: "subagent" as const,
357+
hidden: true,
358+
};
359+
};
315360

316361
const commandConfig = {
317362
...(config.command ?? {}),
@@ -360,23 +405,27 @@ const plugin: Plugin = async (ctx) => {
360405
[DREAMER_AGENT]: buildHiddenAgentConfig(
361406
DREAMER_AGENT,
362407
DREAMER_SYSTEM_PROMPT,
408+
DREAMER_ALLOWED_TOOLS,
363409
dreamerAgentOverrides,
364410
),
365411
[HISTORIAN_AGENT]: buildHiddenAgentConfig(
366412
HISTORIAN_AGENT,
367413
pluginConfig.dreamer?.user_memories?.enabled
368414
? COMPARTMENT_AGENT_SYSTEM_PROMPT + USER_OBSERVATIONS_APPENDIX
369415
: COMPARTMENT_AGENT_SYSTEM_PROMPT,
416+
HISTORIAN_ALLOWED_TOOLS,
370417
historianAgentOverrides,
371418
),
372419
[HISTORIAN_EDITOR_AGENT]: buildHiddenAgentConfig(
373420
HISTORIAN_EDITOR_AGENT,
374421
HISTORIAN_EDITOR_SYSTEM_PROMPT,
422+
HISTORIAN_ALLOWED_TOOLS,
375423
historianAgentOverrides,
376424
),
377425
[SIDEKICK_AGENT]: buildHiddenAgentConfig(
378426
SIDEKICK_AGENT,
379427
SIDEKICK_SYSTEM_PROMPT,
428+
SIDEKICK_ALLOWED_TOOLS,
380429
sidekickAgentOverrides,
381430
),
382431
};

0 commit comments

Comments
 (0)