Skip to content

Commit b1f1a08

Browse files
fix(opencode,core): close audit-v8 wave C schema/security findings
- REGRESSION-01/NEW-01: boundSchema drops a combinator/enum keyword whose members all prune instead of emitting anyOf/oneOf/allOf/enum: [] (invalid under ajv 2020-12 minItems); regression test now walks every emitted keyword at the depth, node-budget and cyclic boundaries. - NEW-03: convertTool restricts root `required` to surviving `properties`, dropping the keyword when none survive, so a pruned cyclic tool stays callable. - NEW-10: draft-07 tuple `items: [...]` is normalized to a single schema. - R-01: external_directory requests the lexical path so alias-keyed config rules match, while persisting the resolved `always` glob; the P1-02 symlink-swap re-prompt is preserved. - deepseek NEW-01: shell pathname globs are treated as dynamic (no `always` grant), matchStrict excludes `*?[` from the strict group, and leading-glob arguments are still resolved for external_directory scanning. - opus NEW-04: sanitizePluginEnv reports dropped key names (never values) at both call sites and adds the OPENCODE_PLUGIN_ENV_ALLOW escape hatch for user-authored `mcp.environment` entries. - NOISE: null/non-object inputSchema guard; matchStrict matches file-path patterns verbatim (unquote only for shell-shaped grants). Tests: packages/opencode 3767 pass / 0 fail; packages/core 1125 pass / 0 fail. Typecheck: packages/opencode and packages/core clean.
1 parent b8bf11b commit b1f1a08

11 files changed

Lines changed: 397 additions & 46 deletions

File tree

‎packages/core/src/util/wildcard.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,11 @@ function glob(pattern: string, strict: boolean) {
5353
// The strict group refuses a flag in any argument position *and* refuses an
5454
// argument that still contains a shell expansion after unquoting. `cat $FILE`
5555
// executes against a path the matcher never saw, and `$(echo -rf) /` can expand
56-
// to flags, so neither may inherit a saved `cat *` grant.
56+
// to flags, so neither may inherit a saved `cat *` grant. Pathname globs
57+
// (`*`, `?`, `[`) are expansions too: `cat */../../etc/passwd` must not match a
58+
// saved literal grant as if the glob were an ordinary argument.
5759
if (escaped.endsWith(" .*"))
58-
escaped = escaped.slice(0, -3) + (strict ? "( (?!-)[^\\s$`(){}]*)*" : "( .*)?")
60+
escaped = escaped.slice(0, -3) + (strict ? "( (?!-)[^\\s$`(){}*?\\[\\]]*)*" : "( .*)?")
5961

6062
const expression = new RegExp("^" + escaped + "$", process.platform === "win32" ? "si" : "s")
6163
if (compiled.size >= 512) {
@@ -79,5 +81,10 @@ export function match(input: string, pattern: string) {
7981
* flag-bearing or expansion-bearing invocations.
8082
*/
8183
export function matchStrict(input: string, pattern: string) {
82-
return glob(unquote(pattern), true).test(unquote(input).replaceAll("\\", "/"))
84+
// Shell grants are stored as `"<literal prefix> *"` and must be unquoted so a
85+
// quoted flag (`rm "-rf" /`) cannot masquerade as a literal argument. File-path
86+
// patterns (external_directory globs, read/write paths) are not shell text, so
87+
// match them verbatim — unquoting could collapse distinct filenames.
88+
const shell = pattern.endsWith(" *")
89+
return glob(shell ? unquote(pattern) : pattern, true).test((shell ? unquote(input) : input).replaceAll("\\", "/"))
8390
}

‎packages/core/test/wildcard.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,20 @@ describe("Wildcard.match", () => {
4949
expect(Wildcard.matchStrict("git push origin main", "git push *")).toBe(true)
5050
expect(Wildcard.matchStrict("chmod 777 /etc", "chmod *")).toBe(true)
5151
})
52+
53+
test("matchStrict refuses pathname globs from inheriting a literal grant", () => {
54+
expect(Wildcard.matchStrict("cat */../../../../etc/passwd", "cat *")).toBe(false)
55+
expect(Wildcard.matchStrict("cat ?/etc/passwd", "cat *")).toBe(false)
56+
expect(Wildcard.matchStrict("cat [a]/etc/passwd", "cat *")).toBe(false)
57+
expect(Wildcard.matchStrict("cat a*/../../etc/passwd", "cat *")).toBe(false)
58+
expect(Wildcard.matchStrict("rm *", "rm *")).toBe(false)
59+
expect(Wildcard.matchStrict("cat foo.txt", "cat *")).toBe(true)
60+
expect(Wildcard.matchStrict("rm file.txt", "rm *")).toBe(true)
61+
})
62+
63+
test("matchStrict treats file-path patterns verbatim", () => {
64+
expect(Wildcard.matchStrict("src/foo.ts", "src/*")).toBe(true)
65+
expect(Wildcard.matchStrict('my"file.txt', "myfile.txt")).toBe(false)
66+
expect(Wildcard.matchStrict("myfile.txt", "myfile.txt")).toBe(true)
67+
})
5268
})

‎packages/opencode/src/mcp/catalog.ts‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,25 @@ export function defs(client: Client, timeout?: number) {
4747

4848
export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number, server?: string): Tool {
4949
const bounded = boundSchema(mcpTool.inputSchema)
50-
const schema = bounded === BOUNDED ? {} : (bounded as JSONSchema7)
50+
// A non-object input schema never reaches here (the MCP SDK zod schema requires an
51+
// object), but guard anyway so a malformed or null tool cannot throw a `TypeError`.
52+
const schema = typeof bounded === "object" && bounded !== null ? (bounded as JSONSchema7) : {}
53+
const properties = (schema.properties ?? {}) as NonNullable<JSONSchema7["properties"]>
54+
// Pruning can drop a property while its `required` entry survives, and the root
55+
// sets `additionalProperties: false`; the result is a schema no instance can
56+
// satisfy, silently making the tool permanently uncallable. Restrict `required`
57+
// to keys that still exist, dropping the keyword entirely when none survive.
58+
const required = Array.isArray(schema.required)
59+
? schema.required.filter((key): key is string => typeof key === "string" && Object.hasOwn(properties, key))
60+
: undefined
5161
const inputSchema: JSONSchema7 = {
5262
...schema,
5363
type: "object",
54-
properties: (schema.properties ?? {}) as JSONSchema7["properties"],
64+
properties,
5565
additionalProperties: false,
5666
}
67+
if (required && required.length > 0) inputSchema.required = required
68+
else delete inputSchema.required
5769

5870
return dynamicTool({
5971
description: describeTool(mcpTool.description, server),
@@ -186,13 +198,25 @@ function boundSchema(
186198
seen.add(value)
187199
counter.nodes++
188200
if (Array.isArray(value)) {
189-
return value.flatMap((item) => {
190-
const bounded = boundSchema(item, depth + 1, seen, counter)
191-
return bounded === BOUNDED ? [] : [bounded]
201+
const bounded = value.flatMap((item) => {
202+
const child = boundSchema(item, depth + 1, seen, counter)
203+
return child === BOUNDED ? [] : [child]
192204
})
205+
// `anyOf`/`oneOf`/`allOf`/`enum` must be non-empty when present. If every member
206+
// of a non-empty source array was pruned, return BOUNDED so the *parent* drops
207+
// the keyword instead of emitting `[]`, which ajv 2020-12 rejects (`minItems`)
208+
// and providers surface as a whole-manifest rejection. A genuinely empty source
209+
// array (e.g. `required: []`) is preserved verbatim.
210+
if (value.length > 0 && bounded.length === 0) return BOUNDED
211+
return bounded
193212
}
194213
return Object.fromEntries(
195214
Object.entries(value).flatMap(([key, item]) => {
215+
// Draft-07 tuple form (`items: [a, b]`) is invalid under 2020-12, where `items`
216+
// must be a single schema. Normalize conservatively to an unconstrained schema
217+
// instead of re-expressing positional constraints; the input was already
218+
// non-2020-12, and the bounded output must not be.
219+
if (key === "items" && Array.isArray(item)) return [[key, {}]]
196220
const bounded = boundSchema(item, depth + 1, seen, counter)
197221
return bounded === BOUNDED ? [] : [[key, bounded]]
198222
}),

‎packages/opencode/src/mcp/index.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"
2020
import { NamedError } from "@opencode-ai/core/util/error"
2121
import { InstallationVersion } from "@opencode-ai/core/installation/version"
2222
import { withTimeout } from "@/util/timeout"
23-
import { sanitizePluginEnv } from "@/util/plugin-env"
23+
import { sanitizePluginEnv, userPluginEnvAllowlist } from "@/util/plugin-env"
2424
import { FSUtil } from "@opencode-ai/core/fs-util"
2525
import { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
2626
import { McpOAuthCallback } from "./oauth-callback"
@@ -380,6 +380,12 @@ const layer = Layer.effect(
380380
status: { status: "failed" as const, error: `MCP server cwd escapes the workspace: ${cwd}` },
381381
}
382382
}
383+
const sanitized = sanitizePluginEnv(mcp.environment ?? {}, userPluginEnvAllowlist())
384+
if (sanitized.dropped.length > 0)
385+
yield* Effect.logWarning("mcp.environment dropped variables not on the allowlist", {
386+
key,
387+
keys: sanitized.dropped,
388+
})
383389
const transport = new StdioClientTransport({
384390
stderr: "pipe",
385391
command: cmd,
@@ -388,7 +394,7 @@ const layer = Layer.effect(
388394
env: {
389395
...process.env,
390396
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
391-
...sanitizePluginEnv(mcp.environment ?? {}),
397+
...sanitized.env,
392398
},
393399
})
394400

‎packages/opencode/src/tool/external-directory.ts‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,31 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
2929
const resolved = FSUtil.resolveExisting(full)
3030
if (containsPath(full, ins) && containsPath(resolved, ins)) return false
3131

32-
// Prompt and persist the glob on the *resolved* target, not the lexical path.
33-
// The approval cache matches request patterns against the saved glob, so keying
34-
// it lexically would let a later symlink swap (`vendor -> /etc`) reuse a `vendor/*`
35-
// grant on `/etc/*` without re-prompting.
32+
// The persisted `always` grant is keyed on the *resolved* target: keying it
33+
// lexically would let a later symlink swap (`vendor -> /etc`) reuse a
34+
// `vendor/*` grant on `/etc/*` without re-prompting. The request `patterns`
35+
// stay on the user-visible lexical path so a pre-approved config rule written
36+
// against an alias/symlink path (macOS `/tmp` -> `/private/tmp`, vendored
37+
// worktree aliases) still matches. `Permission.ask` requires *every* pattern to
38+
// be allowed, so adding the resolved form here would force a prompt even when
39+
// the lexical rule is allowed; the resolved target is surfaced through
40+
// `metadata` instead. Trade-off: an interactively-approved symlink alias
41+
// re-prompts on each call (its cached grant is the resolved path), which is the
42+
// conservative direction.
3643
const kind = options?.kind ?? "file"
37-
const dir = kind === "directory" ? resolved : path.dirname(resolved)
38-
const glob =
44+
const targetDir = (p: string) => (kind === "directory" ? p : path.dirname(p))
45+
const toGlob = (p: string) =>
3946
process.platform === "win32"
40-
? FSUtil.normalizePathPattern(path.join(dir, "*"))
41-
: path.join(dir, "*").replaceAll("\\", "/")
47+
? FSUtil.normalizePathPattern(path.join(p, "*"))
48+
: path.join(p, "*").replaceAll("\\", "/")
49+
const dir = targetDir(resolved)
50+
const resolvedGlob = toGlob(dir)
51+
const lexicalGlob = toGlob(targetDir(full))
4252

4353
yield* ctx.ask({
4454
permission: "external_directory",
45-
patterns: [glob],
46-
always: [glob],
55+
patterns: [lexicalGlob],
56+
always: [resolvedGlob],
4757
metadata: {
4858
filepath: resolved,
4959
parentDir: dir,

‎packages/opencode/src/tool/shell.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ function provider(text: string) {
179179
function dynamic(text: string, ps: boolean) {
180180
if (text.startsWith("(") || text.startsWith("@(")) return true
181181
if (text.includes("$(") || text.includes("${") || text.includes("`")) return true
182+
// Pathname expansion is a shell expansion too: the argument the matcher sees
183+
// (`cat *`) is not the path the shell executes (`cat /etc/passwd`), so a glob
184+
// must never inherit a literal `always` grant.
185+
if (/[?*[]/.test(text)) return true
182186
if (ps) return /\$(?!env:)/i.test(text)
183187
return text.includes("$")
184188
}
@@ -190,6 +194,14 @@ function prefix(text: string) {
190194
return text.slice(0, match.index)
191195
}
192196

197+
function globAnchor(text: string) {
198+
// A leading glob has no literal prefix to resolve. Keep one placeholder segment
199+
// per glob metacharacter (so a trailing `..` traversal still cancels correctly),
200+
// then drop the final, glob-selected segment. `*` anchors at cwd; `*/../..`
201+
// anchors at the directory the traversal reaches, so it is still scanned.
202+
return path.dirname(text.replace(/[*?[\]]/g, "_"))
203+
}
204+
193205
function pathArgs(list: Part[], ps: boolean, cmd = false) {
194206
if (!ps) {
195207
return list
@@ -373,8 +385,13 @@ export const ShellTool = Tool.define(
373385

374386
const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
375387
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
376-
const file = text && prefix(text)
377-
if (!file || dynamic(file, ps)) return
388+
if (!text) return
389+
const file = prefix(text)
390+
// A glob at position 0 has no literal prefix, but the shell still expands it
391+
// relative to cwd and it can traverse out of the worktree (`*/../../etc`).
392+
// Resolve the reachable directory so the external_directory scan still runs.
393+
if (!file) return yield* resolvePath(globAnchor(text), cwd, shell)
394+
if (dynamic(file, ps)) return
378395
const next = ps ? provider(file) : file
379396
if (!next) return
380397
return yield* resolvePath(next, cwd, shell)
@@ -432,9 +449,14 @@ export const ShellTool = Tool.define(
432449
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
433450
{ env: {} },
434451
)
452+
const sanitized = sanitizePluginEnv(extra.env)
453+
if (sanitized.dropped.length > 0)
454+
yield* Effect.logWarning("dropped plugin shell.env variables not on the allowlist", {
455+
keys: sanitized.dropped,
456+
})
435457
return {
436458
...process.env,
437-
...sanitizePluginEnv(extra.env),
459+
...sanitized.env,
438460
}
439461
})
440462

‎packages/opencode/src/util/plugin-env.ts‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,44 @@ const ALLOWED_KEYS = new Set([
4646
])
4747
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
4848

49-
export function sanitizePluginEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
50-
return Object.fromEntries(
49+
// Escape hatch for user-authored `mcp.environment` entries that the built-in
50+
// allowlist does not cover (`PYTHONPATH`, `VIRTUAL_ENV`, `AWS_PROFILE`, ...).
51+
// It is read from the parent process environment only, never from the env being
52+
// sanitized, so an untrusted plugin or MCP server cannot widen its own allowlist.
53+
const USER_ALLOWLIST_ENV = "OPENCODE_PLUGIN_ENV_ALLOW"
54+
55+
export function userPluginEnvAllowlist(env: NodeJS.ProcessEnv = process.env) {
56+
return new Set(
57+
(env[USER_ALLOWLIST_ENV] ?? "")
58+
.split(",")
59+
.map((name) => name.trim().toUpperCase())
60+
.filter((name) => ENV_NAME.test(name)),
61+
)
62+
}
63+
64+
export function sanitizePluginEnv(
65+
env: NodeJS.ProcessEnv,
66+
allow?: ReadonlySet<string>,
67+
): { env: NodeJS.ProcessEnv; dropped: string[] } {
68+
const dropped: string[] = []
69+
const kept = Object.fromEntries(
5170
Object.entries(env).filter(([key]) => {
52-
if (!ENV_NAME.test(key)) return false
53-
const upper = key.toUpperCase()
54-
if (ALLOWED_PREFIXES.some((prefix) => upper.startsWith(prefix))) return true
55-
if (ALLOWED_KEYS.has(upper)) return true
56-
return ALLOWED_SUFFIXES.some((suffix) => upper.endsWith(suffix))
71+
if (allowed(key, allow)) return true
72+
// Names only, so a dropped credential value is never written to logs.
73+
dropped.push(key)
74+
return false
5775
}),
5876
)
77+
return { env: kept, dropped }
78+
}
79+
80+
function allowed(key: string, allow?: ReadonlySet<string>) {
81+
if (!ENV_NAME.test(key)) return false
82+
const upper = key.toUpperCase()
83+
if (allow?.has(upper)) return true
84+
if (ALLOWED_PREFIXES.some((prefix) => upper.startsWith(prefix))) return true
85+
if (ALLOWED_KEYS.has(upper)) return true
86+
return ALLOWED_SUFFIXES.some((suffix) => upper.endsWith(suffix))
5987
}
6088

6189
export * as PluginEnv from "./plugin-env"

0 commit comments

Comments
 (0)