Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-forge",
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"oc-plugin": [
"server",
Expand Down
11 changes: 11 additions & 0 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,15 @@ export const agents: Record<AgentRole, AgentDefinition> = {
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'
6 changes: 5 additions & 1 deletion src/cli/commands/restart.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { LoopState } from '../../services/loop'
import { buildLoopPermissionRuleset } from '../../constants/loop'
import { getAgentExcludedTools } from '../../agents'
import {
openDatabase,
confirm,
Expand Down Expand Up @@ -109,10 +110,13 @@ export async function run(argv: RestartArgs): Promise<void> {
}
}

// 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})`)
Expand Down
12 changes: 11 additions & 1 deletion src/constants/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
6 changes: 5 additions & 1 deletion src/hooks/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> } }): Promise<void>
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion src/services/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down
27 changes: 25 additions & 2 deletions src/storage/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CachedDb>()

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',
Expand All @@ -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()
}
7 changes: 7 additions & 0 deletions src/storage/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
},
},
]
7 changes: 7 additions & 0 deletions src/tools/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})`)
Expand Down Expand Up @@ -508,8 +513,10 @@ export function createLoopTools(ctx: ToolContext): Record<string, ReturnType<typ
}

// Worktree sessions no longer need log directory access since logging is dispatched via host session
// Get the agent's excluded tools to enforce as permanent denials
const permissionRuleset = buildLoopPermissionRuleset(config, null, {
isWorktree: !!stoppedState.worktree,
excludedTools: getAgentExcludedTools('code'),
})

const restartSandbox = isSandboxEnabled(config, ctx.sandboxManager)
Expand Down
2 changes: 2 additions & 0 deletions src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { getGitProjectId } from './utils/project-id'
import { formatDuration, formatTokens, truncate, truncateMiddle } from './utils/format'

import { buildLoopPermissionRuleset } from './constants/loop'
import { getAgentExcludedTools } from './agents'
import { createLoopSessionWithWorkspace } from './utils/loop-session'

type TuiKeybinds = {
Expand Down Expand Up @@ -158,6 +159,7 @@ async function restartLoop(projectId: string, loopName: string, api: TuiPluginAp
const config = loadPluginConfig()
const permissionRuleset = buildLoopPermissionRuleset(config, null, {
isWorktree: !!row.worktree,
excludedTools: getAgentExcludedTools('code'),
})

// Resolve the workspace ID for worktree loops.
Expand Down
5 changes: 5 additions & 0 deletions src/utils/loop-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createLoopsRepo } from '../storage/repos/loops-repo'
import type { LoopRow, LoopLargeFields } from '../storage/repos/loops-repo'
import { createLoopWorkspace } from '../workspace/forge-worktree'
import { createLoopSessionWithWorkspace } from './loop-session'
import { getAgentExcludedTools } from '../agents'

interface FreshLoopOptions {
planText: string
Expand Down Expand Up @@ -142,8 +143,10 @@ export async function launchFreshLoop(options: FreshLoopOptions): Promise<Launch
}

// Worktree sessions no longer need log directory access since logging is dispatched via host session
// Get the agent's excluded tools to enforce as permanent denials
const permissionRuleset = buildLoopPermissionRuleset(config, null, {
isWorktree: true,
excludedTools: getAgentExcludedTools('code'),
})

console.log(`loop-launch: creating session with directory=${hostWorktreeDir} (sandbox: ${isSandboxEnabled})`)
Expand Down Expand Up @@ -176,8 +179,10 @@ export async function launchFreshLoop(options: FreshLoopOptions): Promise<Launch
sandbox: isSandboxEnabled,
dataDir,
})
// Get the agent's excluded tools to enforce as permanent denials
const permissionRuleset = buildLoopPermissionRuleset(config, logTarget?.permissionPath ?? null, {
isWorktree: false,
excludedTools: getAgentExcludedTools('code'),
})

const createResult = await api.client.session.create({
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = '0.2.3'
export const VERSION = '0.2.4'