Compare commits

..
Author SHA1 Message Date
opencode 782a2bf06a release: v1.14.45 2026-05-10 00:07:14 +00:00
5 changed files with 60 additions and 320 deletions
@@ -1,33 +0,0 @@
import type { Permission } from "../permission"
import type { Agent } from "./agent"
/**
* Build the `permission` ruleset for a subagent's session when it's spawned
* via the task tool. Combines:
*
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
* restrictions live on the agent ruleset, not on the session, so a
* subagent that only inherited the parent SESSION's permission would
* silently bypass them. (#26514)
* 2. The parent **session's** deny rules and external_directory rules —
* same forwarding the original code already did.
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
* doesn't already permit them.
*/
export function deriveSubagentSessionPermission(input: {
parentSessionPermission: Permission.Ruleset
parentAgent: Agent.Info | undefined
subagent: Agent.Info
}): Permission.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
return [
...parentAgentDenies,
...input.parentSessionPermission.filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
]
}
+22 -61
View File
@@ -2,13 +2,10 @@ export * as ConfigParse from "./parse"
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import z from "zod"
import type { DeepMutable } from "@opencode-ai/core/schema"
import { InvalidError, JsonError } from "./error"
const log = Log.create({ service: "config.parse" })
type ZodSchema<T> = z.ZodType<T>
export function jsonc(text: string, filepath: string): unknown {
@@ -53,70 +50,34 @@ export function effectSchema<S extends EffectSchema.Decoder<unknown, never>>(
data: unknown,
source: string,
): DeepMutable<S["Type"]> {
// The user's config lives on disk and may legitimately be stale, hand-edited,
// or carry leftover keys from older versions. Crashing the whole load on a
// single bad field would make opencode unstartable for those users (see Ben
// Matthews / Discord, v1.14.45). Strip the malformed top-level fields and
// keep going — log every drop so users can see what was ignored and fix it.
const cleaned = stripUnknownTopLevelKeys(schema, data, source)
return decodeWithFieldTolerance(schema, cleaned, source)
}
function stripUnknownTopLevelKeys(schema: EffectSchema.Top, data: unknown, source: string): unknown {
if (typeof data !== "object" || data === null || Array.isArray(data)) return data
const extra = topLevelExtraKeys(schema, data)
if (extra.length === 0) return data
log.warn("ignoring unrecognized config keys", { source, keys: extra })
const obj = data as Record<string, unknown>
return Object.fromEntries(Object.entries(obj).filter(([key]) => !extra.includes(key)))
}
if (extra.length) {
throw new InvalidError({
path: source,
issues: [
{
code: "unrecognized_keys",
keys: extra,
path: [],
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
} as z.core.$ZodIssue,
],
})
}
function decodeWithFieldTolerance<S extends EffectSchema.Decoder<unknown, never>>(
schema: S,
data: unknown,
source: string,
): DeepMutable<S["Type"]> {
// Try a clean decode first. If it succeeds we're done — common path.
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
const error = Cause.squash(decoded.cause)
const issues = EffectSchema.isSchemaError(error)
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[])
// Identify malformed top-level fields. Anything with a non-empty path is a
// field-scoped issue we can drop and retry. Issues with an empty path are
// root-level (e.g. data is not an object at all) and can't be field-recovered.
const badFields = collectTopLevelFieldNames(issues)
if (badFields.size === 0 || typeof data !== "object" || data === null || Array.isArray(data)) {
throw new InvalidError({ path: source, issues }, { cause: error })
}
log.warn("ignoring invalid config fields", {
source,
fields: [...badFields],
summary: issues
.filter((issue) => issue.path && issue.path.length > 0)
.map((issue) => `${issue.path?.join(".")}: ${issue.message}`)
.slice(0, 8),
})
const obj = data as Record<string, unknown>
const cleaned = Object.fromEntries(Object.entries(obj).filter(([key]) => !badFields.has(key)))
// Retry without the bad fields. If THIS fails, we're past field-tolerance —
// fall back to the original strict error so the user sees the real cause.
const retry = EffectSchema.decodeUnknownExit(schema)(cleaned, { errors: "all", propertyOrder: "original" })
if (Exit.isSuccess(retry)) return retry.value as DeepMutable<S["Type"]>
throw new InvalidError({ path: source, issues }, { cause: error })
}
function collectTopLevelFieldNames(issues: z.core.$ZodIssue[]): Set<string> {
const names = new Set<string>()
for (const issue of issues) {
const head = issue.path?.[0]
if (typeof head === "string") names.add(head)
}
return names
throw new InvalidError(
{
path: source,
issues: EffectSchema.isSchemaError(error)
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]),
},
{ cause: error },
)
}
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
+26 -11
View File
@@ -4,7 +4,6 @@ import { Session } from "@/session/session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
import type { SessionPrompt } from "../session/prompt"
import { Config } from "@/config/config"
import { Effect, Exit, Schema } from "effect"
@@ -59,25 +58,41 @@ export const TaskTool = Tool.define(
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const canTask = next.permission.some((rule) => rule.permission === id)
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
const taskID = params.task_id
const session = taskID
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const parent = yield* sessions.get(ctx.sessionID)
const parentAgent = parent.agent
? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const nextSession =
session ??
(yield* sessions.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...deriveSubagentSessionPermission({
parentSessionPermission: parent.permission ?? [],
parentAgent,
subagent: next,
}),
...(parent.permission ?? []).filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
@@ -129,8 +144,8 @@ export const TaskTool = Tool.define(
},
agent: next.name,
tools: {
...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }),
...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }),
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
@@ -1,141 +0,0 @@
/**
* Reproducer for opencode issue #26514:
*
* In Plan Mode (the `plan` agent), the main agent's edit/write tools are
* blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`).
* However, when the plan agent spawns a subagent via the `task` tool, the
* subagent retains full file modification capabilities — a security bypass.
*
* This test replicates the permission ruleset that would govern a
* `general` subagent when launched from a `plan` parent session, mirroring
* the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime
* subagent agent permissions, evaluated as in `session/prompt.ts`).
*
* The expected (secure) behavior is that the subagent inherits the plan
* mode read-only restriction and `edit`/`write` resolve to `deny`. On
* origin/dev this assertion fails because the parent **agent** permissions
* are not propagated to the subagent — only the parent **session**
* permissions are passed through, and Plan Mode's restrictions live on the
* agent, not the session.
*/
import { test, expect, afterEach } from "bun:test"
import { Effect } from "effect"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Agent } from "../../src/agent/agent"
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
import { Permission } from "../../src/permission"
afterEach(async () => {
await disposeAllInstances()
})
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
// `deriveSubagentSessionPermission` is imported from production. The test
// exercises the actual helper that task.ts uses to build the subagent's
// session permission, so any regression in that helper trips this test.
test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const generalAgent = await load(tmp.path, (svc) => svc.get("general"))
expect(planAgent).toBeDefined()
expect(generalAgent).toBeDefined()
// Sanity: the plan agent itself blocks edit. (Note: `write` and
// `apply_patch` route through the `edit` permission at the runtime
// tool layer — see Permission.disabled / EDIT_TOOLS.)
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
// Simulate the plan-mode parent session: in real flow the plan
// session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: generalAgent!,
})
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
},
})
})
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => {
// Sibling check: even though `explore` is intrinsically read-only, the
// bug surface is the same. Including this case to document that the fix
// should propagate the parent **agent** permissions, not just deny edit
// when the subagent happens to already deny it.
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const explore = await load(tmp.path, (svc) => svc.get("explore"))
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: explore!,
})
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
// Already deny — sanity check.
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
},
})
})
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => {
// The most damaging case: a user-defined subagent with default
// permissions (allow-by-default, like `general`). The subagent must NOT
// be able to edit when the parent agent is `plan`.
await using tmp = await tmpdir({
config: {
agent: {
my_subagent: {
description: "A user-defined subagent",
mode: "subagent",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const my = await load(tmp.path, (svc) => svc.get("my_subagent"))
expect(planAgent).toBeDefined()
expect(my).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: my!,
})
const effective = Permission.merge(my!.permission, subagentSessionPermission)
// BUG: on origin/dev edit resolves to "allow" because the plan
// agent's `edit: deny *` rule never reaches the subagent.
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
},
})
})
+12 -74
View File
@@ -558,22 +558,20 @@ test("handles file inclusion with replacement tokens", async () => {
})
})
test("config loader is tolerant: drops unknown fields, keeps the rest", async () => {
test("validates config schema and throws on invalid fields", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "kept",
invalid_field: "should be dropped, not crash the app",
invalid_field: "should cause error",
})
},
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.username).toBe("kept")
expect((config as Record<string, unknown>).invalid_field).toBeUndefined()
// Strict schema should throw an error for invalid fields
await expect(load()).rejects.toThrow()
},
})
})
@@ -1683,70 +1681,7 @@ test("permission config preserves user key order", async () => {
})
})
// Discord bug report (Ben Matthews, v1.14.45): a malformed `skills:` field
// (array instead of object) made the WHOLE config fail to load, the server
// returned 500, and the desktop app couldn't start. Per Kit:
// "for all of these things that we load from the user's computer, they
// should be kind of tolerant. ... It shouldn't break opencode."
// The contract: drop the malformed top-level field, log a warning, keep
// the rest of the config so the app starts.
test("config parser is tolerant: drops malformed top-level fields, keeps the rest", () => {
const config = ConfigParse.effectSchema(
Config.Info,
{
$schema: "https://opencode.ai/config.json",
username: "ben",
// Wrong shape — schema expects { paths?, urls? }, user has an array
// (looks like the LOADED skills list got pasted into the config).
skills: [
{ name: "scss-layout-accessibility", path: ".opencode/skills/scss-layout-accessibility.md" },
{ name: "testing", path: ".opencode/skills/testing.md" },
],
},
"test",
)
// Pre-fix this throws ConfigInvalidError and the user can't start opencode.
// Post-fix the bad field is dropped and the rest of the config loads.
expect(config.username).toBe("ben")
expect(config.skills).toBeUndefined()
})
test("config parser is tolerant: drops unrecognized top-level keys instead of throwing", () => {
const config = ConfigParse.effectSchema(
Config.Info,
{
$schema: "https://opencode.ai/config.json",
username: "ben",
// Typo or stale key — pre-fix this threw `unrecognized_keys`.
autoshrare: true,
},
"test",
)
expect(config.username).toBe("ben")
expect((config as Record<string, unknown>).autoshrare).toBeUndefined()
})
test("config parser is tolerant: drops multiple bad fields in one pass", () => {
const config = ConfigParse.effectSchema(
Config.Info,
{
$schema: "https://opencode.ai/config.json",
username: "ben",
skills: ["wrong shape"],
autoshare: 42, // wrong type — schema wants string literal | undefined
not_a_real_key: "ignore me",
},
"test",
)
expect(config.username).toBe("ben")
expect(config.skills).toBeUndefined()
expect(config.autoshare).toBeUndefined()
})
test("Effect config parser preserves permission order while dropping unknown top-level keys", () => {
test("Effect config parser preserves permission order while rejecting unknown top-level keys", () => {
const config = ConfigParse.effectSchema(
Config.Info,
{
@@ -1760,10 +1695,13 @@ test("Effect config parser preserves permission order while dropping unknown top
)
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
// Tolerant parser: unknown keys are stripped (with a warning log) instead
// of failing the entire config load.
const stripped = ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
expect((stripped as Record<string, unknown>).invalid_field).toBeUndefined()
try {
ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
throw new Error("expected config parse to fail")
} catch (err) {
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
}
})
// MCP config merging tests