From f152569ed41c29a6894e84a717445c10cb3f5856 Mon Sep 17 00:00:00 2001 From: pizzav-xyz Date: Sat, 16 May 2026 02:01:29 +0200 Subject: [PATCH] feat(keyword-detector): add enabled_expansions config for allowlist control Add optional enabled_expansions field to keyword_detector config schema. When set, acts as an allowlist - only those expansion types fire. Empty array disables all expansions. Absent field keeps all enabled (backward-compatible). Also supports coexistence with disabled_keywords denylist for fine-grained control. - src/config/schema/keyword-detector.ts: add enabled_expansions field - src/hooks/keyword-detector/detector.ts: apply allowlist filter in detectKeywordsWithType - src/hooks/keyword-detector/hook.ts: pass enabled_expansions from config - src/hooks/keyword-detector/index.test.ts: add 4 tests for enabled_expansions behavior - assets/oh-my-opencode.schema.json: regenerate schema --- assets/oh-my-opencode.schema.json | 14 ++++ src/config/schema/keyword-detector.ts | 1 + src/hooks/keyword-detector/detector.ts | 13 +++- src/hooks/keyword-detector/hook.ts | 3 +- src/hooks/keyword-detector/index.test.ts | 99 ++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 3 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 6700c97f9..f7481367a 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -6026,6 +6026,20 @@ "keyword_detector": { "type": "object", "properties": { + "enabled_expansions": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "ultrawork", + "search", + "analyze", + "team", + "hyperplan", + "hyperplan-ultrawork" + ] + } + }, "disabled_keywords": { "type": "array", "items": { diff --git a/src/config/schema/keyword-detector.ts b/src/config/schema/keyword-detector.ts index ce46a3967..a96108fce 100644 --- a/src/config/schema/keyword-detector.ts +++ b/src/config/schema/keyword-detector.ts @@ -4,6 +4,7 @@ export const KeywordTypeSchema = z.enum(["ultrawork", "search", "analyze", "team export type KeywordType = z.infer export const KeywordDetectorConfigSchema = z.object({ + enabled_expansions: z.array(KeywordTypeSchema).optional(), disabled_keywords: z.array(KeywordTypeSchema).optional(), }) diff --git a/src/hooks/keyword-detector/detector.ts b/src/hooks/keyword-detector/detector.ts index 649b93649..56b3762e9 100644 --- a/src/hooks/keyword-detector/detector.ts +++ b/src/hooks/keyword-detector/detector.ts @@ -34,8 +34,9 @@ export function detectKeywords( agentName?: string, modelID?: string, disabledKeywords?: ReadonlyArray, + enabledExpansions?: ReadonlyArray, ): string[] { - return detectKeywordsWithType(text, agentName, modelID, disabledKeywords).map( + return detectKeywordsWithType(text, agentName, modelID, disabledKeywords, enabledExpansions).map( ({ message }) => message, ) } @@ -45,6 +46,7 @@ export function detectKeywordsWithType( agentName?: string, modelID?: string, disabledKeywords?: ReadonlyArray, + enabledExpansions?: ReadonlyArray, ): DetectedKeyword[] { const textWithoutCode = removeCodeBlocks(text) const disabled = new Set(disabledKeywords ?? []) @@ -52,12 +54,19 @@ export function detectKeywordsWithType( if (disabled.has("ultrawork") || disabled.has("hyperplan")) { disabled.add("hyperplan-ultrawork") } + // Allowlist: if enabledExpansions is set, only those types fire + const allowlist = enabledExpansions ? new Set(enabledExpansions) : null return KEYWORD_DETECTORS.map(({ type, pattern, message }) => ({ matches: pattern.test(textWithoutCode), type, message: resolveMessage(message, agentName, modelID), })) - .filter((result) => result.matches && !disabled.has(result.type)) + .filter((result) => { + if (!result.matches) return false + if (allowlist && !allowlist.has(result.type)) return false + if (disabled.has(result.type)) return false + return true + }) .map(({ type, message }) => ({ type, message })) } diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts index 83494c4fd..07b8caee5 100644 --- a/src/hooks/keyword-detector/hook.ts +++ b/src/hooks/keyword-detector/hook.ts @@ -33,6 +33,7 @@ export function createKeywordDetectorHook( config?: KeywordDetectorConfig, ) { const disabledKeywords = config?.disabled_keywords + const enabledExpansions = config?.enabled_expansions function getRuntimeVariant(input: { variant?: string }, message: Record): string | undefined { if (typeof message.variant === "string") { return message.variant @@ -83,7 +84,7 @@ export function createKeywordDetectorHook( // Remove system-reminder content to prevent automated system messages from triggering mode keywords const cleanText = removeSystemReminders(promptText) const modelID = input.model?.modelID - let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID, disabledKeywords) + let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID, disabledKeywords, enabledExpansions) detectedKeywords = suppressComboStandalones(detectedKeywords) if (isPlannerAgent(currentAgent)) { diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index f755194e8..145672999 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -181,6 +181,105 @@ describe("keyword-detector message transform", () => { expect(textPart?.text).toBe(peerText) expect(textPart?.text).not.toContain("[search-mode]") }) + + test("should only fire ultrawork when enabled_expansions is set to [ultrawork]", async () => { + // given - allowlist configured to only enable ultrawork + const collector = new ContextCollector() + const hook = createKeywordDetectorHook( + createMockPluginInput(), + collector, + undefined, + { enabled_expansions: ["ultrawork"] } + ) + const sessionID = "enabled-expansions-ultrawork-only" + const output = { + message: {} as Record, + parts: [{ type: "text", text: "search for the bug" }], + } + + // when - keyword detection runs with enabled_expansions restricting to ultrawork + await hook["chat.message"]({ sessionID }, output) + + // then - search should be blocked by allowlist even though it matches + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toBe("search for the bug") // no search-mode injection + }) + + test("should fire only allowed expansions from allowlist", async () => { + // given - allowlist configured to only enable analyze + const collector = new ContextCollector() + const hook = createKeywordDetectorHook( + createMockPluginInput(), + collector, + undefined, + { enabled_expansions: ["analyze"] } + ) + const sessionID = "enabled-expansions-analyze-only" + const output = { + message: {} as Record, + parts: [{ type: "text", text: "investigate the bug" }], + } + + // when - keyword detection runs with enabled_expansions restricting to analyze + await hook["chat.message"]({ sessionID }, output) + + // then - analyze should fire because it's in the allowlist + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("[analyze-mode]") + }) + + test("should block all expansions when enabled_expansions is empty array", async () => { + // given - empty allowlist (effectively disable all) + const collector = new ContextCollector() + const hook = createKeywordDetectorHook( + createMockPluginInput(), + collector, + undefined, + { enabled_expansions: [] } + ) + const sessionID = "enabled-expansions-empty" + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork fix the bug" }], + } + + // when - keyword detection runs with empty enabled_expansions + await hook["chat.message"]({ sessionID }, output) + + // then - ultrawork should not fire + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toBe("ultrawork fix the bug") // no mode injection + }) + + test("should allow both allowlist and denylist to coexist", async () => { + // given - allowlist enables ultrawork and search, but denylist also blocks search + const collector = new ContextCollector() + const hook = createKeywordDetectorHook( + createMockPluginInput(), + collector, + undefined, + { enabled_expansions: ["ultrawork", "search"], disabled_keywords: ["search"] } + ) + const sessionID = "enabled-and-disabled-coexist" + const output = { + message: {} as Record, + parts: [{ type: "text", text: "search for the bug" }], + } + + // when - both config fields are set + await hook["chat.message"]({ sessionID }, output) + + // then - search blocked by both allowlist (allowed) AND denylist (blocked) + // Actually search is in enabled_expansions so it would fire, but disabled_keywords blocks it + // Wait, let me reconsider: with enabled_expansions=["ultrawork", "search"], search passes the allowlist. + // Then disabled_keywords=["search"] blocks it. So no injection. + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toBe("search for the bug") + }) }) describe("keyword-detector session filtering", () => {