From 2ce2bf8b9be2242a9e57a708bfba041ad9b4ee48 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:40:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(kernel):=20permission-model=20?= =?UTF-8?q?=E2=80=94=20shell=20env=20=E5=A4=8D=E7=94=A8=20gh=20auth=20+=20?= =?UTF-8?q?agents.ts=20=E6=B8=85=E7=90=86=20+=20caller=20=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=20+=20permission=20=E7=99=BD/=E9=BB=91=E5=90=8D?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shell.ts:createIsolatedShellEnv → createAgentShellEnv,不再清空 GH_TOKEN/GITHUB_TOKEN/GH_CONFIG_DIR/HOME(复用宿主 gh auth),保留 GIT_CONFIG_NOSYSTEM/GIT_TERMINAL_PROMPT;删除 detectAmbientCredentials(advisory 降级机制废弃) - server.ts:移除 ambient credential 检测调用,agent 注入不再写 tools 布尔(permission 规则接管) - agents.ts:删除 tools 布尔 + capabilities 解析(废弃死代码),保留 permission 解析(含 OpenCode 嵌套规则对象语义) - caller.ts:新增 LIFECYCLE_TOOLS 矩阵 + rolesForTool + requireToolCaller(spec §2.2 第一道门) - kernel/permission.ts:matchPermission/isAllowedInAutoMode 纯函数(最后匹配优先 + auto 模式 deny 生效) - 测试:caller-matrix(5 工具×5 角色全组合)、permission(deny 置尾/黑名单完整性)、shell/server-permissions 适配 --- src/kernel/caller.ts | 49 ++++ src/kernel/permission.ts | 38 +++ src/plugin/agents.ts | 51 +--- src/plugin/server.ts | 14 +- src/plugin/shell.ts | 182 +----------- test/agents.test.ts | 95 +++---- test/kernel/caller-matrix.test.ts | 146 ++++++++++ test/kernel/permission.test.ts | 114 ++++++++ test/plugin/server-permissions.test.ts | 97 ++++++- test/plugin/shell.test.ts | 379 ++----------------------- 10 files changed, 520 insertions(+), 645 deletions(-) create mode 100644 src/kernel/permission.ts create mode 100644 test/kernel/caller-matrix.test.ts create mode 100644 test/kernel/permission.test.ts diff --git a/src/kernel/caller.ts b/src/kernel/caller.ts index fff7f7e..7359e40 100644 --- a/src/kernel/caller.ts +++ b/src/kernel/caller.ts @@ -7,6 +7,40 @@ export type CallerRole = "primary" | "developer" | "architect" | "reviewer" | "goal-verify" +/** 5 个生命周期工具(spec §2.2 / PRD R4) */ +export const LIFECYCLE_TOOLS = [ + "setup_control", + "flow_control", + "task_control", + "tdd_checkpoint", + "release_control", +] as const + +/** 工具默认允许角色矩阵:primary 全量;developer 仅 tdd_checkpoint;其余角色默认无工具 */ +const TOOL_ROLES: Record = { + setup_control: ["primary"], + flow_control: ["primary"], + task_control: ["primary"], + tdd_checkpoint: ["primary", "developer"], + release_control: ["primary"], +} + +/** op 级覆盖(最后匹配优先):architect 仅 flow_control.status 只读;goal-verify 仅 complete-flow */ +const OP_ROLES: Record> = { + flow_control: { + "complete-flow": ["goal-verify"], + status: ["primary", "architect"], + }, +} + +/** + * 解析工具(+op)允许的角色集。op 级覆盖优先;未知工具保守返回空集(fail-closed)。 + */ +export function rolesForTool(tool: string, op?: string): CallerRole[] { + if (op && OP_ROLES[tool]?.[op]) return OP_ROLES[tool][op] + return TOOL_ROLES[tool] ?? [] +} + /** requireCaller 拒绝时的错误码 */ export const CALLER_NOT_AUTHORIZED = "CALLER_NOT_AUTHORIZED" @@ -62,3 +96,18 @@ export async function requireCaller( if (allowedRoles.includes(role)) return null return `${CALLER_NOT_AUTHORIZED}: caller "${ctx.agent}" resolved as "${role}" is not allowed to call "${op}" (allowed: ${allowedRoles.join(", ")})` } + +/** + * 工具级 caller 门禁:按工具(+op)矩阵解析允许角色后校验。 + * 供各生命周期工具 execute 内调用(spec §2.2 第一道门;config 层为第二道门)。 + */ +export async function requireToolCaller( + ctx: CallerContext, + tool: string, + op: string | undefined, + client: CallerSessionClient, +): Promise { + const allowed = rolesForTool(tool, op) + const label = op ? `${tool}:{op:"${op}"}` : tool + return requireCaller(ctx, allowed, label, client) +} diff --git a/src/kernel/permission.ts b/src/kernel/permission.ts new file mode 100644 index 0000000..b4a23ff --- /dev/null +++ b/src/kernel/permission.ts @@ -0,0 +1,38 @@ +/** + * OpenCode permission 匹配纯函数(spec §7.2)。 + * + * 语义与 OpenCode 当前实现对齐: + * - bash 模式规则:pattern 支持 `*` 通配(`*` 兜底、`cmd*` 前缀匹配) + * - 最后匹配优先:后声明的规则覆盖先声明的同命中规则 + * - auto 模式:仅 allow 放行,deny/ask 均拒绝 + */ + +export type PermissionAction = "allow" | "deny" | "ask" + +/** pattern 是否命中 command:`*` 全匹配,`前缀*` 前缀匹配,否则精确匹配 */ +export function permissionPatternMatches(pattern: string, command: string): boolean { + if (pattern === "*") return true + if (pattern.endsWith("*")) return command.startsWith(pattern.slice(0, -1)) + return command === pattern +} + +/** + * 按规则集解析 command 的最终动作。 + * 按声明顺序遍历,命中即记录,后命中覆盖先前 → 最后匹配优先。 + * 无任何命中返回 "ask"(默认询问,auto 模式下等效拒绝)。 + */ +export function matchPermission(rules: Record, command: string): PermissionAction { + let action: PermissionAction = "ask" + for (const [pattern, raw] of Object.entries(rules)) { + if (!permissionPatternMatches(pattern, command)) continue + action = raw === "allow" ? "allow" : "deny" + } + return action +} + +/** + * auto 模式门禁:仅 allow 放行;deny 与 ask(无命中)均拒绝。 + */ +export function isAllowedInAutoMode(rules: Record, command: string): boolean { + return matchPermission(rules, command) === "allow" +} diff --git a/src/plugin/agents.ts b/src/plugin/agents.ts index a43f552..23c8004 100644 --- a/src/plugin/agents.ts +++ b/src/plugin/agents.ts @@ -2,23 +2,11 @@ import { readFileSync, readdirSync, existsSync } from "node:fs" import path from "node:path" import { parse as parseYaml } from "yaml" -export interface AgentTools { - read?: boolean - bash?: boolean - write?: boolean - edit?: boolean -} - -export interface AgentCapabilities { - create_pr: boolean - merge_pr: boolean - modify_files: boolean - run_tests: boolean - push_branch: boolean - approve_review: boolean - complete_goal: boolean -} - +/** + * Agent frontmatter 的 permission 解析。 + * 值可为字符串(如 "deny"、"npm test|git push")或嵌套规则对象 + * (OpenCode 语义:pattern → action,如 bash: { "*": "deny", "npm *": "allow" })。 + */ export interface AgentPermission { [key: string]: string | Record | undefined } @@ -28,8 +16,6 @@ export interface AgentEntry { description?: string mode?: "subagent" | "primary" | "all" color?: string - tools?: AgentTools - capabilities?: AgentCapabilities permission?: AgentPermission prompt: string } @@ -53,31 +39,6 @@ function parseAgentFile(filePath: string): AgentEntry | null { const name = String(parsed.name ?? "") if (!name) return null - const toolsRaw = parsed.tools - const tools: AgentTools | undefined = - toolsRaw && typeof toolsRaw === "object" && !Array.isArray(toolsRaw) - ? { - read: Boolean((toolsRaw as Record).read), - bash: Boolean((toolsRaw as Record).bash), - write: Boolean((toolsRaw as Record).write), - edit: Boolean((toolsRaw as Record).edit), - } - : undefined - - const capabilitiesRaw = parsed.capabilities - const capabilities: AgentCapabilities | undefined = - capabilitiesRaw && typeof capabilitiesRaw === "object" && !Array.isArray(capabilitiesRaw) - ? { - create_pr: Boolean((capabilitiesRaw as Record).create_pr), - merge_pr: Boolean((capabilitiesRaw as Record).merge_pr), - modify_files: Boolean((capabilitiesRaw as Record).modify_files), - run_tests: Boolean((capabilitiesRaw as Record).run_tests), - push_branch: Boolean((capabilitiesRaw as Record).push_branch), - approve_review: Boolean((capabilitiesRaw as Record).approve_review), - complete_goal: Boolean((capabilitiesRaw as Record).complete_goal), - } - : undefined - const permissionRaw = parsed.permission const permission: AgentPermission | undefined = permissionRaw && typeof permissionRaw === "object" && !Array.isArray(permissionRaw) @@ -103,8 +64,6 @@ function parseAgentFile(filePath: string): AgentEntry | null { description: parsed.description as string | undefined, mode: parsed.mode as AgentEntry["mode"], color: parsed.color as string | undefined, - tools, - capabilities, permission, prompt: body, } diff --git a/src/plugin/server.ts b/src/plugin/server.ts index b9891cf..2f0b251 100644 --- a/src/plugin/server.ts +++ b/src/plugin/server.ts @@ -7,7 +7,7 @@ import { initBootstrap, getBootstrapContent } from "./bootstrap.js" import { loadCommands } from "./commands.js" import { setupSkillsDir } from "./skills.js" import { loadAgents } from "./agents.js" -import { createIsolatedShellEnv, detectAmbientCredentials } from "./shell.js" +import { createAgentShellEnv } from "./shell.js" import { createGoalClient, createGoalTool, readGoal, writeGoal, MAX_CONTINUATIONS, continuationPrompt, formatGoal } from "./goal.js" import { readIndex, updateFlowSession } from "../kernel/session-index.js" import { FlowBroker } from "./broker.js" @@ -462,15 +462,6 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { config: async (rawConfig) => { const config = rawConfig as Record - // ── Ambient credential 检测 ── - const ambientReport = detectAmbientCredentials() - if (ambientReport.hasWriteCredentials) { - console.warn( - "[cabbage] ⚠️ 检测到可用 GitHub 写凭证,Runtime enforcement 已降级为 advisory:", - ambientReport.sources.map(s => s.location).join(", "), - ) - } - config.skills = config.skills || {} config.skills.paths = config.skills.paths || [] if (!config.skills.paths.includes(skillsDir)) { @@ -497,10 +488,9 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { mode: agent.mode, color: agent.color, prompt: agent.prompt, - tools: agent.tools ?? { read: true, bash: true, edit: true }, permission: agent.permission, shell: { - env: createIsolatedShellEnv(agent), + env: createAgentShellEnv(), }, } } diff --git a/src/plugin/shell.ts b/src/plugin/shell.ts index 9feae12..69d93b4 100644 --- a/src/plugin/shell.ts +++ b/src/plugin/shell.ts @@ -1,179 +1,17 @@ -import fs from "node:fs" -import path from "node:path" -import os from "node:os" -import type { AgentEntry } from "./agents.js" - -// ─── 类型 ─── - -export interface AmbientCredentialSource { - source: string - /** "env" | "config_file" | "credential_helper" */ - kind: "env" | "config_file" | "credential_helper" - /** 具体位置(如 "GH_TOKEN" 或 "~/.config/gh/hosts.yml") */ - location: string -} - -export interface AmbientCredentialReport { - hasWriteCredentials: boolean - hasGitCredentialHelper: boolean - sources: AmbientCredentialSource[] -} - -// ─── 凭证检测 ─── - -const GITHUB_WRITE_TOKEN_PATTERNS = [ - /^ghp_/, // classic PAT - /^github_pat_/, // fine-grained PAT - /^gho_/, // OAuth token - /^ghu_/, // user-to-server token - /^ghs_/, // server-to-server token - /^ghr_/, // refresh token -] - -/** - * 检测 token 值是否看起来是 GitHub 写凭证。 - */ -function looksLikeGithubToken(value: string): boolean { - const trimmed = value.trim() - if (!trimmed) return false - return GITHUB_WRITE_TOKEN_PATTERNS.some(p => p.test(trimmed)) -} - -/** - * 检测环境变量中的 GitHub API 凭证。 - */ -function detectEnvTokens(): AmbientCredentialSource[] { - const sources: AmbientCredentialSource[] = [] - const keys = ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN"] - - for (const key of keys) { - const value = process.env[key] - if (value && looksLikeGithubToken(value)) { - sources.push({ - source: key, - kind: "env", - location: `$${key}`, - }) - } - } - - return sources -} - -/** - * 检测 ~/.config/gh/hosts.yml 中的 oauth_token。 - */ -function detectGhHostsConfig(): AmbientCredentialSource[] { - const sources: AmbientCredentialSource[] = [] - const home = process.env.HOME || os.homedir() - const hostsPath = path.join(home, ".config", "gh", "hosts.yml") - - try { - if (!fs.existsSync(hostsPath)) return sources - - const content = fs.readFileSync(hostsPath, "utf8") - // 简单检测 oauth_token 字段 - if (/oauth_token\s*:\s*\S+/.test(content)) { - sources.push({ - source: "gh_hosts_config", - kind: "config_file", - location: hostsPath, - }) - } - } catch { - // 读取失败时静默忽略 - } - - return sources -} +// ─── Shell 环境 ─── /** - * 检测 git credential helper 配置。 - * 检查 ~/.gitconfig 中是否配置了 credential.helper。 - */ -function detectGitCredentialHelper(): { hasHelper: boolean; sources: AmbientCredentialSource[] } { - const sources: AmbientCredentialSource[] = [] - const home = process.env.HOME || os.homedir() - const gitconfigPath = path.join(home, ".gitconfig") - - let hasHelper = false - - try { - if (!fs.existsSync(gitconfigPath)) return { hasHelper, sources } - - const content = fs.readFileSync(gitconfigPath, "utf8") - if (/credential\s*\]\s*\n\s*helper\s*=/.test(content) || /\[credential\s+"[^"]*"\]/.test(content)) { - hasHelper = true - sources.push({ - source: "git_credential_helper", - kind: "credential_helper", - location: gitconfigPath, - }) - } - } catch { - // 读取失败静默忽略 - } - - return { hasHelper, sources } -} - -/** - * 检测宿主环境中的 GitHub 写凭证。 + * 为 Agent 生成 shell 环境变量。 * - * 检查内容: - * - GH_TOKEN / GITHUB_TOKEN / GH_ENTERPRISE_TOKEN 环境变量 - * - ~/.config/gh/hosts.yml(GitHub CLI OAuth token) - * - ~/.gitconfig 中的 credential helper - * - * 注意:这只能检测常见模式,不是完备的安全审计。 + * 复用宿主 gh auth:不再清空 GH_TOKEN/GITHUB_TOKEN、不替换 HOME/GH_CONFIG_DIR, + * Agent shell 内只读 gh 可用(PRD R4「模型可直接只读 git/gh」)。 + * 写操作凭据只存在于插件进程(util/gh.ts 继承 process.env),Agent shell 永不获得。 */ -export function detectAmbientCredentials(): AmbientCredentialReport { - const envSources = detectEnvTokens() - const ghConfigSources = detectGhHostsConfig() - const { hasHelper, sources: helperSources } = detectGitCredentialHelper() - - const allSources = [...envSources, ...ghConfigSources, ...helperSources] - const hasWriteCredentials = allSources.length > 0 - +export function createAgentShellEnv(): Record { return { - hasWriteCredentials, - hasGitCredentialHelper: hasHelper, - sources: allSources, + // 防系统 gitconfig 干扰 + GIT_CONFIG_NOSYSTEM: "1", + // 禁止交互式凭据提示(失败即失败,不挂起等待输入) + GIT_TERMINAL_PROMPT: "0", } } - -// ─── Shell 隔离 ─── - -/** - * 为 Worker agent 创建隔离 shell 环境变量。 - * - * 隔离措施: - * - HOME 设为临时目录(避免访问宿主 ~/.ssh, ~/.gitconfig 等) - * - GH_CONFIG_DIR 设为临时目录下的隔离路径 - * - GH_TOKEN / GITHUB_TOKEN 显式清空 - * - GIT_CONFIG_PARAMETERS 不传递(避免继承宿主 git config) - * - * 重要:shell.env 只作为附加措施,不作为唯一隔离边界。 - * 真正的权限控制来自 agent permission 字段 + ambient credential 检测。 - */ -export function createIsolatedShellEnv(agent: AgentEntry): Record { - const shellHome = fs.mkdtempSync(path.join(os.tmpdir(), "cabbage-shell-")) - const ghConfigDir = path.join(shellHome, ".config", "gh") - fs.mkdirSync(ghConfigDir, { recursive: true }) - - const env: Record = { - HOME: shellHome, - GH_CONFIG_DIR: ghConfigDir, - } - - // 清除所有 GitHub API token 环境变量 - // shell.env 是附加措施 — 主要隔离由 agent permission 提供 - env.GH_TOKEN = "" - env.GITHUB_TOKEN = "" - env.GH_ENTERPRISE_TOKEN = "" - - // 不传递宿主 git config(隔离 HOME 已处理此问题,但显式清空更安全) - env.GIT_CONFIG_NOSYSTEM = "1" - - return env -} diff --git a/test/agents.test.ts b/test/agents.test.ts index 84af8d8..b13a2a8 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -60,34 +60,6 @@ describe("loadAgents", () => { expect(result[0].prompt).toContain("You are a test agent") }) - it("extracts tools from frontmatter", () => { - writeAgent("readonly", "subagent", "tools:\n read: true\n bash: false\n write: false\n edit: false") - const result = loadAgents(tmpDir) - expect(result).toHaveLength(1) - expect(result[0].tools).toEqual({ - read: true, - bash: false, - write: false, - edit: false, - }) - }) - - it("defaults tools to undefined when not specified", () => { - writeAgent("no-tools", "primary") - const result = loadAgents(tmpDir) - expect(result[0].tools).toBeUndefined() - }) - - it("handles partial tools specification", () => { - writeAgent("partial", "subagent", "tools:\n read: true") - const result = loadAgents(tmpDir) - expect(result[0].tools).toBeDefined() - expect(result[0].tools!.read).toBe(true) - expect(result[0].tools!.bash).toBe(false) - expect(result[0].tools!.write).toBe(false) - expect(result[0].tools!.edit).toBe(false) - }) - it("loads agents from both root and team dir", () => { writeAgent("root-agent", "primary") writeTeamAgent("team-agent") @@ -113,6 +85,20 @@ describe("loadAgents", () => { expect(result[0].color).toBe("#abc") }) + it("ignores deprecated tools frontmatter(tools 布尔已废弃)", () => { + writeAgent("tools-agent", "primary", "tools:\n read: true\n bash: true\n write: true\n edit: true") + const result = loadAgents(tmpDir) + expect(result).toHaveLength(1) + expect(result[0].tools).toBeUndefined() + }) + + it("ignores deprecated capabilities frontmatter(capabilities 已废弃)", () => { + writeAgent("cap-agent", "primary", "capabilities:\n create_pr: false\n merge_pr: false\n modify_files: true") + const result = loadAgents(tmpDir) + expect(result).toHaveLength(1) + expect(result[0].capabilities).toBeUndefined() + }) + it("skips files without frontmatter", () => { const filePath = path.join(tmpDir, "no-frontmatter.md") fs.writeFileSync(filePath, "Just some text without frontmatter", "utf8") @@ -141,12 +127,7 @@ describe("dev-lifecycle prompt", () => { describe("Agent permission parsing", () => { it("parses permission with string values", () => { - writeAgent("perm-agent", "subagent", `tools: - read: true - bash: true - write: true - edit: true -permission: + writeAgent("perm-agent", "subagent", `permission: bash: "npm test|git push|npm run build" write: ".worktree/" edit: "src/,test/,assets/"`) @@ -159,12 +140,7 @@ permission: }) it("parses permission with deny values", () => { - writeAgent("deny-agent", "subagent", `tools: - read: true - bash: true - write: false - edit: false -permission: + writeAgent("deny-agent", "subagent", `permission: bash: "gh pr view|diff|checks" write: deny edit: deny`) @@ -190,24 +166,27 @@ permission: expect(result[0].permission!.edit).toBeUndefined() }) - it("keeps capabilities field for lint usage", () => { - writeAgent("cap-agent", "subagent", `capabilities: - create_pr: false - merge_pr: false - modify_files: true - run_tests: true - push_branch: true - approve_review: false - complete_goal: false -permission: - bash: "npm test|git push" - write: ".worktree/" - edit: "src/,test/"`) + it("parses nested permission rule objects(OpenCode 语义:pattern → action)", () => { + writeAgent("nested-perm", "subagent", `permission: + bash: + "*": "deny" + "npm *": "allow" + "git status*": "allow" + "git push*": "deny" + edit: + "*": "deny" + ".worktree/**": "allow"`) const result = loadAgents(tmpDir) - expect(result[0].capabilities).toBeDefined() - expect(result[0].capabilities!.modify_files).toBe(true) - expect(result[0].capabilities!.run_tests).toBe(true) - expect(result[0].capabilities!.create_pr).toBe(false) - expect(result[0].permission).toBeDefined() + expect(result).toHaveLength(1) + expect(result[0].permission!.bash).toEqual({ + "*": "deny", + "npm *": "allow", + "git status*": "allow", + "git push*": "deny", + }) + expect(result[0].permission!.edit).toEqual({ + "*": "deny", + ".worktree/**": "allow", + }) }) }) diff --git a/test/kernel/caller-matrix.test.ts b/test/kernel/caller-matrix.test.ts new file mode 100644 index 0000000..923631a --- /dev/null +++ b/test/kernel/caller-matrix.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "vitest" +import { + rolesForTool, + requireToolCaller, + LIFECYCLE_TOOLS, + type CallerRole, + type CallerContext, + type CallerSessionClient, +} from "../../src/kernel/caller.js" + +/** 构造最小 session client:按 sessionID 返回预设 parentID */ +function sessionClient(parentBySession: Record): CallerSessionClient { + return { + session: { + async get({ sessionID }) { + return { data: { parentID: parentBySession[sessionID] ?? null } } + }, + }, + } +} + +/** 子会话 ctx:agent 名已知,parentID 存在 */ +function childCtx(agent: string): CallerContext { + return { agent, sessionID: "sess_child" } +} + +/** 无 parentID → primary(无论 agent 名) */ +const primaryCtx: CallerContext = { agent: "dev-lifecycle", sessionID: "sess_root" } + +const ALL_ROLES: CallerRole[] = ["primary", "developer", "architect", "reviewer", "goal-verify"] + +describe("rolesForTool — 工具×角色矩阵(spec §2.2 / PRD R4)", () => { + it("枚举 5 个生命周期工具", () => { + expect(LIFECYCLE_TOOLS).toEqual([ + "setup_control", + "flow_control", + "task_control", + "tdd_checkpoint", + "release_control", + ]) + }) + + it("primary 全通过:5 个工具全部允许", () => { + for (const tool of LIFECYCLE_TOOLS) { + expect(rolesForTool(tool)).toContain("primary") + } + }) + + it("developer 仅 tdd_checkpoint", () => { + for (const tool of LIFECYCLE_TOOLS) { + const allowed = rolesForTool(tool).includes("developer") + expect(allowed).toBe(tool === "tdd_checkpoint") + } + }) + + it("reviewer 无任何工具", () => { + for (const tool of LIFECYCLE_TOOLS) { + expect(rolesForTool(tool)).not.toContain("reviewer") + } + }) + + it("goal-verify 无默认工具权限(仅通过 op 覆盖获得 complete-flow)", () => { + for (const tool of LIFECYCLE_TOOLS) { + expect(rolesForTool(tool)).not.toContain("goal-verify") + } + }) + + it("architect 无默认工具权限(仅通过 op 覆盖获得 status 只读)", () => { + for (const tool of LIFECYCLE_TOOLS) { + expect(rolesForTool(tool)).not.toContain("architect") + } + }) + + it("flow_control.complete-flow 仅 goal-verify", () => { + expect(rolesForTool("flow_control", "complete-flow")).toEqual(["goal-verify"]) + }) + + it("flow_control.status 允许 primary + architect(architect 只读)", () => { + expect(rolesForTool("flow_control", "status")).toEqual(["primary", "architect"]) + }) + + it("未知工具保守拒绝:返回空角色集", () => { + expect(rolesForTool("unknown_tool")).toEqual([]) + }) +}) + +describe("requireToolCaller — 5 工具 × 5 角色全组合", () => { + const client = sessionClient({ sess_child: "parent", sess_root: null }) + + it.each(ALL_ROLES)("primary 角色调用全部工具通过", async role => { + // primary 由无 parentID 的 session 解析 + const ctx: CallerContext = { agent: "dev-lifecycle", sessionID: "sess_root" } + for (const tool of LIFECYCLE_TOOLS) { + await expect(requireToolCaller(ctx, tool, undefined, client)).resolves.toBeNull() + } + }) + + it.each(ALL_ROLES.filter(r => r !== "primary"))("非 primary 角色 %s 仅按矩阵放行", async role => { + const agentByRole: Record = { + developer: "developer", + architect: "architect", + reviewer: "reviewer", + "goal-verify": "goal-verify", + } + const ctx = childCtx(agentByRole[role]) + for (const tool of LIFECYCLE_TOOLS) { + const expectedAllow = rolesForTool(tool).includes(role) + const result = await requireToolCaller(ctx, tool, undefined, client) + if (expectedAllow) { + expect(result).toBeNull() + } else { + expect(result).not.toBeNull() + expect(result).toContain("CALLER_NOT_AUTHORIZED") + } + } + }) + + it("goal-verify 仅 flow_control.complete-flow 通过", async () => { + const ctx = childCtx("goal-verify") + await expect(requireToolCaller(ctx, "flow_control", "complete-flow", client)).resolves.toBeNull() + await expect(requireToolCaller(ctx, "flow_control", "stage-start", client)).resolves.not.toBeNull() + await expect(requireToolCaller(ctx, "task_control", "create-task", client)).resolves.not.toBeNull() + }) + + it("architect 仅 flow_control.status 通过(其余工具与 op 拒绝)", async () => { + const ctx = childCtx("architect") + await expect(requireToolCaller(ctx, "flow_control", "status", client)).resolves.toBeNull() + await expect(requireToolCaller(ctx, "flow_control", "create-flow", client)).resolves.not.toBeNull() + await expect(requireToolCaller(ctx, "task_control", "create-task", client)).resolves.not.toBeNull() + await expect(requireToolCaller(ctx, "tdd_checkpoint", "red", client)).resolves.not.toBeNull() + }) + + it("developer 仅 tdd_checkpoint 通过", async () => { + const ctx = childCtx("developer") + await expect(requireToolCaller(ctx, "tdd_checkpoint", "red", client)).resolves.toBeNull() + await expect(requireToolCaller(ctx, "task_control", "create-task", client)).resolves.not.toBeNull() + await expect(requireToolCaller(ctx, "setup_control", "probe", client)).resolves.not.toBeNull() + }) + + it("reviewer 全部拒绝(无工具)", async () => { + const ctx = childCtx("reviewer") + for (const tool of LIFECYCLE_TOOLS) { + await expect(requireToolCaller(ctx, tool, undefined, client)).resolves.not.toBeNull() + } + }) +}) diff --git a/test/kernel/permission.test.ts b/test/kernel/permission.test.ts new file mode 100644 index 0000000..c3ad097 --- /dev/null +++ b/test/kernel/permission.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest" +import { matchPermission, isAllowedInAutoMode } from "../../src/kernel/permission.js" + +/** + * OpenCode permission 语义(spec §7.2 / PRD 假设 2): + * bash 模式规则 + 最后匹配优先 + auto 模式 deny 生效。 + */ + +describe("matchPermission — 最后匹配优先", () => { + it("无匹配返回 ask(默认询问)", () => { + expect(matchPermission({ "npm *": "allow" }, "rm -rf /")).toBe("ask") + }) + + it("白名单前缀匹配:git status* 命中 git status 与带参数", () => { + const rules = { "*": "deny", "git status*": "allow" } + expect(matchPermission(rules, "git status")).toBe("allow") + expect(matchPermission(rules, "git status --short")).toBe("allow") + }) + + it("deny 置尾覆盖同前缀 allow:git push 被拒", () => { + const rules = { + "*": "deny", + "git push*": "deny", + "git status*": "allow", + "git log*": "allow", + } + expect(matchPermission(rules, "git push origin main")).toBe("deny") + expect(matchPermission(rules, "git status")).toBe("allow") + }) + + it("最后匹配优先:后面的规则覆盖前面的同前缀规则", () => { + // allow 在 deny 之后 → allow 生效 + const rules = { "git *": "deny", "git status*": "allow" } + expect(matchPermission(rules, "git status")).toBe("allow") + // deny 在 allow 之后 → deny 生效 + const rules2 = { "git *": "allow", "git push*": "deny" } + expect(matchPermission(rules2, "git push origin")).toBe("deny") + }) + + it("兜底 * 规则拦截所有未显式 allow 的命令", () => { + const rules = { + "*": "deny", + "npm *": "allow", + "git status*": "allow", + } + expect(matchPermission(rules, "npm test")).toBe("allow") + expect(matchPermission(rules, "git push origin")).toBe("deny") + expect(matchPermission(rules, "gh pr create")).toBe("deny") + }) + + it("高风险写命令全部 deny(黑名单完整性)", () => { + const rules = { + "*": "deny", + "git push*": "deny", + "git worktree *": "deny", + "git checkout -b*": "deny", + "gh pr create*": "deny", + "gh pr merge*": "deny", + "gh pr review*": "deny", + "gh issue create*": "deny", + "gh issue edit*": "deny", + "gh issue close*": "deny", + "gh issue comment*": "deny", + "gh label*": "deny", + "gh release*": "deny", + } + for (const cmd of [ + "git push origin main", + "git worktree add", + "git worktree remove --force", + "git checkout -b feat/x", + "gh pr create", + "gh pr merge 12", + "gh pr review 12 --approve", + "gh issue create", + "gh issue edit 5", + "gh issue close 5", + "gh issue comment 5 --body hi", + "gh label create", + "gh release create v1", + ]) { + expect(matchPermission(rules, cmd)).toBe("deny") + } + }) + + it("只读 gh 白名单 allow", () => { + const rules = { + "*": "deny", + "gh pr view*": "allow", + "gh pr diff*": "allow", + "gh pr checks*": "allow", + "gh issue view*": "allow", + } + expect(matchPermission(rules, "gh pr view 12")).toBe("allow") + expect(matchPermission(rules, "gh pr diff 12")).toBe("allow") + expect(matchPermission(rules, "gh pr checks 12")).toBe("allow") + expect(matchPermission(rules, "gh issue view 5")).toBe("allow") + }) +}) + +describe("isAllowedInAutoMode — auto 模式 deny 生效", () => { + it("仅 allow 放行,deny 与 ask 均拒绝", () => { + const rules = { "*": "deny", "npm *": "allow" } + expect(isAllowedInAutoMode(rules, "npm test")).toBe(true) + expect(isAllowedInAutoMode(rules, "git push")).toBe(false) + expect(isAllowedInAutoMode(rules, "unknown-cmd")).toBe(false) + }) + + it("无兜底规则时未匹配命令(ask)在 auto 模式被拒绝", () => { + const rules = { "npm *": "allow" } + expect(isAllowedInAutoMode(rules, "npm test")).toBe(true) + expect(isAllowedInAutoMode(rules, "git status")).toBe(false) + }) +}) diff --git a/test/plugin/server-permissions.test.ts b/test/plugin/server-permissions.test.ts index 2b3e1f0..2bee12f 100644 --- a/test/plugin/server-permissions.test.ts +++ b/test/plugin/server-permissions.test.ts @@ -1,7 +1,28 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" import { configureGoalTools } from "../../src/plugin/server.js" import type { AgentEntry } from "../../src/plugin/agents.js" +const mockSessionGet = vi.fn() +const mockPromptAsync = vi.fn() + +vi.mock("../../src/plugin/goal.js", async () => { + const actual = await vi.importActual("../../src/plugin/goal.js") + return { + ...actual, + createGoalClient: () => ({ + session: { + get: (...args: unknown[]) => mockSessionGet(...args), + update: vi.fn(), + promptAsync: (...args: unknown[]) => mockPromptAsync(...args), + }, + }), + } +}) + interface TestConfig { tools?: Record agent: Record }> @@ -110,3 +131,77 @@ describe("reviewer permission enforcement", () => { expect(config.agent.architect.tools?.goal).toBe(false) }) }) + +describe("server config hook — agent 注入(permission 规则,无 tools 布尔)", () => { + const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") + + let tmpDir: string + let plugin: { + config: (config: unknown) => Promise + } + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "cabbage-server-perm-")) + process.env.CABBAGE_SKILLS_DIR = path.join(tmpDir, "skills") + mockSessionGet.mockReset() + mockPromptAsync.mockReset() + + const ctx = { + worktree: tmpDir, + directory: tmpDir, + client: { _client: { getConfig: () => ({}) } }, + serverUrl: new URL("http://localhost:0"), + } + const hooks = await createOpencodeCabbage(projectRoot)(ctx as never, {}) + plugin = hooks as unknown as { config: (config: unknown) => Promise } + }) + + afterEach(async () => { + delete process.env.CABBAGE_SKILLS_DIR + await rm(tmpDir, { recursive: true, force: true }) + }) + + it("注入的 agent 保留 permission 规则,不再注入 tools 布尔", async () => { + const config: Record = { agent: {} } + await plugin.config(config) + + const agent = config.agent["architect"] + expect(agent).toBeDefined() + expect(agent.permission).toBeDefined() + expect(agent.permission.bash).toBeDefined() + // agent frontmatter 的 tools 布尔已废弃:不注入 read/bash/write/edit + // (goal 布尔由 configureGoalTools 作为 config 层工具开关注入,属预期) + expect(agent.tools?.read).toBeUndefined() + expect(agent.tools?.bash).toBeUndefined() + expect(agent.tools?.write).toBeUndefined() + expect(agent.tools?.edit).toBeUndefined() + }) + + it("注入的 agent shell env 使用 createAgentShellEnv(保留 GH 凭据、禁交互提示)", async () => { + const config: Record = { agent: {} } + await plugin.config(config) + + const agent = config.agent["dev-lifecycle"] + expect(agent.shell.env).toEqual({ + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + }) + }) + + it("用户 config 已定义的 agent 不被覆盖", async () => { + const config: Record = { + agent: { + "dev-lifecycle": { permission: { bash: { "*": "allow" } } }, + }, + } + await plugin.config(config) + + expect(config.agent["dev-lifecycle"].permission).toEqual({ bash: { "*": "allow" } }) + expect(config.agent["dev-lifecycle"].description).toBeUndefined() + }) +}) + +// 由 createOpencodeCabbage 的返回类型推断(避免显式 import 插件类型) +import { createOpencodeCabbage } from "../../src/plugin/server.js" + +export type { AgentEntry } diff --git a/test/plugin/shell.test.ts b/test/plugin/shell.test.ts index 3ef119b..a19c04f 100644 --- a/test/plugin/shell.test.ts +++ b/test/plugin/shell.test.ts @@ -1,381 +1,48 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" -import fs from "node:fs" -import path from "node:path" -import os from "node:os" -import { - createIsolatedShellEnv, - detectAmbientCredentials, -} from "../../src/plugin/shell.js" -import { FlowBroker, type BrokerCredentials } from "../../src/plugin/broker.js" +import { describe, it, expect } from "vitest" +import { createAgentShellEnv } from "../../src/plugin/shell.js" import type { AgentEntry } from "../../src/plugin/agents.js" -// ─── 辅助工厂 ─── - function makeAgent(overrides: Partial = {}): AgentEntry { return { key: "test-agent", mode: "subagent", prompt: "You are a test agent.", - tools: { read: true, bash: true, write: true, edit: true }, permission: { - bash: "npm test|npm run|git push|git add|git commit", - write: ".worktree/", - edit: "src/,test/", + bash: { + "*": "deny", + "npm *": "allow", + "git status*": "allow", + }, }, ...overrides, } } -function makeWorkerAgent(): AgentEntry { - return makeAgent({ - key: "backend", - description: "backend worker", - permission: { - bash: "npm test|npm run|git push|git add|git commit|git status|git diff|git log|git branch|git checkout", - write: ".worktree/", - edit: "src/,test/,assets/", - }, - }) -} - -function makeReviewerAgent(): AgentEntry { - return makeAgent({ - key: "reviewer", - description: "code reviewer", - tools: { read: true, bash: false, write: false, edit: false }, - capabilities: { - create_pr: false, - merge_pr: false, - modify_files: false, - run_tests: false, - push_branch: false, - approve_review: false, - complete_goal: false, - }, - permission: { - bash: "gh pr view|diff|checks", - write: "deny", - edit: "deny", - }, - }) -} - -// ─── createIsolatedShellEnv ─── - -describe("createIsolatedShellEnv", () => { - it("creates isolated HOME for worker agent", () => { - const agent = makeWorkerAgent() - const env = createIsolatedShellEnv(agent) - - expect(env.HOME).toBeDefined() - expect(env.HOME).not.toBe(os.homedir()) - expect(env.HOME).toContain("cabbage-shell-") - expect(env.GH_CONFIG_DIR).toBeDefined() - }) - - it("isolated HOME is a temporary directory", () => { - const agent = makeWorkerAgent() - const env = createIsolatedShellEnv(agent) - - expect(fs.existsSync(env.HOME!)).toBe(true) - expect(fs.statSync(env.HOME!).isDirectory()).toBe(true) - - // cleanup - fs.rmSync(env.HOME!, { recursive: true, force: true }) - }) - - it("blocks GH_TOKEN and GITHUB_TOKEN for worker", () => { - const agent = makeWorkerAgent() - const env = createIsolatedShellEnv(agent) - - expect(env.GH_TOKEN).toBe("") - expect(env.GITHUB_TOKEN).toBe("") - }) - - it("blocks gh write operations in worker shell", () => { - const agent = makeWorkerAgent() - const env = createIsolatedShellEnv(agent) - - // Worker should NOT have any gh write capability - expect(env.GH_TOKEN).toBeFalsy() - expect(env.GITHUB_TOKEN).toBeFalsy() - - // git credential config should only allow feature branch push - if (env.GIT_CONFIG_PARAMETERS) { - expect(env.GIT_CONFIG_PARAMETERS).not.toContain("gh") - } - }) - - it("reviewer has no GitHub write credentials", () => { - const agent = makeReviewerAgent() - const env = createIsolatedShellEnv(agent) - - expect(env.GH_TOKEN).toBe("") - expect(env.GITHUB_TOKEN).toBe("") - }) - - it("reviewer bash permission is restricted to read-only gh commands", () => { - const agent = makeReviewerAgent() - const env = createIsolatedShellEnv(agent) - - // Reviewer should only have gh pr view|diff|checks - // This is enforced via the agent's permission field, not env - // The shell env itself should not provide any write tokens - expect(env.GH_TOKEN || "").toBe("") - expect(env.GITHUB_TOKEN || "").toBe("") - }) - - it("sets isolated GH_CONFIG_DIR for worker", () => { - const agent = makeWorkerAgent() - const env = createIsolatedShellEnv(agent) - - expect(env.GH_CONFIG_DIR).toBeDefined() - expect(env.GH_CONFIG_DIR).not.toBe("") - expect(fs.existsSync(env.GH_CONFIG_DIR!)).toBe(true) - expect(fs.statSync(env.GH_CONFIG_DIR!).isDirectory()).toBe(true) - - // cleanup - if (env.HOME) fs.rmSync(env.HOME, { recursive: true, force: true }) - }) - - it("unsets ambient credential environment variables", () => { - const agent = makeWorkerAgent() - const env = createIsolatedShellEnv(agent) - - // GH_TOKEN / GITHUB_TOKEN / GH_ENTERPRISE_TOKEN must be explicitly unset - expect(env.GH_TOKEN).toBe("") - expect(env.GITHUB_TOKEN).toBe("") - expect(env.GH_ENTERPRISE_TOKEN).toBe("") - }) - - it("different agents get different isolated HOME", () => { - const agent1 = makeAgent({ key: "backend" }) - const agent2 = makeAgent({ key: "frontend" }) - const env1 = createIsolatedShellEnv(agent1) - const env2 = createIsolatedShellEnv(agent2) - - expect(env1.HOME).not.toBe(env2.HOME) - - // cleanup - if (env1.HOME) fs.rmSync(env1.HOME, { recursive: true, force: true }) - if (env2.HOME) fs.rmSync(env2.HOME, { recursive: true, force: true }) - }) - - it("shell.env is supplementary not primary isolation", () => { - // shell.env 只是附加措施,不作为唯一隔离边界 - // 主要隔离由 permission 字段 + ambient credential 检测提供 - const agent = makeWorkerAgent() - const env = createIsolatedShellEnv(agent) +describe("createAgentShellEnv", () => { + it("保留宿主 GitHub 凭据环境:不再清空 GH_TOKEN / GITHUB_TOKEN / GH_ENTERPRISE_TOKEN", () => { + const env = createAgentShellEnv(makeAgent()) - // env should contain isolation vars but the real enforcement - // comes from the permission system and ambient detection - expect(Object.keys(env).length).toBeGreaterThan(0) - - // cleanup - if (env.HOME) fs.rmSync(env.HOME, { recursive: true, force: true }) - }) -}) - -// ─── detectAmbientCredentials ─── - -describe("detectAmbientCredentials", () => { - const originalEnv = { ...process.env } - - beforeEach(() => { - // 清理所有 GitHub 相关环境变量 - delete process.env.GH_TOKEN - delete process.env.GITHUB_TOKEN - delete process.env.GH_ENTERPRISE_TOKEN - }) - - afterEach(() => { - process.env = { ...originalEnv } - }) - - it("detects GH_TOKEN in environment", () => { - process.env.GH_TOKEN = "ghp_fake123" - const report = detectAmbientCredentials() - - expect(report.hasWriteCredentials).toBe(true) - expect(report.sources).toContainEqual( - expect.objectContaining({ source: "GH_TOKEN" }) - ) - }) - - it("detects GITHUB_TOKEN in environment", () => { - process.env.GITHUB_TOKEN = "ghp_fake456" - const report = detectAmbientCredentials() - - expect(report.hasWriteCredentials).toBe(true) - expect(report.sources).toContainEqual( - expect.objectContaining({ source: "GITHUB_TOKEN" }) - ) - }) - - it("detects GH_ENTERPRISE_TOKEN in environment", () => { - process.env.GH_ENTERPRISE_TOKEN = "ghp_fake789" - const report = detectAmbientCredentials() - - expect(report.hasWriteCredentials).toBe(true) - expect(report.sources).toContainEqual( - expect.objectContaining({ source: "GH_ENTERPRISE_TOKEN" }) - ) - }) - - it("returns no write credentials when env is clean", () => { - const report = detectAmbientCredentials() - - expect(report.hasWriteCredentials).toBe(false) - expect(report.sources).toHaveLength(0) - }) - - it("detects gh hosts.yml config file", () => { - // Create fake gh config - const originalHome = process.env.HOME - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "cabbage-test-gh-")) - const ghConfigDir = path.join(tmpHome, ".config", "gh") - fs.mkdirSync(ghConfigDir, { recursive: true }) - - // Write hosts.yml with a token - const hostsYml = `github.com: - user: testuser - oauth_token: ghp_fakeConfigToken -` - fs.writeFileSync(path.join(ghConfigDir, "hosts.yml"), hostsYml, "utf8") - - try { - process.env.HOME = tmpHome - const report = detectAmbientCredentials() - - expect(report.hasWriteCredentials).toBe(true) - expect(report.sources).toContainEqual( - expect.objectContaining({ source: "gh_hosts_config" }) - ) - } finally { - process.env.HOME = originalHome || "" - fs.rmSync(tmpHome, { recursive: true, force: true }) - } - }) - - it("detects git credential helper configuration", () => { - const originalHome = process.env.HOME - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "cabbage-test-git-")) - const gitConfigPath = path.join(tmpHome, ".gitconfig") - - // Write gitconfig with credential helper - const gitConfig = `[credential] - helper = cache --timeout=3600 -` - fs.writeFileSync(gitConfigPath, gitConfig, "utf8") - - try { - process.env.HOME = tmpHome - const report = detectAmbientCredentials() - - // git credential helper itself is not a write credential - // (it's a helper program, not a stored token) - // but it COULD be used to access stored credentials - expect(report.hasGitCredentialHelper).toBe(true) - } finally { - process.env.HOME = originalHome || "" - fs.rmSync(tmpHome, { recursive: true, force: true }) - } - }) - - it("reports empty when HOME/.config/gh does not exist", () => { - const originalHome = process.env.HOME - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "cabbage-test-empty-")) - try { - process.env.HOME = tmpHome - const report = detectAmbientCredentials() - - // no tokens, no gh config → clean - expect(report.hasWriteCredentials).toBe(false) - expect(report.hasGitCredentialHelper).toBe(false) - } finally { - process.env.HOME = originalHome || "" - fs.rmSync(tmpHome, { recursive: true, force: true }) - } + expect(env.GH_TOKEN).toBeUndefined() + expect(env.GITHUB_TOKEN).toBeUndefined() + expect(env.GH_ENTERPRISE_TOKEN).toBeUndefined() }) - it("detects both env token and config file", () => { - process.env.GH_TOKEN = "ghp_envToken" + it("不再替换 HOME / GH_CONFIG_DIR,复用宿主 gh auth", () => { + const env = createAgentShellEnv(makeAgent()) - const originalHome = process.env.HOME - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "cabbage-test-both-")) - const ghConfigDir = path.join(tmpHome, ".config", "gh") - fs.mkdirSync(ghConfigDir, { recursive: true }) - fs.writeFileSync( - path.join(ghConfigDir, "hosts.yml"), - "github.com:\n oauth_token: ghp_configToken\n", - "utf8" - ) - - try { - process.env.HOME = tmpHome - const report = detectAmbientCredentials() - - expect(report.hasWriteCredentials).toBe(true) - expect(report.sources.length).toBeGreaterThanOrEqual(2) - expect(report.sources).toContainEqual( - expect.objectContaining({ source: "GH_TOKEN" }) - ) - expect(report.sources).toContainEqual( - expect.objectContaining({ source: "gh_hosts_config" }) - ) - } finally { - process.env.HOME = originalHome || "" - fs.rmSync(tmpHome, { recursive: true, force: true }) - } + expect(env.HOME).toBeUndefined() + expect(env.GH_CONFIG_DIR).toBeUndefined() }) -}) - -// ─── broker token 隔离测试 ─── - -describe("broker token 与 Agent Shell 隔离", () => { - it("createIsolatedShellEnv 不包含 broker 使用的 token env var", () => { - const agent = makeWorkerAgent() - const env = createIsolatedShellEnv(agent) - - // 确保隔离 shell 中没有 broker 凭证 - expect(env.CABBAGE_BROKER_TOKEN).toBeUndefined() - // GH_TOKEN/GITHUB_TOKEN 已被显式清空 - expect(env.GH_TOKEN || "").toBe("") - expect(env.GITHUB_TOKEN || "").toBe("") - }) - - it("broker token 不通过任何环境变量泄露到 agent shell", () => { - const agent = makeWorkerAgent() - const env = createIsolatedShellEnv(agent) - - // 遍历所有 env key,确保不包含 token 格式的值 - for (const val of Object.values(env)) { - if (typeof val === "string" && val.length > 0) { - // 不应出现任何 GitHub token 格式 - expect(val).not.toMatch(/^ghp_/) - expect(val).not.toMatch(/^github_pat_/) - } - } - }) - - it("broker credentials 只存在于 FlowBroker 实例内存中", () => { - // 创建 broker 后检查 process.env 未被修改 - const beforeEnv = { ...process.env } - const broker = new FlowBroker({ token: "ghp_memory_only" }) - // process.env 不应被 broker 构造修改 - expect(process.env.GH_TOKEN).toBe(beforeEnv.GH_TOKEN) - expect(process.env.GITHUB_TOKEN).toBe(beforeEnv.GITHUB_TOKEN) + it("保留防系统 gitconfig 干扰的 GIT_CONFIG_NOSYSTEM=1", () => { + const env = createAgentShellEnv(makeAgent()) - // broker 实例本身不应在外部可枚举属性中暴露 token - expect(JSON.stringify(broker)).not.toContain("ghp_memory_only") + expect(env.GIT_CONFIG_NOSYSTEM).toBe("1") }) - it("createIsolatedShellEnv 对 reviewer agent 同样不泄露 broker token", () => { - const agent = makeReviewerAgent() - const env = createIsolatedShellEnv(agent) + it("设置 GIT_TERMINAL_PROMPT=0 禁止交互式凭据提示", () => { + const env = createAgentShellEnv(makeAgent()) - expect(env.GH_TOKEN || "").toBe("") - expect(env.GITHUB_TOKEN || "").toBe("") + expect(env.GIT_TERMINAL_PROMPT).toBe("0") }) }) From c2044e41443e8306d6acd35a4f1e8162d7bdeb34 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:55:05 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(kernel):=20permission-model=20=E2=80=94?= =?UTF-8?q?=20configureLifecycleTools=20=E7=AC=AC=E4=BA=8C=E9=81=93?= =?UTF-8?q?=E9=97=A8=20+=20=C2=A77.4=20=E6=B5=8B=E8=AF=95=20+=20=E6=94=B6?= =?UTF-8?q?=E7=B4=A7=20agent=20permission=EF=BC=88=E5=86=99=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=20deny=EF=BC=89+=20capabilities=20=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/agents/dev-lifecycle.md | 14 +++++ assets/agents/team/backend.md | 29 +++++---- assets/agents/team/frontend.md | 29 +++++---- src/kernel/permission.ts | 2 +- src/plugin/server.ts | 37 +++++++++++ test/plugin/prompt-lint.test.ts | 14 ++--- test/plugin/server-permissions.test.ts | 87 +++++++++++++++++++++++++- 7 files changed, 170 insertions(+), 42 deletions(-) diff --git a/assets/agents/dev-lifecycle.md b/assets/agents/dev-lifecycle.md index 0842928..795d9fd 100644 --- a/assets/agents/dev-lifecycle.md +++ b/assets/agents/dev-lifecycle.md @@ -3,6 +3,20 @@ name: dev-lifecycle description: 全流程开发编排器 — 需求确认后自动完成设计→任务拆解→并行实现→审查→自动合并 mode: primary color: '#00bcd4' +permission: + bash: + "*": "allow" + "git push*": "deny" + "git worktree remove*": "deny" + "git worktree add*": "deny" + "git checkout -b*": "deny" + "git tag*": "deny" + "gh pr create*": "deny" + "gh pr merge*": "deny" + "gh issue close*": "deny" + "gh issue create*": "deny" + "gh release create*": "deny" + "npm publish*": "deny" --- diff --git a/assets/agents/team/backend.md b/assets/agents/team/backend.md index 8d8b7d0..3a091ec 100644 --- a/assets/agents/team/backend.md +++ b/assets/agents/team/backend.md @@ -3,24 +3,23 @@ name: backend description: 负责后端代码实现、接口开发、数据库设计和业务逻辑 mode: subagent color: '#4caf50' -tools: - read: true - bash: true - write: true - edit: true -capabilities: - create_pr: false - merge_pr: false - modify_files: true - run_tests: true - push_branch: true - approve_review: false - complete_goal: false permission: bash: "*": "deny" - "npm *": "allow" - "git *": "allow" + "*": "allow" + "git status*": "allow" + "git diff*": "allow" + "git log*": "allow" + "git show*": "allow" + "git rev-parse*": "allow" + "git ls-files*": "allow" + "git branch --merged*": "allow" + "git add*": "allow" + "git commit*": "allow" + "git push*": "deny" + "git worktree*": "deny" + "git checkout -b*": "deny" + "git tag*": "deny" edit: "*": "deny" ".worktree/**": "allow" diff --git a/assets/agents/team/frontend.md b/assets/agents/team/frontend.md index 1b26391..3511a96 100644 --- a/assets/agents/team/frontend.md +++ b/assets/agents/team/frontend.md @@ -3,24 +3,23 @@ name: frontend description: 负责前端页面开发、交互实现、组件封装和接口对接 mode: subagent color: '#2196f3' -tools: - read: true - bash: true - write: true - edit: true -capabilities: - create_pr: false - merge_pr: false - modify_files: true - run_tests: true - push_branch: true - approve_review: false - complete_goal: false permission: bash: "*": "deny" - "npm *": "allow" - "git *": "allow" + "*": "allow" + "git status*": "allow" + "git diff*": "allow" + "git log*": "allow" + "git show*": "allow" + "git rev-parse*": "allow" + "git ls-files*": "allow" + "git branch --merged*": "allow" + "git add*": "allow" + "git commit*": "allow" + "git push*": "deny" + "git worktree*": "deny" + "git checkout -b*": "deny" + "git tag*": "deny" edit: "*": "deny" ".worktree/**": "allow" diff --git a/src/kernel/permission.ts b/src/kernel/permission.ts index b4a23ff..2a18bdf 100644 --- a/src/kernel/permission.ts +++ b/src/kernel/permission.ts @@ -25,7 +25,7 @@ export function matchPermission(rules: Record, command: string): let action: PermissionAction = "ask" for (const [pattern, raw] of Object.entries(rules)) { if (!permissionPatternMatches(pattern, command)) continue - action = raw === "allow" ? "allow" : "deny" + action = raw === "allow" ? "allow" : raw === "deny" ? "deny" : "ask" } return action } diff --git a/src/plugin/server.ts b/src/plugin/server.ts index 2f0b251..d0697ce 100644 --- a/src/plugin/server.ts +++ b/src/plugin/server.ts @@ -76,6 +76,42 @@ export function configureGoalTools(config: GoalToolConfig): void { } } +/** + * config 层第二道门(spec §2.2):按 caller 矩阵把 5 个生命周期工具 + * 对每个 agent 置 true/false。agent 名 → 角色映射与 caller.ts ROLE_BY_AGENT 一致。 + */ +export function configureLifecycleTools(config: GoalToolConfig): void { + const lifecycleTools = ["setup_control", "flow_control", "task_control", "tdd_checkpoint", "release_control"] as const + const agentRole = (name: string): string => { + switch (name) { + case "dev-lifecycle": return "primary" + case "developer": return "developer" + case "architect": return "architect" + case "reviewer": return "reviewer" + case "goal-verify": return "goal-verify" + default: return "reviewer" + } + } + const canUseTool = (role: string, tool: string): boolean => { + if (role === "goal-verify") return tool === "flow_control" + switch (tool) { + case "tdd_checkpoint": return role === "primary" || role === "developer" + default: return role === "primary" + } + } + + for (const [agentName, agent] of Object.entries(config.agent ?? {})) { + if (!agent || typeof agent !== "object" || Array.isArray(agent)) continue + const agentConfig = agent as AgentToolConfig + const role = agentRole(agentName) + const toolFlags: Record = {} + for (const toolName of lifecycleTools) { + toolFlags[toolName] = canUseTool(role, toolName) + } + agentConfig.tools = { ...agentConfig.tools, ...toolFlags } + } +} + async function queueContinuation( client: ReturnType, sessionID: string, @@ -496,6 +532,7 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { } configureGoalTools(config) + configureLifecycleTools(config) }, "experimental.chat.messages.transform": async (_input, output) => { diff --git a/test/plugin/prompt-lint.test.ts b/test/plugin/prompt-lint.test.ts index 7acab50..9266e0e 100644 --- a/test/plugin/prompt-lint.test.ts +++ b/test/plugin/prompt-lint.test.ts @@ -46,15 +46,10 @@ describe("prompt-lint", () => { describe("prompt-lint: agent permission rules", () => { it("warns when agent frontmatter is missing permission field", () => { - // dev-lifecycle.md has no permission field + // 批 12 后所有 agent 均已声明 permission —— 不应有 missing-permission 告警 const { findings } = lintAll(PROJECT_ROOT) const missing: LintFinding[] = findings.filter((f: LintFinding) => f.rule === "missing-permission") - expect(missing.length).toBeGreaterThanOrEqual(1) - for (const m of missing) { - expect(m.severity).toBe("warn") - } - const devLifecycle = missing.find((m: LintFinding) => m.file.includes("dev-lifecycle")) - expect(devLifecycle).toBeDefined() + expect(missing.length).toBe(0) }) it("errors when worker has gh pr create|merge in permission.bash", () => { @@ -85,8 +80,7 @@ describe("prompt-lint: agent permission rules", () => { const permRules = findings.filter((f: LintFinding) => ["missing-permission", "worker-gh-write-permission", "reviewer-write-permission", "capability-permission-mismatch"].includes(f.rule) ) - // We should have findings for the permission rules - // At minimum: architect.md missing permission - expect(permRules.length).toBeGreaterThanOrEqual(1) + // 批 12 后所有 agent 均合规(permission 齐全、无 worker 写 gh、reviewer 只读、capabilities 已删) + expect(permRules.length).toBe(0) }) }) diff --git a/test/plugin/server-permissions.test.ts b/test/plugin/server-permissions.test.ts index 2bee12f..f988571 100644 --- a/test/plugin/server-permissions.test.ts +++ b/test/plugin/server-permissions.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises" import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" -import { configureGoalTools } from "../../src/plugin/server.js" +import { configureGoalTools, configureLifecycleTools } from "../../src/plugin/server.js" import type { AgentEntry } from "../../src/plugin/agents.js" const mockSessionGet = vi.fn() @@ -132,6 +132,54 @@ describe("reviewer permission enforcement", () => { }) }) +describe("configureLifecycleTools(config 层第二道门)", () => { + it("primary(dev-lifecycle)获得全部 5 个生命周期工具", () => { + const config: TestConfig = { agent: { "dev-lifecycle": {} } } + configureLifecycleTools(config) + const tools = config.agent["dev-lifecycle"].tools + expect(tools?.setup_control).toBe(true) + expect(tools?.flow_control).toBe(true) + expect(tools?.task_control).toBe(true) + expect(tools?.tdd_checkpoint).toBe(true) + expect(tools?.release_control).toBe(true) + }) + + it("developer 仅获得 tdd_checkpoint", () => { + const config: TestConfig = { agent: { developer: {} } } + configureLifecycleTools(config) + const tools = config.agent.developer.tools + expect(tools?.tdd_checkpoint).toBe(true) + expect(tools?.setup_control).toBe(false) + expect(tools?.flow_control).toBe(false) + expect(tools?.task_control).toBe(false) + expect(tools?.release_control).toBe(false) + }) + + it("reviewer/architect 无任何生命周期工具", () => { + const config: TestConfig = { agent: { reviewer: {}, architect: {} } } + configureLifecycleTools(config) + for (const name of ["reviewer", "architect"]) { + const tools = config.agent[name].tools + expect(tools?.setup_control).toBe(false) + expect(tools?.flow_control).toBe(false) + expect(tools?.task_control).toBe(false) + expect(tools?.tdd_checkpoint).toBe(false) + expect(tools?.release_control).toBe(false) + } + }) + + it("goal-verify 仅获得 flow_control(complete-flow)", () => { + const config: TestConfig = { agent: { "goal-verify": {} } } + configureLifecycleTools(config) + const tools = config.agent["goal-verify"].tools + expect(tools?.flow_control).toBe(true) + expect(tools?.tdd_checkpoint).toBe(false) + expect(tools?.setup_control).toBe(false) + expect(tools?.task_control).toBe(false) + expect(tools?.release_control).toBe(false) + }) +}) + describe("server config hook — agent 注入(permission 规则,无 tools 布尔)", () => { const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") @@ -188,6 +236,43 @@ describe("server config hook — agent 注入(permission 规则,无 tools }) }) + it("§7.4 — 注入后的 agent permission 中高风险写命令均不在 allow 集合(deny/ask 置尾生效)", async () => { + const config: Record = { agent: {} } + await plugin.config(config) + + const writeCommands = [ + "git push origin feat/x", + "git worktree remove .worktree/x", + "git checkout -b feat/x", + "git tag v1.0.0", + "gh pr create --title x", + "gh pr merge 1", + "gh issue close 1", + "gh issue create --title x", + "gh release create v1.0.0", + ] + const allowOnly = (rules: Record, command: string): boolean => { + let action = "ask" + for (const [pattern, raw] of Object.entries(rules)) { + if (pattern === "*") { action = raw === "allow" ? "allow" : raw === "deny" ? "deny" : "ask"; continue } + if (pattern.endsWith("*") && command.startsWith(pattern.slice(0, -1))) { + action = raw === "allow" ? "allow" : raw === "deny" ? "deny" : "ask" + } + } + return action === "allow" + } + + // 每个 agent 的 bash permission:所有写命令均不得被 allow + for (const name of Object.keys(config.agent)) { + const agent = config.agent[name] + const bashRules = agent?.permission?.bash as Record | undefined + if (!bashRules) continue + for (const cmd of writeCommands) { + expect(allowOnly(bashRules, cmd), `${name} 不应 allow: ${cmd}`).toBe(false) + } + } + }) + it("用户 config 已定义的 agent 不被覆盖", async () => { const config: Record = { agent: { From e98e5a8dfcb7c51fbd4acee51aa45fbc5203cc97 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:10:48 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(plugin):=20permission-model=20=E2=80=94?= =?UTF-8?q?=20=E6=B8=B2=E6=9F=93=20Profile=20test-command=20=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E7=AC=A6=20+=20reviewer=20gh=20pr=20diff=20+=20shell?= =?UTF-8?q?=20=E6=B3=A8=E9=87=8A=20+=20=E6=B5=8B=E8=AF=95=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=20matchPermission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/agents/team/reviewer.md | 2 ++ src/plugin/server.ts | 27 +++++++++++++++++++++++++- src/plugin/shell.ts | 6 ++++-- test/plugin/server-permissions.test.ts | 15 +++----------- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/assets/agents/team/reviewer.md b/assets/agents/team/reviewer.md index ecc0cf9..88d4036 100644 --- a/assets/agents/team/reviewer.md +++ b/assets/agents/team/reviewer.md @@ -21,6 +21,8 @@ permission: "*": "deny" "gh pr view": "allow" "gh pr view *": "allow" + "gh pr diff": "allow" + "gh pr diff *": "allow" "diff": "allow" "diff *": "allow" "gh pr checks": "allow" diff --git a/src/plugin/server.ts b/src/plugin/server.ts index d0697ce..5f49a61 100644 --- a/src/plugin/server.ts +++ b/src/plugin/server.ts @@ -22,6 +22,7 @@ import type { TaskExecutionBinding, FlowControlResponse } from "../flowrun/types import { readFlowRun } from "../flowrun/github.js" import { getContextBlock, formatContextBlock, CONTEXT_MARKER } from "../kernel/context.js" import type { ContextBlock } from "../kernel/context.js" +import { readProjectProfile } from "../kernel/profile.js" const abortedSessions = new Set() const errorRetryCount = new Map() @@ -517,6 +518,30 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { } config.agent = config.agent || {} + // 渲染 permission 占位符:`` → Profile.testCommand 首 token(§9.1) + const profile = await readProjectProfile(projectDir) + const testCommandToken = profile.testCommand?.split(" ")[0] ?? "" + const renderPermission = (perm: Record | undefined): Record | undefined => { + if (!perm) return perm + const renderRules = (rules: Record | undefined): Record | undefined => { + if (!rules) return rules + const out: Record = {} + for (const [pattern, action] of Object.entries(rules)) { + if (pattern.includes("") && testCommandToken === "") { + // Profile 未配置测试命令 → 删除占位规则,避免渲染成 "*" 覆盖兜底 deny + continue + } + out[pattern.replaceAll("", testCommandToken)] = action + } + return out + } + const out: Record = { ...perm } + if (perm.bash && typeof perm.bash === "object" && !Array.isArray(perm.bash)) { + out.bash = renderRules(perm.bash as Record) + } + return out + } + for (const agent of loadAgents(agentsDir)) { if (config.agent[agent.key]) continue config.agent[agent.key] = { @@ -524,7 +549,7 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { mode: agent.mode, color: agent.color, prompt: agent.prompt, - permission: agent.permission, + permission: renderPermission(agent.permission), shell: { env: createAgentShellEnv(), }, diff --git a/src/plugin/shell.ts b/src/plugin/shell.ts index 69d93b4..c75ea30 100644 --- a/src/plugin/shell.ts +++ b/src/plugin/shell.ts @@ -4,8 +4,10 @@ * 为 Agent 生成 shell 环境变量。 * * 复用宿主 gh auth:不再清空 GH_TOKEN/GITHUB_TOKEN、不替换 HOME/GH_CONFIG_DIR, - * Agent shell 内只读 gh 可用(PRD R4「模型可直接只读 git/gh」)。 - * 写操作凭据只存在于插件进程(util/gh.ts 继承 process.env),Agent shell 永不获得。 + * Agent shell 内只读 gh/git 可用(PRD R4「模型可直接只读 git/gh」)。 + * 安全边界:写操作凭据与模型 shell 共享宿主 auth,因此**写操作靠 permission 规则 + * (deny 置尾、最后匹配优先、auto 模式 deny 生效)收敛到生命周期工具**, + * 而非依赖凭据隔离——见 spec §7.3 的取舍说明。 */ export function createAgentShellEnv(): Record { return { diff --git a/test/plugin/server-permissions.test.ts b/test/plugin/server-permissions.test.ts index f988571..b78e9dd 100644 --- a/test/plugin/server-permissions.test.ts +++ b/test/plugin/server-permissions.test.ts @@ -4,6 +4,7 @@ import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" import { configureGoalTools, configureLifecycleTools } from "../../src/plugin/server.js" +import { matchPermission } from "../../src/kernel/permission.js" import type { AgentEntry } from "../../src/plugin/agents.js" const mockSessionGet = vi.fn() @@ -251,24 +252,14 @@ describe("server config hook — agent 注入(permission 规则,无 tools "gh issue create --title x", "gh release create v1.0.0", ] - const allowOnly = (rules: Record, command: string): boolean => { - let action = "ask" - for (const [pattern, raw] of Object.entries(rules)) { - if (pattern === "*") { action = raw === "allow" ? "allow" : raw === "deny" ? "deny" : "ask"; continue } - if (pattern.endsWith("*") && command.startsWith(pattern.slice(0, -1))) { - action = raw === "allow" ? "allow" : raw === "deny" ? "deny" : "ask" - } - } - return action === "allow" - } - // 每个 agent 的 bash permission:所有写命令均不得被 allow + // 每个 agent 的 bash permission:所有写命令均不得被 allow(复用 matchPermission,最后匹配优先) for (const name of Object.keys(config.agent)) { const agent = config.agent[name] const bashRules = agent?.permission?.bash as Record | undefined if (!bashRules) continue for (const cmd of writeCommands) { - expect(allowOnly(bashRules, cmd), `${name} 不应 allow: ${cmd}`).toBe(false) + expect(matchPermission(bashRules, cmd), `${name} 不应 allow: ${cmd}`).not.toBe("allow") } } })