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
This commit is contained in:
justsisyphus
2026-01-27 11:03:44 +09:00
parent 04500bae7d
commit de439edc22
6 changed files with 123 additions and 2 deletions

View File

@@ -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,
},

View File

@@ -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";

View File

@@ -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()
})
})
})

View File

@@ -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."
)
},
}
}

View File

@@ -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);

View File

@@ -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,
},