From de439edc22e13ff976a65df234851855b03111d6 Mon Sep 17 00:00:00 2001 From: justsisyphus Date: Tue, 27 Jan 2026 11:03:44 +0900 Subject: [PATCH] feat(subagent): block question tool at both SDK and hook level - Add permission: [{ permission: 'question', action: 'deny' }] to session.create() in background-agent and delegate-task for SDK-level blocking - Add subagent-question-blocker hook as backup layer to intercept question tool calls in tool.execute.before event - Ensures subagents cannot ask questions to users and must work autonomously --- src/features/background-agent/manager.ts | 5 +- src/hooks/index.ts | 1 + .../subagent-question-blocker/index.test.ts | 82 +++++++++++++++++++ src/hooks/subagent-question-blocker/index.ts | 29 +++++++ src/index.ts | 3 + src/tools/delegate-task/tools.ts | 5 +- 6 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/hooks/subagent-question-blocker/index.test.ts create mode 100644 src/hooks/subagent-question-blocker/index.ts diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index d7bf04791..9ff7f254a 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -224,7 +224,10 @@ export class BackgroundManager { body: { parentID: input.parentSessionID, title: `Background: ${input.description}`, - }, + permission: [ + { permission: "question", action: "deny" as const, pattern: "*" }, + ], + } as any, query: { directory: parentDirectory, }, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 5b1ce2104..7206def38 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -33,3 +33,4 @@ export { createStartWorkHook } from "./start-work"; export { createAtlasHook } from "./atlas"; export { createDelegateTaskRetryHook } from "./delegate-task-retry"; export { createQuestionLabelTruncatorHook } from "./question-label-truncator"; +export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker"; diff --git a/src/hooks/subagent-question-blocker/index.test.ts b/src/hooks/subagent-question-blocker/index.test.ts new file mode 100644 index 000000000..3a769141a --- /dev/null +++ b/src/hooks/subagent-question-blocker/index.test.ts @@ -0,0 +1,82 @@ +import { describe, test, expect, beforeEach } from "bun:test" +import { createSubagentQuestionBlockerHook } from "./index" +import { subagentSessions, _resetForTesting } from "../../features/claude-code-session-state" + +describe("createSubagentQuestionBlockerHook", () => { + const hook = createSubagentQuestionBlockerHook() + + beforeEach(() => { + _resetForTesting() + }) + + describe("tool.execute.before", () => { + test("allows question tool for non-subagent sessions", async () => { + //#given + const sessionID = "ses_main" + const input = { tool: "question", sessionID, callID: "call_1" } + const output = { args: { questions: [] } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + + test("blocks question tool for subagent sessions", async () => { + //#given + const sessionID = "ses_subagent" + subagentSessions.add(sessionID) + const input = { tool: "question", sessionID, callID: "call_1" } + const output = { args: { questions: [] } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions") + }) + + test("blocks Question tool (case insensitive) for subagent sessions", async () => { + //#given + const sessionID = "ses_subagent" + subagentSessions.add(sessionID) + const input = { tool: "Question", sessionID, callID: "call_1" } + const output = { args: { questions: [] } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions") + }) + + test("blocks AskUserQuestion tool for subagent sessions", async () => { + //#given + const sessionID = "ses_subagent" + subagentSessions.add(sessionID) + const input = { tool: "AskUserQuestion", sessionID, callID: "call_1" } + const output = { args: { questions: [] } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions") + }) + + test("ignores non-question tools for subagent sessions", async () => { + //#given + const sessionID = "ses_subagent" + subagentSessions.add(sessionID) + const input = { tool: "bash", sessionID, callID: "call_1" } + const output = { args: { command: "ls" } } + + //#when + const result = hook["tool.execute.before"]?.(input as any, output as any) + + //#then + await expect(result).resolves.toBeUndefined() + }) + }) +}) diff --git a/src/hooks/subagent-question-blocker/index.ts b/src/hooks/subagent-question-blocker/index.ts new file mode 100644 index 000000000..b848d859a --- /dev/null +++ b/src/hooks/subagent-question-blocker/index.ts @@ -0,0 +1,29 @@ +import type { Hooks } from "@opencode-ai/plugin" +import { subagentSessions } from "../../features/claude-code-session-state" +import { log } from "../../shared" + +export function createSubagentQuestionBlockerHook(): Hooks { + return { + "tool.execute.before": async (input) => { + const toolName = input.tool?.toLowerCase() + if (toolName !== "question" && toolName !== "askuserquestion") { + return + } + + if (!subagentSessions.has(input.sessionID)) { + return + } + + log("[subagent-question-blocker] Blocking question tool call from subagent session", { + sessionID: input.sessionID, + tool: input.tool, + }) + + throw new Error( + "Question tool is disabled for subagent sessions. " + + "Subagents should complete their work autonomously without asking questions to users. " + + "If you need clarification, return to the parent agent with your findings and uncertainties." + ) + }, + } +} diff --git a/src/index.ts b/src/index.ts index 4b3d3fa82..48e6123d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import { createPrometheusMdOnlyHook, createSisyphusJuniorNotepadHook, createQuestionLabelTruncatorHook, + createSubagentQuestionBlockerHook, } from "./hooks"; import { contextCollector, @@ -224,6 +225,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { : null; const questionLabelTruncator = createQuestionLabelTruncatorHook(); + const subagentQuestionBlocker = createSubagentQuestionBlockerHook(); const taskResumeInfo = createTaskResumeInfoHook(); @@ -555,6 +557,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { }, "tool.execute.before": async (input, output) => { + await subagentQuestionBlocker["tool.execute.before"]?.(input, output); await questionLabelTruncator["tool.execute.before"]?.(input, output); await claudeCodeHooks["tool.execute.before"](input, output); await nonInteractiveEnv?.["tool.execute.before"](input, output); diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index 10f951773..0da36ae42 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -864,7 +864,10 @@ To continue this session: session_id="${task.sessionID}"` body: { parentID: ctx.sessionID, title: `Task: ${args.description}`, - }, + permission: [ + { permission: "question", action: "deny" as const, pattern: "*" }, + ], + } as any, query: { directory: parentDirectory, },