diff --git a/package.json b/package.json index a40b4796c3..5dc5e7b0c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-forge", - "version": "0.2.3", + "version": "0.2.4", "type": "module", "oc-plugin": [ "server", diff --git a/src/agents/index.ts b/src/agents/index.ts index 7bbf23cace..0228d5a93c 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -9,4 +9,15 @@ export const agents: Record = { auditor: auditorAgent, } +/** + * Returns the list of tools that the given agent role is configured to exclude. + * + * Callers use this to append the exclusions as deny rules when constructing a + * loop session's permission ruleset, so the agent cannot invoke those tools + * regardless of the allow-all worktree rule. + */ +export function getAgentExcludedTools(role: AgentRole = 'code'): string[] { + return agents[role]?.tools?.exclude ?? [] +} + export { type AgentRole, type AgentDefinition, type AgentConfig } from './types' diff --git a/src/cli/commands/restart.ts b/src/cli/commands/restart.ts index 4bf82dc774..bdcbe7102f 100644 --- a/src/cli/commands/restart.ts +++ b/src/cli/commands/restart.ts @@ -1,5 +1,6 @@ import type { LoopState } from '../../services/loop' import { buildLoopPermissionRuleset } from '../../constants/loop' +import { getAgentExcludedTools } from '../../agents' import { openDatabase, confirm, @@ -109,10 +110,13 @@ export async function run(argv: RestartArgs): Promise { } } - // Worktree sessions no longer need log directory access since logging is dispatched via host session + // Worktree sessions no longer need log directory access since logging is dispatched via host session. + // Forward the agent's excluded tools as deny rules so a CLI-restarted loop retains the same + // tool exclusions as a freshly launched loop. const config = loadPluginConfig() const permissionRuleset = buildLoopPermissionRuleset(config, null, { isWorktree: !!state.worktree, + excludedTools: getAgentExcludedTools('code'), }) console.log(`restart: creating session with directory=${sessionDir} (sandbox: ${!!state.sandbox})`) diff --git a/src/constants/loop.ts b/src/constants/loop.ts index eed73413d9..7986fdc0ce 100644 --- a/src/constants/loop.ts +++ b/src/constants/loop.ts @@ -10,19 +10,23 @@ type PermissionRule = { permission: string; pattern: string; action: 'allow' | ' * - Adds external_directory allow rule for worktree logging when configured AND needed. * Note: With host-session dispatch, worktree sessions no longer need direct host log access. * This parameter is kept for backward compatibility but should be null for new designs. + * - Agent tool exclusions are appended as deny rules at the END to ensure they take precedence. * * Note on external_directory evaluation: The blanket `*:*:allow` for worktree loops * covers the session's own cwd. The `external_directory:*:deny` rule only blocks * paths outside the worktree. Audit performed: sandbox worktree loops launch * without permission prompts for their own cwd because the container-mapped * directory falls within the worktree scope that the blanket allow covers. + * + * @param excludedTools - List of tool names to exclude (from agent definition). These are appended as deny rules last. */ export function buildLoopPermissionRuleset( config: PluginConfig, logDirectory?: string | null, - options?: { isWorktree?: boolean }, + options?: { isWorktree?: boolean; excludedTools?: string[] }, ): PermissionRule[] { const isWorktree = options?.isWorktree ?? true + const excludedTools = options?.excludedTools ?? [] const rules: PermissionRule[] = [] if (isWorktree) { @@ -50,5 +54,11 @@ export function buildLoopPermissionRuleset( { permission: 'loop-status', pattern: '*', action: 'deny' }, ) + // Append agent tool exclusions as deny rules at the END + // This ensures they take precedence due to findLast evaluation in opencode + for (const tool of excludedTools) { + rules.push({ permission: tool, pattern: '*', action: 'deny' }) + } + return rules } diff --git a/src/hooks/loop.ts b/src/hooks/loop.ts index 375adccf38..56593ad1e0 100644 --- a/src/hooks/loop.ts +++ b/src/hooks/loop.ts @@ -11,6 +11,7 @@ import { buildWorktreeCompletionPayload, writeWorktreeCompletionLog } from '../s import { buildLoopPermissionRuleset } from '../constants/loop' import { createLoopSessionWithWorkspace, publishWorkspaceDetachedToast } from '../utils/loop-session' import { cleanupLoopWorktree } from '../utils/worktree-cleanup' +import { getAgentExcludedTools } from '../agents' export interface LoopEventHandler { onEvent(input: { event: { type: string; properties?: Record } }): Promise @@ -411,9 +412,12 @@ export function createLoopEventHandler( const sessionDir = state.worktreeDir // Worktree sessions no longer need log directory access since logging is dispatched via host session - // Only resolve log target for non-worktree sessions + // Only resolve log target for non-worktree sessions. + // Forward the agent's excluded tools as deny rules so they remain enforced across every + // iteration's rotated session, not just iteration 1. const permissionRuleset = buildLoopPermissionRuleset(getConfig(), null, { isWorktree: !!state.worktree, + excludedTools: getAgentExcludedTools('code'), }) const createResult = await createLoopSessionWithWorkspace({ diff --git a/src/hooks/permission-ask.ts b/src/hooks/permission-ask.ts index c93243c4ab..c328aeb396 100644 --- a/src/hooks/permission-ask.ts +++ b/src/hooks/permission-ask.ts @@ -3,7 +3,7 @@ import type { Logger } from '../types' export interface PermissionAskDeps { resolver: { - resolveActiveLoopForSession(sessionId: string): Promise<{ loopName: string; active: boolean; sandbox?: boolean } | null> + resolveActiveLoopForSession(sessionId: string): Promise<{ loopName: string; active: boolean; sandbox?: boolean; worktreeDir?: string } | null> } logger: Logger } @@ -19,8 +19,11 @@ export function createPermissionAskHandler(deps: PermissionAskDeps) { return } - if (!state.sandbox) { - deps.logger.log(`[permission.ask] loop=${state.loopName} is not a sandbox loop — falling through to host default`) + // Only apply permission checks to worktree loops (sandbox or non-sandbox worktrees) + // In-place loops fall through to host default permissions + const isWorktree = !!state.worktreeDir + if (!isWorktree) { + deps.logger.log(`[permission.ask] loop=${state.loopName} is not a worktree loop — falling through to host default`) return } diff --git a/src/services/loop.ts b/src/services/loop.ts index 923f661e92..ba603d3512 100644 --- a/src/services/loop.ts +++ b/src/services/loop.ts @@ -244,7 +244,7 @@ export function createLoopService( `- ${finding.file}:${finding.line}`, ` - Severity: ${finding.severity}`, ` - Description: ${finding.description}`, - ` - Scenario: ${finding.scenario}`, + ` - Scenario: ${finding.scenario || 'N/A'}`, ].join('\n') }).join('\n\n') } diff --git a/src/storage/database.ts b/src/storage/database.ts index 3de0efa8c6..8294130855 100644 --- a/src/storage/database.ts +++ b/src/storage/database.ts @@ -64,6 +64,13 @@ interface ForgeDatabaseOptions { const DEFAULT_COMPLETED_LOOP_TTL_MS = 7 * 24 * 60 * 60 * 1000 +type CachedDb = { db: Database; refCount: number } +const dbCache = new Map() + +function cacheKey(dbPath: string, options?: ForgeDatabaseOptions): string { + return `${dbPath}::${options?.completedLoopTtlMs ?? DEFAULT_COMPLETED_LOOP_TTL_MS}` +} + export function openForgeDatabase(dbPath: string, options?: ForgeDatabaseOptions): Database { const db = openSqliteWithIntegrityGuard(dbPath, { label: 'Forge database', @@ -84,10 +91,26 @@ export function initializeDatabase(dataDir: string, options?: ForgeDatabaseOptio } const dbPath = `${dataDir}/graph.db` - - return openForgeDatabase(dbPath, options) + const key = cacheKey(dbPath, options) + const cached = dbCache.get(key) + if (cached) { + cached.refCount += 1 + return cached.db + } + const db = openForgeDatabase(dbPath, options) + dbCache.set(key, { db, refCount: 1 }) + return db } export function closeDatabase(db: Database): void { + for (const [key, entry] of dbCache) { + if (entry.db !== db) continue + entry.refCount -= 1 + if (entry.refCount <= 0) { + dbCache.delete(key) + db.close() + } + return + } db.close() } diff --git a/src/storage/migrations/index.ts b/src/storage/migrations/index.ts index b6929da35e..67923ffa4a 100644 --- a/src/storage/migrations/index.ts +++ b/src/storage/migrations/index.ts @@ -96,4 +96,11 @@ export const migrations: Migration[] = [ db.run(loadSql('110_drop_completion_signal_from_loops.sql')) }, }, + { + id: '111', + description: 'Make scenario column nullable in review_findings table', + apply: (db: Database) => { + db.run(loadSql('111_make_scenario_nullable.sql')) + }, + }, ] diff --git a/src/tools/loop.ts b/src/tools/loop.ts index 6c39b4a7b4..32d015b9e7 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -17,6 +17,7 @@ import { waitForGraphReady } from '../utils/tui-graph-status' import { createLoopWorkspace } from '../workspace/forge-worktree' import { createLoopSessionWithWorkspace, publishWorkspaceDetachedToast } from '../utils/loop-session' import { cleanupLoopWorktree } from '../utils/worktree-cleanup' +import { getAgentExcludedTools } from '../agents' const z = tool.schema @@ -66,8 +67,10 @@ export async function setupLoop( sandbox: false, dataDir: ctx.dataDir, }, ctx.logger) + // Get the agent's excluded tools to enforce as permanent denials const permissionRuleset = buildLoopPermissionRuleset(config, logTarget?.permissionPath ?? null, { isWorktree: false, + excludedTools: getAgentExcludedTools('code'), }) let currentBranch: string | undefined @@ -152,8 +155,10 @@ export async function setupLoop( // Worktree sessions no longer need log directory access since logging is dispatched via host session // Only resolve log target for non-worktree sessions or if needed for other purposes + // Get the agent's excluded tools to enforce as permanent denials const permissionRuleset = buildLoopPermissionRuleset(config, null, { isWorktree: true, + excludedTools: getAgentExcludedTools('code'), }) logger.log(`loop: creating session with directory=${sessionDirectory} (host: ${hostWorktreeDir}, sandbox: ${sandboxEnabled})`) @@ -508,8 +513,10 @@ export function createLoopTools(ctx: ToolContext): Record { - test('sandbox loop + non-push pattern → output.status === "allow"', async () => { + test('worktree loop (sandbox) + non-push pattern → output.status === "allow"', async () => { const resolver = { - resolveActiveLoopForSession: async (_sessionId: string) => ({ loopName: 'test-loop', active: true, sandbox: true }), + resolveActiveLoopForSession: async (_sessionId: string) => ({ loopName: 'test-loop', active: true, sandbox: true, worktreeDir: '/tmp/test' }), } const logs: string[] = [] @@ -29,9 +29,9 @@ describe('createPermissionAskHandler', () => { expect(logs.some((l) => l.includes('allow'))).toBe(true) }) - test('sandbox loop + git push origin main → output.status === "deny"', async () => { + test('worktree loop (sandbox) + git push origin main → output.status === "deny"', async () => { const resolver = { - resolveActiveLoopForSession: async (_sessionId: string) => ({ loopName: 'test-loop', active: true, sandbox: true }), + resolveActiveLoopForSession: async (_sessionId: string) => ({ loopName: 'test-loop', active: true, sandbox: true, worktreeDir: '/tmp/test' }), } const logs: string[] = [] @@ -49,9 +49,9 @@ describe('createPermissionAskHandler', () => { expect(logs.some((l) => l.includes('deny'))).toBe(true) }) - test('sandbox loop + mixed patterns including git push → deny', async () => { + test('worktree loop (sandbox) + mixed patterns including git push → deny', async () => { const resolver = { - resolveActiveLoopForSession: async (_sessionId: string) => ({ loopName: 'test-loop', active: true, sandbox: true }), + resolveActiveLoopForSession: async (_sessionId: string) => ({ loopName: 'test-loop', active: true, sandbox: true, worktreeDir: '/tmp/test' }), } const logs: string[] = [] @@ -68,7 +68,47 @@ describe('createPermissionAskHandler', () => { expect(output.status).toBe('deny') }) - test('non-sandbox loop → falls through to host default (output.status unchanged)', async () => { + test('worktree loop (non-sandbox) + non-push pattern → output.status === "allow"', async () => { + const resolver = { + resolveActiveLoopForSession: async (_sessionId: string) => ({ loopName: 'test-loop', active: true, sandbox: false, worktreeDir: '/tmp/test' }), + } + + const logs: string[] = [] + const logger = { ...mockLogger, log: (msg: string) => logs.push(msg) } + + const handler = createPermissionAskHandler({ resolver: resolver as any, logger }) + const output: { status?: 'allow' | 'deny' | 'ask' } = {} + + await handler( + { sessionID: 'session-worktree', type: 'bash', pattern: 'ls *', id: '1', messageID: '1', title: 'test', metadata: {}, time: { created: 0 } } as Permission, + output, + ) + + expect(output.status).toBe('allow') + expect(logs.some((l) => l.includes('allow'))).toBe(true) + }) + + test('worktree loop (non-sandbox) + git push → deny', async () => { + const resolver = { + resolveActiveLoopForSession: async (_sessionId: string) => ({ loopName: 'test-loop', active: true, sandbox: false, worktreeDir: '/tmp/test' }), + } + + const logs: string[] = [] + const logger = { ...mockLogger, log: (msg: string) => logs.push(msg) } + + const handler = createPermissionAskHandler({ resolver: resolver as any, logger }) + const output: { status?: 'allow' | 'deny' | 'ask' } = {} + + await handler( + { sessionID: 'session-worktree', type: 'bash', pattern: 'git push origin main', id: '1', messageID: '1', title: 'test', metadata: {}, time: { created: 0 } } as Permission, + output, + ) + + expect(output.status).toBe('deny') + expect(logs.some((l) => l.includes('deny'))).toBe(true) + }) + + test('in-place loop (non-worktree) → falls through to host default (output.status unchanged)', async () => { const resolver = { resolveActiveLoopForSession: async (_sessionId: string) => ({ loopName: 'test-loop', active: true, sandbox: false }), } @@ -85,7 +125,7 @@ describe('createPermissionAskHandler', () => { ) expect(output.status).toBeUndefined() - expect(logs.some((l) => l.includes('not a sandbox loop'))).toBe(true) + expect(logs.some((l) => l.includes('not a worktree loop'))).toBe(true) }) test('unresolved session → output.status unchanged', async () => {