From 66a59db8fbeb2c9d6cd0838a852ae60d3b454224 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 8 Aug 2026 17:17:32 -0300 Subject: [PATCH 1/5] fix(skills): preserve scoped models across agent notifications --- src/services/api/openaiShim.test.ts | 108 ++++++++++++++++++ src/services/api/openaiShim.ts | 65 ++++++----- src/utils/attachments.ts | 2 + src/utils/handlePromptSubmit.ts | 8 ++ .../processUserInput/activeSkillScope.test.ts | 74 ++++++++++++ .../processUserInput/activeSkillScope.ts | 50 ++++++++ .../processUserInput/processSlashCommand.tsx | 3 +- 7 files changed, 276 insertions(+), 34 deletions(-) create mode 100644 src/utils/processUserInput/activeSkillScope.test.ts create mode 100644 src/utils/processUserInput/activeSkillScope.ts diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index b5767e381b..c7a280a1ce 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -2335,6 +2335,114 @@ test('preserves Gemini tool call extra_content from streaming chunks', async () }) }) +test('assembles function names split across streaming tool-call deltas', async () => { + globalThis.fetch = (async (_input, _init) => { + const chunks = makeStreamChunks([ + { + id: 'chatcmpl-split-tool-name', + object: 'chat.completion.chunk', + model: 'max/deepseek-v4-pro', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: 'function-call-split-name', + type: 'function', + function: { + name: 'Rea', + arguments: '', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-split-tool-name', + object: 'chat.completion.chunk', + model: 'max/deepseek-v4-pro', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { + name: 'd', + arguments: '{"file_path":"/tmp/example.ts"}', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-split-tool-name', + object: 'chat.completion.chunk', + model: 'max/deepseek-v4-pro', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'tool_calls', + }, + ], + }, + ]) + + return makeSseResponse(chunks) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages + .create({ + model: 'max/deepseek-v4-pro', + system: 'test system', + messages: [{ role: 'user', content: 'Read the example file' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const events: Array> = [] + for await (const event of result.data) { + events.push(event) + } + + const toolStarts = events.filter( + (event) => + event.type === 'content_block_start' && + typeof event.content_block === 'object' && + event.content_block !== null && + (event.content_block as Record).type === 'tool_use', + ) as Array<{ content_block: Record }> + const inputDeltas = events.filter( + (event) => + event.type === 'content_block_delta' && + typeof event.delta === 'object' && + event.delta !== null && + (event.delta as Record).type === 'input_json_delta', + ) as Array<{ delta: { partial_json: string } }> + + expect(toolStarts.at(-1)?.content_block).toMatchObject({ + type: 'tool_use', + id: 'function-call-split-name', + name: 'Read', + }) + expect(inputDeltas.map((event) => event.delta.partial_json).join('')).toBe( + '{"file_path":"/tmp/example.ts"}', + ) +}) + test('normalizes plain string Bash tool arguments from OpenAI-compatible responses', async () => { globalThis.fetch = (async (_input, _init) => { return new Response( diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index bacc8527b9..0b3f5d85da 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1169,6 +1169,7 @@ async function* openaiStreamToAnthropic( index: number jsonBuffer: string normalizeAtStop: boolean + nameWasContinued: boolean extra_content?: Record } >() @@ -1503,6 +1504,7 @@ async function* openaiStreamToAnthropic( index: toolBlockIndex, jsonBuffer: initialArguments, normalizeAtStop, + nameWasContinued: false, extra_content: initEC, }) @@ -1516,7 +1518,10 @@ async function* openaiStreamToAnthropic( input: {}, ...(initEC ? { extra_content: initEC } : {}), ...((initEC?.google as any)?.thought_signature - ? { signature: (initEC.google as any).thought_signature } + ? { + signature: (initEC?.google as any) + .thought_signature, + } : {}), }, } @@ -1533,12 +1538,23 @@ async function* openaiStreamToAnthropic( }, } } - } else if (tc.function?.arguments) { - // Continuation of existing tool call + } else { + // Continuation of an existing tool call. OpenAI-compatible + // providers may split function names across SSE deltas (for + // example `Rea` then `d`). Preserve both name and argument + // fragments instead of executing a truncated unknown tool. const active = activeToolCalls.get(tc.index) if (active) { - if (tc.function.arguments) { - active.jsonBuffer += tc.function.arguments + const functionNameFragment = tc.function?.name + const argumentFragment = tc.function?.arguments + + if (functionNameFragment) { + active.name += functionNameFragment + active.nameWasContinued = true + } + + if (argumentFragment) { + active.jsonBuffer += argumentFragment } // Also capture extra_content/thought_signature if bundled with args @@ -1556,7 +1572,7 @@ async function* openaiStreamToAnthropic( } } - if (active.normalizeAtStop) { + if (!argumentFragment || active.normalizeAtStop) { continue } @@ -1565,29 +1581,10 @@ async function* openaiStreamToAnthropic( index: active.index, delta: { type: 'input_json_delta', - partial_json: tc.function.arguments, + partial_json: argumentFragment, }, } } - } else { - // Chunk with only extra_content / thought_signature (Gemini thinking models - // may send thought_signature in a separate chunk from id/name/arguments) - const active = activeToolCalls.get(tc.index) - if (active) { - const lateSig = (tc as any).thought_signature as - string | undefined - const lateEC = tc.extra_content - ? { ...tc.extra_content } - : lateSig - ? { google: { thought_signature: lateSig } } - : undefined - if (lateEC) { - active.extra_content = { - ...(active.extra_content ?? {}), - ...lateEC, - } - } - } } } } @@ -1609,10 +1606,10 @@ async function* openaiStreamToAnthropic( } // Close active tool calls for (const [, tc] of activeToolCalls) { - // Re-emit content_block_start with final extra_content so that - // late-arriving thought_signature chunks (Gemini thinking models) - // are reflected in the stored message block before it is finalized. - if (tc.extra_content) { + // Re-emit content_block_start when late metadata or function-name + // fragments arrived, so the stored block has the final tool name + // and any provider-specific signature before it is finalized. + if (tc.extra_content || tc.nameWasContinued) { yield { type: 'content_block_start' as const, index: tc.index, @@ -1621,10 +1618,12 @@ async function* openaiStreamToAnthropic( id: tc.id, name: tc.name, input: {}, - extra_content: tc.extra_content, - ...((tc.extra_content.google as any)?.thought_signature + ...(tc.extra_content + ? { extra_content: tc.extra_content } + : {}), + ...((tc.extra_content?.google as any)?.thought_signature ? { - signature: (tc.extra_content.google as any) + signature: (tc.extra_content?.google as any) .thought_signature, } : {}), diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index e3c8362190..236ea181c4 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -64,6 +64,7 @@ import { } from 'src/types/textInputTypes.js' import { randomUUID, type UUID } from 'crypto' import { getSettings_DEPRECATED } from './settings/settings.js' +import type { EffortValue } from './effort.js' import { getSnippetForTwoFileDiff } from 'src/tools/FileEditTool/utils.js' import type { ContentBlockParam, @@ -606,6 +607,7 @@ export type Attachment = type: 'command_permissions' allowedTools: string[] model?: string + effort?: EffortValue } | AgentMentionAttachment | { diff --git a/src/utils/handlePromptSubmit.ts b/src/utils/handlePromptSubmit.ts index 461306a805..40f8e41c12 100644 --- a/src/utils/handlePromptSubmit.ts +++ b/src/utils/handlePromptSubmit.ts @@ -28,6 +28,7 @@ import { enqueue } from './messageQueueManager.js' import { resolveSkillModelOverride } from './model/model.js' import type { ProcessUserInputContext } from './processUserInput/processUserInput.js' import { processUserInput } from './processUserInput/processUserInput.js' +import { getActiveSkillScopeForQueuedContinuation } from './processUserInput/activeSkillScope.js' import type { QueryGuard } from './QueryGuard.js' import { queryCheckpoint, startQueryProfile } from './queryProfiler.js' import { runWithWorkload } from './workloadContext.js' @@ -553,6 +554,13 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise { const primaryCmd = commands[0] const primaryMode = primaryCmd?.mode ?? 'prompt' + const inheritedSkillScope = getActiveSkillScopeForQueuedContinuation( + messages, + primaryMode, + ) + allowedTools ??= inheritedSkillScope?.allowedTools + model ??= inheritedSkillScope?.model + effort ??= inheritedSkillScope?.effort const primaryInput = primaryCmd && typeof primaryCmd.value === 'string' ? primaryCmd.value diff --git a/src/utils/processUserInput/activeSkillScope.test.ts b/src/utils/processUserInput/activeSkillScope.test.ts new file mode 100644 index 0000000000..2aa1c349a7 --- /dev/null +++ b/src/utils/processUserInput/activeSkillScope.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'bun:test' +import { getActiveSkillScopeForQueuedContinuation } from './activeSkillScope.js' + +const scope = (overrides: Record = {}) => ({ + type: 'attachment', + attachment: { + type: 'command_permissions', + allowedTools: ['Read', 'Agent'], + model: 'gpt-5.6-sol', + effort: 'high', + ...overrides, + }, +}) + +describe('getActiveSkillScopeForQueuedContinuation', () => { + test('restores the latest skill scope for a task notification', () => { + expect( + getActiveSkillScopeForQueuedContinuation( + [ + { type: 'user', isMeta: false }, + scope(), + { type: 'assistant' }, + { + type: 'user', + origin: { kind: 'task-notification' }, + }, + ] as never, + 'task-notification', + ), + ).toEqual({ + allowedTools: ['Read', 'Agent'], + model: 'gpt-5.6-sol', + effort: 'high', + }) + }) + + test('uses the newest nested skill scope', () => { + expect( + getActiveSkillScopeForQueuedContinuation( + [ + scope(), + scope({ + allowedTools: ['Read', 'Bash'], + model: 'max/deepseek-v4-pro', + effort: 'medium', + }), + ] as never, + 'task-notification', + ), + ).toEqual({ + allowedTools: ['Read', 'Bash'], + model: 'max/deepseek-v4-pro', + effort: 'medium', + }) + }) + + test('does not apply a skill scope to an ordinary user turn', () => { + expect( + getActiveSkillScopeForQueuedContinuation( + [scope()] as never, + 'prompt', + ), + ).toBeNull() + }) + + test('does not revive a skill after a later visible user prompt', () => { + expect( + getActiveSkillScopeForQueuedContinuation( + [scope(), { type: 'user', isMeta: false }] as never, + 'task-notification', + ), + ).toBeNull() + }) +}) diff --git a/src/utils/processUserInput/activeSkillScope.ts b/src/utils/processUserInput/activeSkillScope.ts new file mode 100644 index 0000000000..241becd204 --- /dev/null +++ b/src/utils/processUserInput/activeSkillScope.ts @@ -0,0 +1,50 @@ +import type { Message } from '../../types/message.js' +import type { EffortValue } from '../effort.js' + +export type ActiveSkillScope = { + allowedTools: string[] + model?: string + effort?: EffortValue +} + +/** + * Background task notifications continue the workflow that launched the task. + * Recover the most recent inline skill scope so the notification turn keeps + * the skill's model, effort, and tool permissions instead of silently falling + * back to the session defaults. + * + * A later visible user prompt ends the scope. This prevents an old background + * task from reviving a skill after the user has moved on to another request. + */ +export function getActiveSkillScopeForQueuedContinuation( + messages: Message[], + mode: string | undefined, +): ActiveSkillScope | null { + if (mode !== 'task-notification') return null + + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] + if (!message) continue + + if ( + message.type === 'attachment' && + message.attachment?.type === 'command_permissions' + ) { + return { + allowedTools: message.attachment.allowedTools ?? [], + model: message.attachment.model, + effort: message.attachment.effort, + } + } + + if ( + message.type === 'user' && + !message.isMeta && + message.origin?.kind !== 'task-notification' + ) { + return null + } + } + + return null +} diff --git a/src/utils/processUserInput/processSlashCommand.tsx b/src/utils/processUserInput/processSlashCommand.tsx index a325213cc3..d347b3d749 100644 --- a/src/utils/processUserInput/processSlashCommand.tsx +++ b/src/utils/processUserInput/processSlashCommand.tsx @@ -907,7 +907,8 @@ async function getMessagesForPromptSlashCommand(command: CommandBase & PromptCom }), ...attachmentMessages, createAttachmentMessage({ type: 'command_permissions', allowedTools: additionalAllowedTools, - model: command.model + model: command.model, + effort: command.effort })]; return { messages, From 7523ea3918b70b7ce474c3c73889bcae934a8ab7 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 8 Aug 2026 17:32:17 -0300 Subject: [PATCH 2/5] fix(tools): recover unambiguous truncated tool names --- src/Tool.test.ts | 31 +++++++++++++++++++++ src/Tool.ts | 19 +++++++++++++ src/services/tools/StreamingToolExecutor.ts | 11 ++++++-- src/services/tools/toolExecution.ts | 12 +++++++- 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src/Tool.test.ts diff --git a/src/Tool.test.ts b/src/Tool.test.ts new file mode 100644 index 0000000000..f772d701e1 --- /dev/null +++ b/src/Tool.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' + +import { findToolByNameOrUniquePrefix, type Tools } from './Tool.js' + +const tools = [ + { name: 'Read' }, + { name: 'Bash' }, + { name: 'Grep' }, +] as Tools + +describe('findToolByNameOrUniquePrefix', () => { + test('recovers an unambiguous provider-truncated built-in tool name', () => { + expect(findToolByNameOrUniquePrefix(tools, 'Rea')?.name).toBe('Read') + }) + + test('does not guess short, ambiguous, or MCP tool names', () => { + expect(findToolByNameOrUniquePrefix(tools, 'R')).toBeUndefined() + expect( + findToolByNameOrUniquePrefix( + [{ name: 'Read' }, { name: 'Ready' }] as Tools, + 'Rea', + ), + ).toBeUndefined() + expect( + findToolByNameOrUniquePrefix( + [{ name: 'mcp__files__read' }] as Tools, + 'mcp__files__rea', + ), + ).toBeUndefined() + }) +}) diff --git a/src/Tool.ts b/src/Tool.ts index e3d36405ea..a94ad6045d 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -374,6 +374,25 @@ export function findToolByName(tools: Tools, name: string): Tool | undefined { return tools.find(t => toolMatchesName(t, name)) } +/** + * Resolves a provider-truncated built-in tool name only when the prefix is + * unambiguous among tools already available to the model. This keeps malformed + * calls such as `Rea` recoverable as `Read` without guessing MCP tool names or + * accepting very short, collision-prone prefixes. + */ +export function findToolByNameOrUniquePrefix( + tools: Tools, + name: string, +): Tool | undefined { + const exactMatch = findToolByName(tools, name) + if (exactMatch) return exactMatch + + if (name.length < 3 || name.startsWith('mcp__')) return undefined + + const prefixMatches = tools.filter(tool => tool.name.startsWith(name)) + return prefixMatches.length === 1 ? prefixMatches[0] : undefined +} + export type Tool< Input extends AnyObject = AnyObject, Output = unknown, diff --git a/src/services/tools/StreamingToolExecutor.ts b/src/services/tools/StreamingToolExecutor.ts index 2f698b43a6..b4eeeba59d 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -5,7 +5,11 @@ import { withMemoryCorrectionHint, } from 'src/utils/messages.js' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' -import { findToolByName, type Tools, type ToolUseContext } from '../../Tool.js' +import { + findToolByNameOrUniquePrefix, + type Tools, + type ToolUseContext, +} from '../../Tool.js' import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' import type { AssistantMessage, Message } from '../../types/message.js' import { createChildAbortController } from '../../utils/abortController.js' @@ -80,7 +84,10 @@ export class StreamingToolExecutor { * Add a tool to the execution queue. Will start executing immediately if conditions allow. */ addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void { - const toolDefinition = findToolByName(this.toolDefinitions, block.name) + const toolDefinition = findToolByNameOrUniquePrefix( + this.toolDefinitions, + block.name, + ) if (!toolDefinition) { this.tools.push({ id: block.id, diff --git a/src/services/tools/toolExecution.ts b/src/services/tools/toolExecution.ts index 27acd1ec16..a833a59679 100644 --- a/src/services/tools/toolExecution.ts +++ b/src/services/tools/toolExecution.ts @@ -29,6 +29,7 @@ import { import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import { findToolByName, + findToolByNameOrUniquePrefix, type Tool, type ToolProgress, type ToolProgressData, @@ -388,7 +389,16 @@ export async function* runToolUse( ): AsyncGenerator { const toolName = toolUse.name // First try to find in the available tools (what the model sees) - let tool = findToolByName(toolUseContext.options.tools, toolName) + let tool = findToolByNameOrUniquePrefix( + toolUseContext.options.tools, + toolName, + ) + + if (tool && tool.name !== toolName) { + logForDebugging( + `Recovered truncated tool name ${toolName} as ${tool.name}: ${toolUse.id}`, + ) + } // If not found, check if it's a deprecated tool being called by alias // (e.g., old transcripts calling "KillShell" which is now an alias for "TaskStop") From 695439c36ca3db92d6583f70b07d9f2b76bdbcc9 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 8 Aug 2026 17:39:57 -0300 Subject: [PATCH 3/5] fix(tools): recover truncated names after filtering --- src/services/tools/StreamingToolExecutor.ts | 15 ++++++++++++++- src/services/tools/toolExecution.ts | 20 +++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/services/tools/StreamingToolExecutor.ts b/src/services/tools/StreamingToolExecutor.ts index b4eeeba59d..a570934921 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -10,6 +10,7 @@ import { type Tools, type ToolUseContext, } from '../../Tool.js' +import { getAllBaseTools } from '../../tools.js' import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' import type { AssistantMessage, Message } from '../../types/message.js' import { createChildAbortController } from '../../utils/abortController.js' @@ -84,10 +85,22 @@ export class StreamingToolExecutor { * Add a tool to the execution queue. Will start executing immediately if conditions allow. */ addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void { - const toolDefinition = findToolByNameOrUniquePrefix( + let toolDefinition = findToolByNameOrUniquePrefix( this.toolDefinitions, block.name, ) + if (!toolDefinition) { + const baseTool = findToolByNameOrUniquePrefix( + getAllBaseTools(), + block.name, + ) + if (baseTool && !baseTool.isMcp && baseTool.name !== block.name) { + toolDefinition = baseTool + logForDebugging( + `Recovered truncated base tool name ${block.name} as ${baseTool.name}: ${block.id}`, + ) + } + } if (!toolDefinition) { this.tools.push({ id: block.id, diff --git a/src/services/tools/toolExecution.ts b/src/services/tools/toolExecution.ts index a833a59679..9f96a05236 100644 --- a/src/services/tools/toolExecution.ts +++ b/src/services/tools/toolExecution.ts @@ -404,10 +404,24 @@ export async function* runToolUse( // (e.g., old transcripts calling "KillShell" which is now an alias for "TaskStop") // Only fall back for tools where the name matches an alias, not the primary name if (!tool) { - const fallbackTool = findToolByName(getAllBaseTools(), toolName) - // Only use fallback if the tool was found via alias (deprecated name) - if (fallbackTool && fallbackTool.aliases?.includes(toolName)) { + const fallbackTool = findToolByNameOrUniquePrefix( + getAllBaseTools(), + toolName, + ) + // A model can only call a tool it received in its schema. Recover an exact + // alias or an unambiguous truncated built-in name when filtering removed + // the original definition from this execution path. + if ( + fallbackTool && + (fallbackTool.aliases?.includes(toolName) || + (!fallbackTool.isMcp && fallbackTool.name !== toolName)) + ) { tool = fallbackTool + if (tool.name !== toolName) { + logForDebugging( + `Recovered truncated base tool name ${toolName} as ${tool.name}: ${toolUse.id}`, + ) + } } } const messageId = assistantMessage.message.id From 3a79b8eaf11a80d6e11837ce9be321951fa05995 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 8 Aug 2026 17:58:13 -0300 Subject: [PATCH 4/5] fix(permissions): allow project planning artifacts --- src/utils/openclaudeUiSurfaces.test.ts | 25 +++++++++++++++ src/utils/permissions/filesystem.ts | 44 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/utils/openclaudeUiSurfaces.test.ts b/src/utils/openclaudeUiSurfaces.test.ts index ade201526f..35cfb65fff 100644 --- a/src/utils/openclaudeUiSurfaces.test.ts +++ b/src/utils/openclaudeUiSurfaces.test.ts @@ -9,6 +9,7 @@ import { import { isInGlobalClaudeFolder } from '../components/permissions/FilePermissionDialog/permissionOptions.tsx' import { optionForPermissionSaveDestination } from '../components/permissions/rules/AddPermissionRules.tsx' import { + checkEditableInternalPath, getClaudeSkillScope, isClaudeSettingsPath, } from './permissions/filesystem.ts' @@ -33,6 +34,30 @@ afterEach(() => { }) describe('Verboo settings path surfaces', () => { + test('allows project planning artifacts without unlocking Verboo configuration', () => { + const input = { file_path: '' } + + expect( + checkEditableInternalPath( + join( + process.cwd(), + '.verboo', + 'plans', + 'ui-unit-tests', + 'requirements.md', + ), + input, + ), + ).toMatchObject({ behavior: 'allow', updatedInput: input }) + + expect( + checkEditableInternalPath( + join(process.cwd(), '.verboo', 'settings.local.json'), + input, + ), + ).toMatchObject({ behavior: 'passthrough' }) + }) + test('isClaudeSettingsPath recognizes project .verboo settings files', () => { expect( isClaudeSettingsPath( diff --git a/src/utils/permissions/filesystem.ts b/src/utils/permissions/filesystem.ts index 57b0743911..a56fe92645 100644 --- a/src/utils/permissions/filesystem.ts +++ b/src/utils/permissions/filesystem.ts @@ -268,6 +268,34 @@ function isSessionPlanFile(absolutePath: string): boolean { ) } +/** + * Project-local planning bundles are generated artifacts, not harness + * configuration. Keep this carve-out deliberately narrower than .verboo/**: + * settings, skills, agents, hooks, and every other config path still pass + * through the normal protected-directory checks. + * + * Check every lexical and resolved form of the target. This prevents a + * symlink placed under .verboo/plans from being used to write outside the + * plans directory. + */ +function isProjectPlanArtifactPath(absolutePath: string): boolean { + const planRoot = join(getOriginalCwd(), '.verboo', 'plans') + const planRootForms = getPathsForPermissionCheck(planRoot).map(normalize) + const targetForms = getPathsForPermissionCheck(absolutePath).map(normalize) + + return ( + planRootForms.length > 0 && + targetForms.length > 0 && + targetForms.every(targetPath => + planRootForms.some( + planRootPath => + targetPath !== planRootPath && + targetPath.startsWith(planRootPath + sep), + ), + ) + ) +} + /** * Returns the session memory directory path for the current session with trailing separator. * Path format: {projectDir}/{sessionId}/session-memory/ @@ -1514,6 +1542,22 @@ export function checkEditableInternalPath( } } + // Skills commonly persist requirement, research, and execution bundles in + // the project's .verboo/plans directory. Unlike the rest of .verboo, these + // files are data artifacts and are not loaded as executable configuration. + // Allow them before the .verboo safety guard so bypass mode actually remains + // non-interactive for the supported planning workflow. + if (isProjectPlanArtifactPath(normalizedPath)) { + return { + behavior: 'allow', + updatedInput: input, + decisionReason: { + type: 'other', + reason: 'Project planning artifacts are allowed for writing', + }, + } + } + // Scratchpad directory for current session if (isScratchpadPath(normalizedPath)) { return { From 30ac9569c0acb60d87a45964472e7be8855a6d4f Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 8 Aug 2026 18:03:38 -0300 Subject: [PATCH 5/5] Revert "fix(permissions): allow project planning artifacts" This reverts commit 3a79b8eaf11a80d6e11837ce9be321951fa05995. --- src/utils/openclaudeUiSurfaces.test.ts | 25 --------------- src/utils/permissions/filesystem.ts | 44 -------------------------- 2 files changed, 69 deletions(-) diff --git a/src/utils/openclaudeUiSurfaces.test.ts b/src/utils/openclaudeUiSurfaces.test.ts index 35cfb65fff..ade201526f 100644 --- a/src/utils/openclaudeUiSurfaces.test.ts +++ b/src/utils/openclaudeUiSurfaces.test.ts @@ -9,7 +9,6 @@ import { import { isInGlobalClaudeFolder } from '../components/permissions/FilePermissionDialog/permissionOptions.tsx' import { optionForPermissionSaveDestination } from '../components/permissions/rules/AddPermissionRules.tsx' import { - checkEditableInternalPath, getClaudeSkillScope, isClaudeSettingsPath, } from './permissions/filesystem.ts' @@ -34,30 +33,6 @@ afterEach(() => { }) describe('Verboo settings path surfaces', () => { - test('allows project planning artifacts without unlocking Verboo configuration', () => { - const input = { file_path: '' } - - expect( - checkEditableInternalPath( - join( - process.cwd(), - '.verboo', - 'plans', - 'ui-unit-tests', - 'requirements.md', - ), - input, - ), - ).toMatchObject({ behavior: 'allow', updatedInput: input }) - - expect( - checkEditableInternalPath( - join(process.cwd(), '.verboo', 'settings.local.json'), - input, - ), - ).toMatchObject({ behavior: 'passthrough' }) - }) - test('isClaudeSettingsPath recognizes project .verboo settings files', () => { expect( isClaudeSettingsPath( diff --git a/src/utils/permissions/filesystem.ts b/src/utils/permissions/filesystem.ts index a56fe92645..57b0743911 100644 --- a/src/utils/permissions/filesystem.ts +++ b/src/utils/permissions/filesystem.ts @@ -268,34 +268,6 @@ function isSessionPlanFile(absolutePath: string): boolean { ) } -/** - * Project-local planning bundles are generated artifacts, not harness - * configuration. Keep this carve-out deliberately narrower than .verboo/**: - * settings, skills, agents, hooks, and every other config path still pass - * through the normal protected-directory checks. - * - * Check every lexical and resolved form of the target. This prevents a - * symlink placed under .verboo/plans from being used to write outside the - * plans directory. - */ -function isProjectPlanArtifactPath(absolutePath: string): boolean { - const planRoot = join(getOriginalCwd(), '.verboo', 'plans') - const planRootForms = getPathsForPermissionCheck(planRoot).map(normalize) - const targetForms = getPathsForPermissionCheck(absolutePath).map(normalize) - - return ( - planRootForms.length > 0 && - targetForms.length > 0 && - targetForms.every(targetPath => - planRootForms.some( - planRootPath => - targetPath !== planRootPath && - targetPath.startsWith(planRootPath + sep), - ), - ) - ) -} - /** * Returns the session memory directory path for the current session with trailing separator. * Path format: {projectDir}/{sessionId}/session-memory/ @@ -1542,22 +1514,6 @@ export function checkEditableInternalPath( } } - // Skills commonly persist requirement, research, and execution bundles in - // the project's .verboo/plans directory. Unlike the rest of .verboo, these - // files are data artifacts and are not loaded as executable configuration. - // Allow them before the .verboo safety guard so bypass mode actually remains - // non-interactive for the supported planning workflow. - if (isProjectPlanArtifactPath(normalizedPath)) { - return { - behavior: 'allow', - updatedInput: input, - decisionReason: { - type: 'other', - reason: 'Project planning artifacts are allowed for writing', - }, - } - } - // Scratchpad directory for current session if (isScratchpadPath(normalizedPath)) { return {