fix: resolve 11 council-audited violations across athena subsystem
- Add switch_agent to athena-junior deny list (P1 defense-in-depth) - Add terminal status check to waitForSessionIds polling loop - Add athena-junior to OverridableAgentNameSchema and AgentOverridesSchema - Add explicit retry workflow instructions to non-interactive prompt Step 9 - Fix JSON schema generation to exclude defaulted fields from required arrays - Fix docs example for non_interactive_member_list (remove Council: prefix) - Use segment-aware regex in path-policy.ts to block fake.sisyphus/ paths - Add defensive path resolution and contract validation for prompt_file - Add explicit .optional() to AthenaOverrideConfigSchema fields for clarity - Fix traversal check precision for .. prefixed directory names
This commit is contained in:
@@ -1,17 +1,64 @@
|
||||
import { z } from "zod"
|
||||
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
||||
|
||||
function removeDefaultedFromRequired(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof schema !== "object" || schema === null) return schema
|
||||
|
||||
const result = { ...schema }
|
||||
|
||||
if (Array.isArray(result.required) && result.properties && typeof result.properties === "object") {
|
||||
const props = result.properties as Record<string, Record<string, unknown>>
|
||||
result.required = (result.required as string[]).filter((key) => {
|
||||
const prop = props[key]
|
||||
return prop && !("default" in prop)
|
||||
})
|
||||
if ((result.required as string[]).length === 0) {
|
||||
delete result.required
|
||||
}
|
||||
}
|
||||
|
||||
if (result.properties && typeof result.properties === "object") {
|
||||
const newProps: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(result.properties as Record<string, unknown>)) {
|
||||
newProps[key] = removeDefaultedFromRequired(value as Record<string, unknown>)
|
||||
}
|
||||
result.properties = newProps
|
||||
}
|
||||
|
||||
if (result.items && typeof result.items === "object") {
|
||||
result.items = removeDefaultedFromRequired(result.items as Record<string, unknown>)
|
||||
}
|
||||
|
||||
for (const key of ["allOf", "anyOf", "oneOf"]) {
|
||||
if (Array.isArray(result[key])) {
|
||||
result[key] = (result[key] as Record<string, unknown>[]).map((s) => removeDefaultedFromRequired(s))
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ["$defs", "definitions"]) {
|
||||
if (result[key] && typeof result[key] === "object") {
|
||||
const newDefs: Record<string, unknown> = {}
|
||||
for (const [defKey, defValue] of Object.entries(result[key] as Record<string, unknown>)) {
|
||||
newDefs[defKey] = removeDefaultedFromRequired(defValue as Record<string, unknown>)
|
||||
}
|
||||
result[key] = newDefs
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function createOhMyOpenCodeJsonSchema(): Record<string, unknown> {
|
||||
const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, {
|
||||
target: "draft-7",
|
||||
unrepresentable: "any",
|
||||
}) as Record<string, unknown>
|
||||
|
||||
return {
|
||||
return removeDefaultedFromRequired({
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
||||
title: "Oh My OpenCode Configuration",
|
||||
description: "Configuration schema for oh-my-opencode plugin",
|
||||
...jsonSchema,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ATHENA_JUNIOR_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
}
|
||||
|
||||
export function createAthenaJuniorAgent(model: string): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["call_omo_agent", "question"])
|
||||
const restrictions = createAgentToolRestrictions(["call_omo_agent", "question", "switch_agent"])
|
||||
return {
|
||||
description:
|
||||
"Non-interactive council orchestrator for programmatic multi-model synthesis. Returns structured <athena_council_result> JSON without user interaction. (Athena-Junior - OhMyOpenCode)",
|
||||
|
||||
@@ -90,6 +90,14 @@ Track every task_id from the response for use in Step 5.
|
||||
- cancel_retrying_on_quorum = {CANCEL_RETRYING_ON_QUORUM}
|
||||
- Quorum enforcement: minimum 2 successful members required before synthesis.
|
||||
|
||||
If retry_on_fail > 0 and failed members exist:
|
||||
1. Re-launch failed members via athena_council with the same prompt_file and members parameter set to the failed member names.
|
||||
2. Return to Step 5 to wait for their completion via background_wait.
|
||||
3. Call council_finalize again to collect retried results.
|
||||
4. Continue retrying until retry count exhausted or quorum met.
|
||||
- If retry_failed_if_others_finished is true, only retry after all non-failed members have completed.
|
||||
- If cancel_retrying_on_quorum is true, stop retrying once quorum (2+ successful) is met.
|
||||
|
||||
### Step 10: Synthesize using council_finalize runtime guidance.
|
||||
- Read every member's archive_file with Read tool.
|
||||
- Apply the injected <athena_runtime_guidance> from council_finalize.
|
||||
|
||||
@@ -42,6 +42,7 @@ export const OverridableAgentNameSchema = z.enum([
|
||||
"multimodal-looker",
|
||||
"atlas",
|
||||
"athena",
|
||||
"athena-junior",
|
||||
"council-member",
|
||||
])
|
||||
|
||||
|
||||
@@ -58,10 +58,10 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
|
||||
export const AthenaOverrideConfigSchema = AgentOverrideConfigSchema.extend({
|
||||
council: AthenaConfigSchema.shape.council.optional(),
|
||||
bulk_launch: AthenaConfigSchema.shape.bulk_launch,
|
||||
non_interactive_mode: AthenaConfigSchema.shape.non_interactive_mode,
|
||||
non_interactive_members: AthenaConfigSchema.shape.non_interactive_members,
|
||||
non_interactive_member_list: AthenaConfigSchema.shape.non_interactive_member_list,
|
||||
bulk_launch: AthenaConfigSchema.shape.bulk_launch.optional(),
|
||||
non_interactive_mode: AthenaConfigSchema.shape.non_interactive_mode.optional(),
|
||||
non_interactive_members: AthenaConfigSchema.shape.non_interactive_members.optional(),
|
||||
non_interactive_member_list: AthenaConfigSchema.shape.non_interactive_member_list.optional(),
|
||||
})
|
||||
|
||||
export const AgentOverridesSchema = z.object({
|
||||
@@ -83,6 +83,7 @@ export const AgentOverridesSchema = z.object({
|
||||
atlas: AgentOverrideConfigSchema.optional(),
|
||||
"council-member": AgentOverrideConfigSchema.optional(),
|
||||
athena: AthenaOverrideConfigSchema.optional(),
|
||||
"athena-junior": AthenaOverrideConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
|
||||
|
||||
@@ -16,13 +16,13 @@ export function isAllowedPath(filePath: string, workspaceRoot: string): boolean
|
||||
// 2. Get relative path from workspace root
|
||||
const rel = relative(workspaceRoot, resolved)
|
||||
|
||||
// 3. Reject if escapes root (starts with ".." or is absolute)
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
// 3. Reject if escapes root (traversal or absolute path)
|
||||
if ((rel === ".." || rel.startsWith("../") || rel.startsWith("..\\")) || isAbsolute(rel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Check if .sisyphus/ or .sisyphus\ exists anywhere in the path (case-insensitive)
|
||||
if (!/\.sisyphus[/\\]/i.test(rel)) {
|
||||
// 4. Check if .sisyphus is a complete path segment
|
||||
if (!/(^|[\/\\])\.sisyphus[\/\\]/i.test(rel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -286,6 +286,7 @@ export function createToolRegistry(args: {
|
||||
athena_council: createAthenaCouncilTool({
|
||||
backgroundManager: managers.backgroundManager,
|
||||
councilConfig: pluginConfig.agents?.athena?.council,
|
||||
directory: ctx.directory,
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { CouncilConfig } from "../../config/schema/athena"
|
||||
|
||||
const TEST_TMP_DIR = join(tmpdir(), "athena-council-test")
|
||||
const PROMPT_FILE = join(TEST_TMP_DIR, "test-prompt.md")
|
||||
const SISYPHUS_TMP_DIR = join(TEST_TMP_DIR, ".sisyphus", "tmp")
|
||||
const PROMPT_FILE = join(SISYPHUS_TMP_DIR, "test-prompt.md")
|
||||
|
||||
const makeManager = (): BackgroundManager =>
|
||||
({
|
||||
@@ -46,7 +47,7 @@ const makeCouncilConfig = (members?: Array<{ name: string; model: string; varian
|
||||
|
||||
describe("createAthenaCouncilTool", () => {
|
||||
beforeEach(async () => {
|
||||
await mkdir(TEST_TMP_DIR, { recursive: true })
|
||||
await mkdir(SISYPHUS_TMP_DIR, { recursive: true })
|
||||
await writeFile(PROMPT_FILE, "prompt file content", "utf-8")
|
||||
mockLaunchCouncilMember.mockImplementation(async (member: { name: string; model: string }) => ({
|
||||
member,
|
||||
@@ -61,7 +62,7 @@ describe("createAthenaCouncilTool", () => {
|
||||
describe("#given council is not configured (undefined)", () => {
|
||||
describe("#when execute is called", () => {
|
||||
it("#then returns error message about council not configured", async () => {
|
||||
const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: undefined })
|
||||
const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: undefined, directory: TEST_TMP_DIR })
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
expect(result).toContain("Council not configured")
|
||||
})
|
||||
@@ -72,7 +73,7 @@ describe("createAthenaCouncilTool", () => {
|
||||
describe("#when execute is called", () => {
|
||||
it("#then returns error message about council not configured", async () => {
|
||||
const config = makeCouncilConfig([])
|
||||
const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: config })
|
||||
const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: config, directory: TEST_TMP_DIR })
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
expect(result).toContain("Council not configured")
|
||||
})
|
||||
@@ -85,10 +86,12 @@ describe("createAthenaCouncilTool", () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
directory: TEST_TMP_DIR,
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: "/nonexistent/prompt.md" }, makeToolContext())
|
||||
const nonExistentFile = join(SISYPHUS_TMP_DIR, "nonexistent.md")
|
||||
const result = await tool.execute({ prompt_file: nonExistentFile }, makeToolContext())
|
||||
expect(result).toContain("Failed to read prompt file")
|
||||
expect(result).toContain("/nonexistent/prompt.md")
|
||||
expect(result).toContain("nonexistent.md")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -99,6 +102,7 @@ describe("createAthenaCouncilTool", () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
directory: TEST_TMP_DIR,
|
||||
})
|
||||
const result = await tool.execute(
|
||||
{ prompt_file: PROMPT_FILE, members: ["NonExistentMember"] },
|
||||
@@ -116,6 +120,7 @@ describe("createAthenaCouncilTool", () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
directory: TEST_TMP_DIR,
|
||||
})
|
||||
const result = await tool.execute(
|
||||
{ prompt_file: PROMPT_FILE, members: ["Claude Opus"] },
|
||||
@@ -137,6 +142,7 @@ describe("createAthenaCouncilTool", () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
directory: TEST_TMP_DIR,
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
@@ -151,6 +157,7 @@ describe("createAthenaCouncilTool", () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
directory: TEST_TMP_DIR,
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
expect(result).toContain("background_wait")
|
||||
@@ -176,6 +183,7 @@ describe("createAthenaCouncilTool", () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
directory: TEST_TMP_DIR,
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
@@ -188,6 +196,7 @@ describe("createAthenaCouncilTool", () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
directory: TEST_TMP_DIR,
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
@@ -211,6 +220,7 @@ describe("createAthenaCouncilTool", () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
directory: TEST_TMP_DIR,
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
expect(result).toContain("All council member launches failed")
|
||||
@@ -221,6 +231,7 @@ describe("createAthenaCouncilTool", () => {
|
||||
const tool = createAthenaCouncilTool({
|
||||
backgroundManager: makeManager(),
|
||||
councilConfig: makeCouncilConfig(),
|
||||
directory: TEST_TMP_DIR,
|
||||
})
|
||||
const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
|
||||
expect(result).toContain("Claude Opus")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { resolve } from "node:path"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { CouncilConfig, CouncilMemberConfig } from "../../config/schema/athena"
|
||||
import { launchCouncilMember, type CouncilLaunchContext } from "./council-launcher"
|
||||
@@ -86,9 +87,10 @@ async function waitForSessionIds(
|
||||
if (task?.sessionID) {
|
||||
result.set(taskId, task.sessionID)
|
||||
pending.delete(taskId)
|
||||
} else if (task?.status === "error" || task?.status === "cancelled" || task?.status === "interrupt") {
|
||||
pending.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.size > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SESSION_WAIT_INTERVAL_MS))
|
||||
}
|
||||
@@ -100,8 +102,9 @@ async function waitForSessionIds(
|
||||
export function createAthenaCouncilTool(args: {
|
||||
backgroundManager: BackgroundManager
|
||||
councilConfig: CouncilConfig | undefined
|
||||
directory: string
|
||||
}): ToolDefinition {
|
||||
const { backgroundManager, councilConfig } = args
|
||||
const { backgroundManager, councilConfig, directory } = args
|
||||
const description = buildToolDescription(councilConfig)
|
||||
|
||||
return tool({
|
||||
@@ -124,7 +127,12 @@ export function createAthenaCouncilTool(args: {
|
||||
|
||||
let promptContent: string
|
||||
try {
|
||||
promptContent = await readFile(toolArgs.prompt_file, "utf-8")
|
||||
const resolvedPath = resolve(directory, toolArgs.prompt_file)
|
||||
const expectedPrefix = resolve(directory, ".sisyphus/tmp")
|
||||
if (!resolvedPath.startsWith(expectedPrefix)) {
|
||||
return `Invalid prompt_file path: expected path within .sisyphus/tmp/, got: ${toolArgs.prompt_file}`
|
||||
}
|
||||
promptContent = await readFile(resolvedPath, "utf-8")
|
||||
} catch (err) {
|
||||
return `Failed to read prompt file: ${toolArgs.prompt_file}. Error: ${String(err)}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user