From 1becad04ce0a85cfc9fb519b8879f158d681224e Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Fri, 20 Feb 2026 13:39:40 +0200 Subject: [PATCH] fix(taskctl): add taskctl permission to adversarial-pipeline and fix verdict prompt syntax (#259, #260) --- packages/opencode/src/agent/agent.ts | 36 ++++--- packages/opencode/src/tasks/pulse.ts | 3 +- .../test/tasks/agent-permissions.test.ts | 99 +++++++++++++++++++ 3 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 packages/opencode/test/tasks/agent-permissions.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 14b1278d2142..8f15b6a5fac2 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -261,6 +261,7 @@ RULES FOR GOOD TASK DECOMPOSITION: defaults, PermissionNext.fromConfig({ task: "deny", + taskctl: "deny", }), user, ), @@ -281,7 +282,7 @@ You will receive a task description with: 3. Write minimal code to make tests pass 4. Refactor for clarity following AGENTS.md style guide 5. Run \`bun run typecheck && bun test\` — fix all errors -6. When done: \`taskctl comment "Implementation complete: "\` +6. When done: simply complete your work - the pulse system automatically detects completion ## Rules - ONLY implement what is explicitly in the task description @@ -298,12 +299,7 @@ You will receive a task description with: - **context7** — current library docs. Use resolve-library-id then query-docs before using any external library API - **parallel-search + web_fetch MCP tools** — web search when you need current information not in the codebase -## taskctl commands available to you -- \`taskctl comment ""\` — log progress or signal completion -- \`taskctl split \` — if task is too large, split it (creates two sub-tasks) -- \`taskctl depends --on \` — if you discover an undeclared dependency - -You may NOT call: taskctl start, taskctl stop, taskctl verdict, taskctl override, taskctl retry, taskctl resume`, +NOTE: You do NOT have access to taskctl commands. The pipeline handles task state automatically.`, }, "adversarial-pipeline": { name: "adversarial-pipeline", @@ -315,6 +311,7 @@ You may NOT call: taskctl start, taskctl stop, taskctl verdict, taskctl override PermissionNext.fromConfig({ "*": "deny", bash: "allow", + taskctl: "allow", }), user, ), @@ -336,16 +333,31 @@ Your ONLY job is to review code changes in an assigned worktree and record a str 5. Check: Does typecheck pass? (Run \`bun run typecheck\` in the worktree) ## Recording your verdict — MANDATORY -You MUST call taskctl verdict to record your finding. Never write a text response instead. + +You MUST use the \`taskctl\` MCP tool to record your verdict. This is an MCP tool in your tool list — NOT a bash command. Never write a text response instead. + +Use the taskctl tool with these parameters: **If the code is good:** -\`taskctl verdict --verdict APPROVED\` +- command: "verdict" +- taskId: +- verdict: "APPROVED" +- verdictSummary: "Brief summary of what you reviewed and why it passes" +- verdictIssues: [] **If there are fixable issues:** -\`taskctl verdict --verdict ISSUES_FOUND --summary "Brief summary" --issues '[{"location":"src/foo.ts:42","severity":"HIGH","fix":"Add null check before calling user.profile"}]'\` +- command: "verdict" +- taskId: +- verdict: "ISSUES_FOUND" +- verdictSummary: "Brief summary" +- verdictIssues: [{"location":"src/foo.ts:42","severity":"HIGH","fix":"Add null check before calling user.profile"}] **If there are critical/blocking issues:** -\`taskctl verdict --verdict CRITICAL_ISSUES_FOUND --summary "Brief summary" --issues '[...]'\` +- command: "verdict" +- taskId: +- verdict: "CRITICAL_ISSUES_FOUND" +- verdictSummary: "Brief summary" +- verdictIssues: [{"location":"...","severity":"CRITICAL","fix":"..."}] ## Severity guide - CRITICAL: Security vulnerability, data loss risk, or complete functional failure @@ -354,7 +366,7 @@ You MUST call taskctl verdict to record your finding. Never write a text respons - LOW: Style or minor improvement suggestion ## Rules -- You may ONLY call: taskctl verdict +- You may ONLY use: the taskctl MCP tool (command: "verdict") - Do NOT spawn any agents - Do NOT commit or push - Be specific: every issue must have a location (file:line) and a concrete fix suggestion diff --git a/packages/opencode/src/tasks/pulse.ts b/packages/opencode/src/tasks/pulse.ts index 85faa2ca68d6..6d8ef32f9a8e 100644 --- a/packages/opencode/src/tasks/pulse.ts +++ b/packages/opencode/src/tasks/pulse.ts @@ -728,8 +728,7 @@ Summary: ${verdict.summary} Issues: ${issueLines} -The codebase changes are already in this worktree. Fix the specific issues listed above, run tests, then signal completion with: -taskctl comment ${task.id} "Implementation complete: "` +The codebase changes are already in this worktree. Fix the specific issues listed above, run tests, and complete your work. The pulse system automatically detects completion.` try { await SessionPrompt.prompt({ diff --git a/packages/opencode/test/tasks/agent-permissions.test.ts b/packages/opencode/test/tasks/agent-permissions.test.ts new file mode 100644 index 000000000000..0bc29d4693d4 --- /dev/null +++ b/packages/opencode/test/tasks/agent-permissions.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Agent } from "../../src/agent/agent" +import { PermissionNext } from "../../src/permission/next" +import { tmpdir } from "../fixture/fixture" + +// Helper to evaluate permission for a tool with wildcard pattern +function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined { + if (!agent) return undefined + return PermissionNext.evaluate(permission, "*", agent.permission).action +} + +describe("agent permissions for taskctl pipeline", () => { + test("adversarial-pipeline agent has taskctl permission", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const adversarial = await Agent.get("adversarial-pipeline") + expect(adversarial).toBeDefined() + expect(adversarial?.permission).toBeDefined() + + // Check the raw ruleset contains taskctl allowance + const hasTaskctlAllow = adversarial!.permission.some( + (rule: any) => rule.permission === "taskctl" && rule.action === "allow" + ) + expect(hasTaskctlAllow).toBe(true) + + // Verify via PermissionNext.evaluate - taskctl with any pattern should be allowed + const result = PermissionNext.evaluate("taskctl", "*", adversarial!.permission) + expect(result.action).toBe("allow") + }, + }) + }) + + test("adversarial-pipeline agent has bash permission", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const adversarial = await Agent.get("adversarial-pipeline") + expect(adversarial).toBeDefined() + + // Verify bash is allowed + const result = PermissionNext.evaluate("bash", "*", adversarial!.permission) + expect(result.action).toBe("allow") + }, + }) + }) + + test("developer-pipeline agent explicitly denies taskctl permission", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const developer = await Agent.get("developer-pipeline") + expect(developer).toBeDefined() + + // Developer should explicitly deny taskctl - not just "not allow" + const result = PermissionNext.evaluate("taskctl", "*", developer!.permission) + expect(result.action).toBe("deny") // Explicit deny, not fallback "ask" + }, + }) + }) + + test("adversarial-pipeline denies everything else (minimal permissions)", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const adversarial = await Agent.get("adversarial-pipeline") + expect(adversarial).toBeDefined() + + // Check that common tools are denied (wildcard deny rule) + const deniedTools = ["edit", "write", "read", "task"] + + for (const tool of deniedTools) { + const result = PermissionNext.evaluate(tool, "*", adversarial!.permission) + expect(result.action).toBe("deny") + } + }, + }) + }) + + test("developer-pipeline denies task tool", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const developer = await Agent.get("developer-pipeline") + expect(developer).toBeDefined() + + // Developer should NOT be able to spawn other agents via task tool + const result = PermissionNext.evaluate("task", "*", developer!.permission) + expect(result.action).toBe("deny") + }, + }) + }) +}) \ No newline at end of file