diff --git a/package.json b/package.json index a40b4796..5dc5e7b0 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 7bbf23ca..0228d5a9 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 4bf82dc7..bdcbe710 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 eed73413..7986fdc0 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 375adccf..56593ad1 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/services/loop.ts b/src/services/loop.ts index 923f661e..ba603d35 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 3de0efa8..82941308 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 b6929da3..67923ffa 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 6c39b4a7..32d015b9 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