refactor: remove legacy tools format, use permission only

BREAKING: Requires OpenCode 1.1.1+

- Remove supportsNewPermissionSystem/usesLegacyToolsSystem checks
- Simplify permission-compat.ts to permission format only
- Unify explore/librarian deny lists: write, edit, task, sisyphus_task, call_omo_agent
- Add sisyphus_task to oracle deny list
- Update agent-tool-restrictions.ts with correct per-agent restrictions
- Clean config-handler.ts conditional version checks
- Update tests for simplified API
This commit is contained in:
justsisyphus
2026-01-16 17:11:34 +09:00
parent ede9abceb3
commit 83cbc56709
21 changed files with 505 additions and 347 deletions
+59
View File
@@ -0,0 +1,59 @@
import type { PermissionValue } from "./permission-compat"
/**
* Agent tool restrictions for session.prompt calls.
* OpenCode SDK's session.prompt `tools` parameter OVERRIDES agent-level permissions.
* This provides complete restriction sets so session.prompt calls include all necessary restrictions.
*/
const EXPLORATION_AGENT_DENYLIST: Record<string, PermissionValue> = {
write: "deny",
edit: "deny",
task: "deny",
sisyphus_task: "deny",
call_omo_agent: "deny",
}
const AGENT_RESTRICTIONS: Record<string, Record<string, PermissionValue>> = {
explore: EXPLORATION_AGENT_DENYLIST,
librarian: EXPLORATION_AGENT_DENYLIST,
oracle: {
write: "deny",
edit: "deny",
task: "deny",
sisyphus_task: "deny",
},
"multimodal-looker": {
"*": "deny",
read: "allow",
},
"document-writer": {
task: "deny",
sisyphus_task: "deny",
call_omo_agent: "deny",
},
"frontend-ui-ux-engineer": {
task: "deny",
sisyphus_task: "deny",
call_omo_agent: "deny",
},
"Sisyphus-Junior": {
task: "deny",
sisyphus_task: "deny",
},
}
export function getAgentToolRestrictions(agentName: string): Record<string, PermissionValue> {
return AGENT_RESTRICTIONS[agentName] ?? {}
}
export function hasAgentToolRestrictions(agentName: string): boolean {
const restrictions = AGENT_RESTRICTIONS[agentName]
return restrictions !== undefined && Object.keys(restrictions).length > 0
}
+1
View File
@@ -25,3 +25,4 @@ export * from "./agent-variant"
export * from "./session-cursor"
export * from "./shell-env"
export * from "./system-directive"
export * from "./agent-tool-restrictions"
+18 -52
View File
@@ -1,16 +1,14 @@
import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from "bun:test"
import * as childProcess from "child_process"
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import {
parseVersion,
compareVersions,
isVersionGte,
isVersionLt,
getOpenCodeVersion,
supportsNewPermissionSystem,
usesLegacyToolsSystem,
isOpenCodeVersionAtLeast,
resetVersionCache,
setVersionCache,
PERMISSION_BREAKING_VERSION,
MINIMUM_OPENCODE_VERSION,
} from "./opencode-version"
describe("opencode-version", () => {
@@ -163,7 +161,7 @@ describe("opencode-version", () => {
})
})
describe("supportsNewPermissionSystem", () => {
describe("isOpenCodeVersionAtLeast", () => {
beforeEach(() => {
resetVersionCache()
})
@@ -172,34 +170,34 @@ describe("opencode-version", () => {
resetVersionCache()
})
test("returns true for v1.1.1", () => {
test("returns true for exact version", () => {
// #given version is 1.1.1
setVersionCache("1.1.1")
// #when checking permission system support
const result = supportsNewPermissionSystem()
// #when checking against 1.1.1
const result = isOpenCodeVersionAtLeast("1.1.1")
// #then returns true
expect(result).toBe(true)
})
test("returns true for versions above 1.1.1", () => {
// #given version is above 1.1.1
test("returns true for versions above target", () => {
// #given version is above target
setVersionCache("1.2.0")
// #when checking
const result = supportsNewPermissionSystem()
// #when checking against 1.1.1
const result = isOpenCodeVersionAtLeast("1.1.1")
// #then returns true
expect(result).toBe(true)
})
test("returns false for versions below 1.1.1", () => {
// #given version is below 1.1.1
test("returns false for versions below target", () => {
// #given version is below target
setVersionCache("1.1.0")
// #when checking
const result = supportsNewPermissionSystem()
// #when checking against 1.1.1
const result = isOpenCodeVersionAtLeast("1.1.1")
// #then returns false
expect(result).toBe(false)
@@ -210,48 +208,16 @@ describe("opencode-version", () => {
setVersionCache(null)
// #when checking
const result = supportsNewPermissionSystem()
const result = isOpenCodeVersionAtLeast("1.1.1")
// #then returns true (assume newer version)
expect(result).toBe(true)
})
})
describe("usesLegacyToolsSystem", () => {
beforeEach(() => {
resetVersionCache()
})
afterEach(() => {
resetVersionCache()
})
test("returns true for versions below 1.1.1", () => {
// #given version is below 1.1.1
setVersionCache("1.0.150")
// #when checking
const result = usesLegacyToolsSystem()
// #then returns true
expect(result).toBe(true)
})
test("returns false for v1.1.1 and above", () => {
// #given version is 1.1.1
setVersionCache("1.1.1")
// #when checking
const result = usesLegacyToolsSystem()
// #then returns false
expect(result).toBe(false)
})
})
describe("PERMISSION_BREAKING_VERSION", () => {
describe("MINIMUM_OPENCODE_VERSION", () => {
test("is set to 1.1.1", () => {
expect(PERMISSION_BREAKING_VERSION).toBe("1.1.1")
expect(MINIMUM_OPENCODE_VERSION).toBe("1.1.1")
})
})
})
+9 -9
View File
@@ -1,6 +1,10 @@
import { execSync } from "child_process"
export const PERMISSION_BREAKING_VERSION = "1.1.1"
/**
* Minimum OpenCode version required for this plugin.
* This plugin only supports OpenCode 1.1.1+ which uses the permission system.
*/
export const MINIMUM_OPENCODE_VERSION = "1.1.1"
const NOT_CACHED = Symbol("NOT_CACHED")
let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED
@@ -53,14 +57,10 @@ export function getOpenCodeVersion(): string | null {
}
}
export function supportsNewPermissionSystem(): boolean {
const version = getOpenCodeVersion()
if (!version) return true
return isVersionGte(version, PERMISSION_BREAKING_VERSION)
}
export function usesLegacyToolsSystem(): boolean {
return !supportsNewPermissionSystem()
export function isOpenCodeVersionAtLeast(version: string): boolean {
const current = getOpenCodeVersion()
if (!current) return true
return isVersionGte(current, version)
}
export function resetVersionCache(): void {
+41 -123
View File
@@ -1,27 +1,15 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { describe, test, expect } from "bun:test"
import {
createAgentToolRestrictions,
createAgentToolAllowlist,
migrateToolsToPermission,
migratePermissionToTools,
migrateAgentConfig,
} from "./permission-compat"
import { setVersionCache, resetVersionCache } from "./opencode-version"
describe("permission-compat", () => {
beforeEach(() => {
resetVersionCache()
})
afterEach(() => {
resetVersionCache()
})
describe("createAgentToolRestrictions", () => {
test("returns permission format for v1.1.1+", () => {
// #given version is 1.1.1
setVersionCache("1.1.1")
test("returns permission format with deny values", () => {
// #given tools to restrict
// #when creating restrictions
const result = createAgentToolRestrictions(["write", "edit"])
@@ -31,38 +19,19 @@ describe("permission-compat", () => {
})
})
test("returns tools format for versions below 1.1.1", () => {
// #given version is below 1.1.1
setVersionCache("1.0.150")
test("returns empty permission for empty array", () => {
// #given empty tools array
// #when creating restrictions
const result = createAgentToolRestrictions(["write", "edit"])
const result = createAgentToolRestrictions([])
// #then returns tools format
expect(result).toEqual({
tools: { write: false, edit: false },
})
})
test("assumes new format when version unknown", () => {
// #given version is null
setVersionCache(null)
// #when creating restrictions
const result = createAgentToolRestrictions(["write"])
// #then returns permission format (assumes new version)
expect(result).toEqual({
permission: { write: "deny" },
})
// #then returns empty permission
expect(result).toEqual({ permission: {} })
})
})
describe("createAgentToolAllowlist", () => {
test("returns wildcard deny with explicit allow for v1.1.1+", () => {
// #given version is 1.1.1
setVersionCache("1.1.1")
test("returns wildcard deny with explicit allow", () => {
// #given tools to allow
// #when creating allowlist
const result = createAgentToolAllowlist(["read"])
@@ -72,11 +41,9 @@ describe("permission-compat", () => {
})
})
test("returns wildcard deny with multiple allows for v1.1.1+", () => {
// #given version is 1.1.1
setVersionCache("1.1.1")
// #when creating allowlist with multiple tools
test("returns wildcard deny with multiple allows", () => {
// #given multiple tools to allow
// #when creating allowlist
const result = createAgentToolAllowlist(["read", "glob"])
// #then returns wildcard deny with both allows
@@ -84,35 +51,6 @@ describe("permission-compat", () => {
permission: { "*": "deny", read: "allow", glob: "allow" },
})
})
test("returns explicit deny list for old versions", () => {
// #given version is below 1.1.1
setVersionCache("1.0.150")
// #when creating allowlist
const result = createAgentToolAllowlist(["read"])
// #then returns tools format with common tools denied except read
expect(result).toHaveProperty("tools")
const tools = (result as { tools: Record<string, boolean> }).tools
expect(tools.write).toBe(false)
expect(tools.edit).toBe(false)
expect(tools.bash).toBe(false)
expect(tools.read).toBeUndefined()
})
test("excludes allowed tools from legacy deny list", () => {
// #given version is below 1.1.1
setVersionCache("1.0.150")
// #when creating allowlist with glob
const result = createAgentToolAllowlist(["read", "glob"])
// #then glob is not in deny list
const tools = (result as { tools: Record<string, boolean> }).tools
expect(tools.glob).toBeUndefined()
expect(tools.write).toBe(false)
})
})
describe("migrateToolsToPermission", () => {
@@ -132,38 +70,9 @@ describe("permission-compat", () => {
})
})
describe("migratePermissionToTools", () => {
test("converts permission to boolean tools", () => {
// #given permission config
const permission = { write: "deny" as const, edit: "allow" as const }
// #when migrating
const result = migratePermissionToTools(permission)
// #then converts correctly
expect(result).toEqual({ write: false, edit: true })
})
test("excludes ask values", () => {
// #given permission with ask
const permission = {
write: "deny" as const,
edit: "ask" as const,
bash: "allow" as const,
}
// #when migrating
const result = migratePermissionToTools(permission)
// #then ask is excluded
expect(result).toEqual({ write: false, bash: true })
})
})
describe("migrateAgentConfig", () => {
test("migrates tools to permission for v1.1.1+", () => {
// #given v1.1.1 and config with tools
setVersionCache("1.1.1")
test("migrates tools to permission", () => {
// #given config with tools
const config = {
model: "test",
tools: { write: false, edit: false },
@@ -178,25 +87,8 @@ describe("permission-compat", () => {
expect(result.model).toBe("test")
})
test("migrates permission to tools for old versions", () => {
// #given old version and config with permission
setVersionCache("1.0.150")
const config = {
model: "test",
permission: { write: "deny" as const, edit: "deny" as const },
}
// #when migrating
const result = migrateAgentConfig(config)
// #then converts to tools
expect(result.permission).toBeUndefined()
expect(result.tools).toEqual({ write: false, edit: false })
})
test("preserves other config fields", () => {
// #given config with other fields
setVersionCache("1.1.1")
const config = {
model: "test",
temperature: 0.5,
@@ -212,5 +104,31 @@ describe("permission-compat", () => {
expect(result.temperature).toBe(0.5)
expect(result.prompt).toBe("hello")
})
test("merges existing permission with migrated tools", () => {
// #given config with both tools and permission
const config = {
tools: { write: false },
permission: { bash: "deny" as const },
}
// #when migrating
const result = migrateAgentConfig(config)
// #then merges permission (existing takes precedence)
expect(result.tools).toBeUndefined()
expect(result.permission).toEqual({ write: "deny", bash: "deny" })
})
test("returns unchanged config if no tools", () => {
// #given config without tools
const config = { model: "test", permission: { edit: "deny" as const } }
// #when migrating
const result = migrateAgentConfig(config)
// #then returns unchanged
expect(result).toEqual(config)
})
})
})
+36 -103
View File
@@ -1,98 +1,48 @@
import { supportsNewPermissionSystem } from "./opencode-version"
export { supportsNewPermissionSystem }
/**
* Permission system utilities for OpenCode 1.1.1+.
* This module only supports the new permission format.
*/
export type PermissionValue = "ask" | "allow" | "deny"
export interface LegacyToolsFormat {
tools: Record<string, boolean>
}
export interface NewPermissionFormat {
export interface PermissionFormat {
permission: Record<string, PermissionValue>
}
export type VersionAwareRestrictions = LegacyToolsFormat | NewPermissionFormat
/**
* Creates tool restrictions that deny specified tools.
*/
export function createAgentToolRestrictions(
denyTools: string[]
): VersionAwareRestrictions {
if (supportsNewPermissionSystem()) {
return {
permission: Object.fromEntries(
denyTools.map((tool) => [tool, "deny" as const])
),
}
}
): PermissionFormat {
return {
tools: Object.fromEntries(denyTools.map((tool) => [tool, false])),
permission: Object.fromEntries(
denyTools.map((tool) => [tool, "deny" as const])
),
}
}
/**
* Common tools that should be denied when using allowlist approach.
* Used for legacy fallback when `*: deny` pattern is not supported.
*/
const COMMON_TOOLS_TO_DENY = [
"write",
"edit",
"bash",
"task",
"sisyphus_task",
"call_omo_agent",
"webfetch",
"glob",
"grep",
"lsp_diagnostics",
"lsp_prepare_rename",
"lsp_rename",
"ast_grep_search",
"ast_grep_replace",
"session_list",
"session_read",
"session_search",
"session_info",
"background_output",
"background_cancel",
"skill",
"skill_mcp",
"look_at",
"todowrite",
"todoread",
"interactive_bash",
] as const
/**
* Creates tool restrictions that ONLY allow specified tools.
* All other tools are denied by default.
*
* Uses `*: deny` pattern for new permission system,
* falls back to explicit deny list for legacy systems.
* All other tools are denied by default using `*: deny` pattern.
*/
export function createAgentToolAllowlist(
allowTools: string[]
): VersionAwareRestrictions {
if (supportsNewPermissionSystem()) {
return {
permission: {
"*": "deny" as const,
...Object.fromEntries(
allowTools.map((tool) => [tool, "allow" as const])
),
},
}
}
// Legacy fallback: explicitly deny common tools except allowed ones
const allowSet = new Set(allowTools)
const denyTools = COMMON_TOOLS_TO_DENY.filter((tool) => !allowSet.has(tool))
): PermissionFormat {
return {
tools: Object.fromEntries(denyTools.map((tool) => [tool, false])),
permission: {
"*": "deny" as const,
...Object.fromEntries(
allowTools.map((tool) => [tool, "allow" as const])
),
},
}
}
/**
* Converts legacy tools format to permission format.
* For migrating user configs from older versions.
*/
export function migrateToolsToPermission(
tools: Record<string, boolean>
): Record<string, PermissionValue> {
@@ -104,40 +54,23 @@ export function migrateToolsToPermission(
)
}
export function migratePermissionToTools(
permission: Record<string, PermissionValue>
): Record<string, boolean> {
return Object.fromEntries(
Object.entries(permission)
.filter(([, value]) => value !== "ask")
.map(([key, value]) => [key, value === "allow"])
)
}
/**
* Migrates agent config from legacy tools format to permission format.
* If config has `tools`, converts to `permission`.
*/
export function migrateAgentConfig(
config: Record<string, unknown>
): Record<string, unknown> {
const result = { ...config }
if (supportsNewPermissionSystem()) {
if (result.tools && typeof result.tools === "object") {
const existingPermission =
(result.permission as Record<string, PermissionValue>) || {}
const migratedPermission = migrateToolsToPermission(
result.tools as Record<string, boolean>
)
result.permission = { ...migratedPermission, ...existingPermission }
delete result.tools
}
} else {
if (result.permission && typeof result.permission === "object") {
const existingTools = (result.tools as Record<string, boolean>) || {}
const migratedTools = migratePermissionToTools(
result.permission as Record<string, PermissionValue>
)
result.tools = { ...migratedTools, ...existingTools }
delete result.permission
}
if (result.tools && typeof result.tools === "object") {
const existingPermission =
(result.permission as Record<string, PermissionValue>) || {}
const migratedPermission = migrateToolsToPermission(
result.tools as Record<string, boolean>
)
result.permission = { ...migratedPermission, ...existingPermission }
delete result.tools
}
return result