fix(agents): address all PR #2299 code review findings

Blocking fixes:
- B1: Return empty restrictions for unknown/custom agents instead of
  EXPLORATION_AGENT_DENYLIST, allowing custom agents full tool access
- B2: Use Object.create(null) consistently across all 5 agent-loading
  result objects to prevent prototype pollution
- B3: Add code comment documenting custom agent bash access trust model
- B4: Mock getOpenCodeConfigDir in opencode-config-agents-reader tests
  to prevent global config dir leakage

Non-blocking fixes:
- N1: Use resolveAgentDefinitionPaths with project boundary enforcement
  in opencode-config-agents-reader for path containment
- N2: Add session-scoped 30s TTL cache to resolveCallableAgents to
  avoid redundant SDK IPC calls per tool invocation
- N3: Extract shared parseToolsConfig into src/shared/parse-tools-config.ts
  replacing 4 duplicated local implementations
- N4: Add .min(1) to AgentDefinitionPathSchema rejecting empty paths
- N5: Add resolve-agent-definition-paths.test.ts covering tilde expansion,
  relative paths, boundary enforcement, and null containmentDir
- N6: Validate agent mode against allowed values instead of bare type
  assertion in opencode-config-agents-reader
This commit is contained in:
YeonGyu-Kim
2026-04-15 10:41:32 +09:00
parent 4c77045c47
commit e5d3fe96c4
14 changed files with 214 additions and 82 deletions
@@ -2,23 +2,11 @@ import { existsSync, readFileSync } from "fs"
import { basename, extname } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { log } from "../../shared/logger"
import { parseToolsConfig } from "../../shared/parse-tools-config"
import { parseJsonAgentFile } from "./json-agent-loader"
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
if (!toolsStr) return undefined
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
if (tools.length === 0) return undefined
const result: Record<string, boolean> = {}
for (const tool of tools) {
result[tool.toLowerCase()] = true
}
return result
}
export function parseMarkdownAgentFile(filePath: string, scope: AgentScope): LoadedAgent | null {
try {
if (!existsSync(filePath)) {
@@ -67,7 +55,7 @@ export function loadAgentDefinitions(
paths: string[],
scope: AgentScope
): Record<string, ClaudeCodeAgentConfig> {
const result: Record<string, ClaudeCodeAgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
for (const filePath of paths) {
if (!existsSync(filePath)) {
@@ -1,23 +1,9 @@
import { existsSync, readFileSync } from "fs"
import { parseJsoncSafe } from "../../shared/jsonc-parser"
import { parseToolsConfig } from "../../shared/parse-tools-config"
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
import type { AgentScope, AgentJsonDefinition, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
function parseToolsConfig(tools?: string | string[]): Record<string, boolean> | undefined {
if (!tools) return undefined
const toolsArray = Array.isArray(tools) ? tools : tools.split(",").map((t) => t.trim())
const filtered = toolsArray.filter((t) => typeof t === "string" && t.length > 0)
if (filtered.length === 0) return undefined
const result: Record<string, boolean> = {}
for (const tool of filtered) {
result[tool.toLowerCase()] = true
}
return result
}
export function parseJsonAgentFile(filePath: string, scope: AgentScope): LoadedAgent | null {
try {
if (!existsSync(filePath)) {
@@ -32,7 +32,7 @@ export function loadUserAgents(): Record<string, ClaudeCodeAgentConfig> {
const userAgentsDir = join(getClaudeConfigDir(), "agents")
const agents = loadAgentsFromDir(userAgentsDir, "user")
const result: Record<string, ClaudeCodeAgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
for (const agent of agents) {
result[agent.name] = agent.config
}
@@ -43,7 +43,7 @@ export function loadProjectAgents(directory?: string): Record<string, ClaudeCode
const projectAgentsDir = join(directory ?? process.cwd(), ".claude", "agents")
const agents = loadAgentsFromDir(projectAgentsDir, "project")
const result: Record<string, ClaudeCodeAgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
for (const agent of agents) {
result[agent.name] = agent.config
}
@@ -55,7 +55,7 @@ export function loadOpencodeGlobalAgents(): Record<string, ClaudeCodeAgentConfig
const opencodeAgentsDir = join(configDir, "agents")
const agents = loadAgentsFromDir(opencodeAgentsDir, "opencode")
const result: Record<string, ClaudeCodeAgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
for (const agent of agents) {
result[agent.name] = agent.config
}
@@ -66,7 +66,7 @@ export function loadOpencodeProjectAgents(directory?: string): Record<string, Cl
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "agents")
const agents = loadAgentsFromDir(opencodeProjectDir, "opencode-project")
const result: Record<string, ClaudeCodeAgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
for (const agent of agents) {
result[agent.name] = agent.config
}
@@ -1,11 +1,26 @@
import { describe, expect, it } from "bun:test"
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
import { mock } from "bun:test"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { readOpencodeConfigAgents } from "./opencode-config-agents-reader"
// Mock getOpenCodeConfigDir to prevent global config leakage
let mockGlobalConfigDir: string
mock.module("../../shared/opencode-config-dir", () => ({
getOpenCodeConfigDir: () => mockGlobalConfigDir,
}))
const { readOpencodeConfigAgents } = require("./opencode-config-agents-reader")
describe("readOpencodeConfigAgents", () => {
beforeEach(() => {
mockGlobalConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-mock-global-"))
})
afterEach(() => {
fs.rmSync(mockGlobalConfigDir, { recursive: true, force: true })
})
it("returns empty record when no opencode.json exists", () => {
const nonexistentDir = "/nonexistent/directory/path"
const result = readOpencodeConfigAgents(nonexistentDir)
@@ -3,6 +3,8 @@ import * as path from "node:path"
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
import { parseJsoncSafe } from "../../shared/jsonc-parser"
import { parseToolsConfig } from "../../shared/parse-tools-config"
import { resolveAgentDefinitionPaths } from "../../shared/resolve-agent-definition-paths"
import { loadAgentDefinitions } from "./agent-definitions-loader"
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
import type { ClaudeCodeAgentConfig } from "./types"
@@ -25,28 +27,6 @@ function getConfigPaths(directory: string): string[] {
return paths
}
function parseToolsConfig(toolsValue: unknown): Record<string, boolean> | undefined {
if (!toolsValue) return undefined
let toolsStr: string
if (typeof toolsValue === "string") {
toolsStr = toolsValue
} else if (Array.isArray(toolsValue)) {
toolsStr = toolsValue.filter((t) => typeof t === "string").join(",")
} else {
return undefined
}
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
if (tools.length === 0) return undefined
const result: Record<string, boolean> = {}
for (const tool of tools) {
result[tool.toLowerCase()] = true
}
return result
}
function convertInlineAgent(agentData: unknown): ClaudeCodeAgentConfig | null {
if (!agentData || typeof agentData !== "object") {
return null
@@ -63,9 +43,15 @@ function convertInlineAgent(agentData: unknown): ClaudeCodeAgentConfig | null {
? `${mappedModel.providerID}/${mappedModel.modelID}`
: undefined
const VALID_MODES = ["subagent", "primary", "all"] as const
const rawMode = typeof agent.mode === "string" ? agent.mode : undefined
const mode = rawMode && (VALID_MODES as readonly string[]).includes(rawMode)
? (rawMode as "subagent" | "primary" | "all")
: "subagent"
const config: ClaudeCodeAgentConfig = {
description,
mode: (agent.mode as "subagent" | "primary" | "all") || "subagent",
mode,
prompt: agent.prompt ? String(agent.prompt) : "",
...(modelString ? { model: modelString } : {}),
}
@@ -106,9 +92,7 @@ export function readOpencodeConfigAgents(directory: string): Record<string, Clau
if (parseResult.data.agent_definitions) {
const definitionPaths = extractDefinitionPaths(parseResult.data.agent_definitions)
const resolvedPaths = definitionPaths.map((p) =>
path.isAbsolute(p) ? p : path.resolve(configDir, p)
)
const resolvedPaths = resolveAgentDefinitionPaths(definitionPaths, configDir, directory)
const definitionAgents = loadAgentDefinitions(resolvedPaths, "opencode-config")