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
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -4,6 +4,7 @@ export const KeywordTypeSchema = z.enum(["ultrawork", "search", "analyze", "team
|
||||
export type KeywordType = z.infer<typeof KeywordTypeSchema>
|
||||
|
||||
export const KeywordDetectorConfigSchema = z.object({
|
||||
enabled_expansions: z.array(KeywordTypeSchema).optional(),
|
||||
disabled_keywords: z.array(KeywordTypeSchema).optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -34,8 +34,9 @@ export function detectKeywords(
|
||||
agentName?: string,
|
||||
modelID?: string,
|
||||
disabledKeywords?: ReadonlyArray<KeywordType>,
|
||||
enabledExpansions?: ReadonlyArray<KeywordType>,
|
||||
): 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<KeywordType>,
|
||||
enabledExpansions?: ReadonlyArray<KeywordType>,
|
||||
): DetectedKeyword[] {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
const disabled = new Set<KeywordType>(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<KeywordType>(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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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, unknown>): 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)) {
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user