feat(multimodal-looker): restrict to read-only tool access

Use createAgentToolAllowlist to allow only 'read' tool for multimodal-looker agent.
Previously denied write/edit/bash but allowed other tools.
Now uses wildcard deny pattern (*: deny) with explicit read allow.

- Add createAgentToolAllowlist function for allowlist-based restrictions
- Support legacy fallback for older OpenCode versions
- Add 4 test cases covering both permission systems
This commit is contained in:
justsisyphus
2026-01-16 15:02:55 +09:00
parent 27ef9fa8df
commit ede9abceb3
5 changed files with 150 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
import { createAgentToolAllowlist } from "../shared/permission-compat"
const DEFAULT_MODEL = "google/gemini-3-flash"
@@ -14,11 +14,7 @@ export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
export function createMultimodalLookerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"bash",
])
const restrictions = createAgentToolAllowlist(["read"])
return {
description:

View File

@@ -1,11 +1,11 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import { isGptModel } from "./types"
import type { AgentOverrideConfig, CategoryConfig } from "../config/schema"
import {
createAgentToolRestrictions,
migrateAgentConfig,
supportsNewPermissionSystem,
} from "../shared/permission-compat"
import { isGptModel } from "./types"
const SISYPHUS_JUNIOR_PROMPT = `<Role>
Sisyphus-Junior - Focused executor from OhMyOpenCode.
@@ -58,6 +58,7 @@ No todos on multi-step work = INCOMPLETE WORK.
<Verification>
Task NOT complete without:
- lsp_diagnostics clean on changed files
- Build passes (if applicable)
- All todos marked completed
</Verification>
@@ -84,7 +85,7 @@ export const SISYPHUS_JUNIOR_DEFAULTS = {
export function createSisyphusJuniorAgentWithOverrides(
override: AgentOverrideConfig | undefined,
systemDefaultModel?: string,
systemDefaultModel?: string
): AgentConfig {
if (override?.disable) {
override = undefined
@@ -120,8 +121,7 @@ export function createSisyphusJuniorAgentWithOverrides(
}
const base: AgentConfig = {
description:
override?.description ??
description: override?.description ??
"Sisyphus-Junior - Focused task executor. Same discipline, no delegation.",
mode: "subagent" as const,
model,
@@ -148,7 +148,7 @@ export function createSisyphusJuniorAgentWithOverrides(
export function createSisyphusJuniorAgent(
categoryConfig: CategoryConfig,
promptAppend?: string,
promptAppend?: string
): AgentConfig {
const prompt = buildSisyphusJuniorPrompt(promptAppend)
const model = categoryConfig.model
@@ -158,8 +158,10 @@ export function createSisyphusJuniorAgent(
...(categoryConfig.tools ? { tools: categoryConfig.tools } : {}),
})
const base: AgentConfig = {
description: "Sisyphus-Junior - Focused task executor. Same discipline, no delegation.",
description:
"Sisyphus-Junior - Focused task executor. Same discipline, no delegation.",
mode: "subagent" as const,
model,
maxTokens: categoryConfig.maxTokens ?? 64000,

View File

@@ -1,18 +1,18 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AvailableAgent, AvailableSkill, AvailableTool } from "./sisyphus-prompt-builder"
import { isGptModel } from "./types"
import type { AvailableAgent, AvailableTool, AvailableSkill } from "./sisyphus-prompt-builder"
import {
buildAntiPatternsSection,
buildDelegationTable,
buildExploreSection,
buildFrontendSection,
buildHardBlocksSection,
buildKeyTriggersSection,
buildLibrarianSection,
buildOracleSection,
buildToolSelectionTable,
buildExploreSection,
buildLibrarianSection,
buildDelegationTable,
buildFrontendSection,
buildOracleSection,
buildHardBlocksSection,
buildAntiPatternsSection,
categorizeTools,
} from "./sisyphus-prompt-builder"
import { isGptModel } from "./types"
const DEFAULT_MODEL = "anthropic/claude-opus-4-5"
@@ -336,6 +336,7 @@ When you're mentioned in GitHub issues or asked to "look into" something and "cr
2. **Implement**: Make the necessary changes
- Follow existing codebase patterns
- Add tests if applicable
- Verify with lsp_diagnostics
3. **Verify**: Ensure everything works
- Run build if exists
- Run tests if exists
@@ -360,12 +361,18 @@ const SISYPHUS_CODE_CHANGES = `### Code Changes:
### Verification:
Run \`lsp_diagnostics\` on changed files at:
- End of a logical task unit
- Before marking a todo item complete
- Before reporting completion to user
If project has build/test commands, run them at task completion.
### Evidence Requirements (task NOT complete without these):
| Action | Required Evidence |
|--------|-------------------|
| File edit | \`lsp_diagnostics\` clean on changed files |
| Build command | Exit code 0 |
| Test run | Pass (or explicit note of pre-existing failures) |
| Delegation | Agent result received and verified |
@@ -394,6 +401,7 @@ const SISYPHUS_PHASE3 = `## Phase 3 - Completion
A task is complete when:
- [ ] All planned todo items marked done
- [ ] Diagnostics clean on changed files
- [ ] Build passes (if applicable)
- [ ] User's original request fully addressed
@@ -517,7 +525,7 @@ const SISYPHUS_SOFT_GUIDELINES = `## Soft Guidelines
function buildDynamicSisyphusPrompt(
availableAgents: AvailableAgent[],
availableTools: AvailableTool[] = [],
availableSkills: AvailableSkill[] = [],
availableSkills: AvailableSkill[] = []
): string {
const keyTriggers = buildKeyTriggersSection(availableAgents, availableSkills)
const toolSelection = buildToolSelectionTable(availableAgents, availableTools, availableSkills)
@@ -602,7 +610,7 @@ export function createSisyphusAgent(
model: string = DEFAULT_MODEL,
availableAgents?: AvailableAgent[],
availableToolNames?: string[],
availableSkills?: AvailableSkill[],
availableSkills?: AvailableSkill[]
): AgentConfig {
const tools = availableToolNames ? categorizeTools(availableToolNames) : []
const skills = availableSkills ?? []

View File

@@ -1,6 +1,7 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import {
createAgentToolRestrictions,
createAgentToolAllowlist,
migrateToolsToPermission,
migratePermissionToTools,
migrateAgentConfig,
@@ -57,6 +58,63 @@ describe("permission-compat", () => {
})
})
describe("createAgentToolAllowlist", () => {
test("returns wildcard deny with explicit allow for v1.1.1+", () => {
// #given version is 1.1.1
setVersionCache("1.1.1")
// #when creating allowlist
const result = createAgentToolAllowlist(["read"])
// #then returns wildcard deny with read allow
expect(result).toEqual({
permission: { "*": "deny", read: "allow" },
})
})
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
const result = createAgentToolAllowlist(["read", "glob"])
// #then returns wildcard deny with both allows
expect(result).toEqual({
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", () => {
test("converts boolean tools to permission values", () => {
// #given tools config

View File

@@ -30,6 +30,69 @@ export function createAgentToolRestrictions(
}
}
/**
* 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.
*/
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))
return {
tools: Object.fromEntries(denyTools.map((tool) => [tool, false])),
}
}
export function migrateToolsToPermission(
tools: Record<string, boolean>
): Record<string, PermissionValue> {