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:
ismeth
2026-03-03 22:37:26 +01:00
committed by YeonGyu-Kim
parent 6db83b937f
commit 62cf707502
9 changed files with 97 additions and 20 deletions
+49 -2
View File
@@ -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,
}
})
}
+1 -1
View File
@@ -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.
+1
View File
@@ -42,6 +42,7 @@ export const OverridableAgentNameSchema = z.enum([
"multimodal-looker",
"atlas",
"athena",
"athena-junior",
"council-member",
])
+5 -4
View File
@@ -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
}
+1
View File
@@ -286,6 +286,7 @@ export function createToolRegistry(args: {
athena_council: createAthenaCouncilTool({
backgroundManager: managers.backgroundManager,
councilConfig: pluginConfig.agents?.athena?.council,
directory: ctx.directory,
}),
}
+17 -6
View File
@@ -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")
+11 -3
View File
@@ -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)}`
}