Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions test/adversarial/01-bad-slug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* 对抗测试 #1 — 坏 slug(低质量模型典型输入):
* `Task 001` / `task-1` / `implement-task` / 纯序号等必须被 validateTitle 拒绝,
* 且 create-flow / create-task 工具不得继续创建(返回对应错误码,不做任何 gh 写调用)。
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { validateTitle, deriveSlug } from "../../src/kernel/slug.js"
import { createFlow } from "../../src/kernel/flow.js"
import { createFlowControlTool } from "../../src/plugin/flow-control.js"
import { createTaskControlTool } from "../../src/plugin/task-control.js"
import { setFlowGhExecutor } from "../../src/kernel/flow.js"
import { setRecordsGhExecutor } from "../../src/kernel/records.js"
import { setTaskGhExecutor, setTaskGitExecutor } from "../../src/kernel/task.js"

const BAD_TITLES: Array<{ title: string; code: string }> = [
{ title: "Task 001", code: "INVALID_FORMAT" },
{ title: "task-1", code: "GENERIC_TITLE" },
{ title: "implement-task", code: "GENERIC_TITLE" },
{ title: "fix-bug", code: "GENERIC_TITLE" },
{ title: "123", code: "GENERIC_TITLE" },
{ title: "Validate-execution-binding", code: "INVALID_FORMAT" },
]

const primaryClient = {
session: {
get: async () => ({ data: { parentID: null } }),
update: async () => {},
},
}

function makeCtx(agent: string, sessionID: string) {
return { agent, sessionID, messageID: "m1", directory: ".", worktree: "." }
}

describe("adversarial #1 — 坏 slug 必须被拒绝", () => {
beforeEach(() => {
// 所有 gh/git 写调用都必须抛错:若校验失效会走到真实写路径 → 测试失败
setFlowGhExecutor(() => {
throw new Error("unexpected flow gh: gate should reject before any call")
})
setRecordsGhExecutor(() => {
throw new Error("unexpected records gh: gate should reject before any call")
})
setTaskGhExecutor(() => {
throw new Error("unexpected task gh: gate should reject before any call")
})
setTaskGitExecutor(() => {
throw new Error("unexpected task git: gate should reject before any call")
})
})

afterEach(() => {
setFlowGhExecutor(null)
setRecordsGhExecutor(null)
setTaskGhExecutor(null)
setTaskGitExecutor(null)
})

it.each(BAD_TITLES)("validateTitle 拒绝 %s → %s", ({ title, code }) => {
const result = validateTitle(title)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe(code)
}
})

it("deriveSlug 对无意义输入返回空串(调用方据此判定,不做兜底)", () => {
expect(deriveSlug("Task 001")).toBe("task-001")
expect(deriveSlug("!!!")).toBe("")
})

it("createFlow(kernel 层)对坏标题返回对应错误码且不写任何 gh", async () => {
const dir = await mkdtemp(join(tmpdir(), "cabbage-adv-slug-"))
try {
const result = await createFlow({ projectDir: dir, title: "Task 001", sessionID: "s" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe("INVALID_FORMAT")
} finally {
await rm(dir, { recursive: true, force: true })
}
})

it("create-flow 工具拒绝坏标题(INVALID_FORMAT)", async () => {
const t = createFlowControlTool({ projectDir: ".", sessionClient: primaryClient })
const out = await t.execute({ op: "create-flow", title: "implement-task" }, makeCtx("dev-lifecycle", "sess-1") as never)
const resp = JSON.parse(String(out)) as { ok: boolean; error?: { code: string } }
expect(resp.ok).toBe(false)
expect(resp.error?.code).toBe("GENERIC_TITLE")
})

it("create-task 工具拒绝坏标题(INVALID_FORMAT)且不做任何 gh 写调用", async () => {
const t = createTaskControlTool({ projectDir: ".", sessionClient: primaryClient })
const out = await t.execute(
{ op: "create-task", parent_issue_number: 12, title: "Task 001" },
makeCtx("dev-lifecycle", "sess-1") as never,
)
const resp = JSON.parse(String(out)) as { ok: boolean; error?: { code: string } }
expect(resp.ok).toBe(false)
expect(resp.error?.code).toBe("INVALID_FORMAT")
})
})
102 changes: 102 additions & 0 deletions test/adversarial/02-worktree-start.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* 对抗测试 #2 — 非法 branch/worktree 路径:
* 分支已存在未合并 / 已合并残留 / 目录检出错误分支 → worktree-start 必须拒绝,
* 且任何 git 调用不得出现 --force(不做暴力覆盖)。
*/
import { describe, it, expect, beforeEach, afterEach, beforeAll } from "vitest"
import { mkdtemp, mkdir, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { worktreeStart } from "../../src/kernel/worktree.js"
import type { setWorktreeGitExecutor as SetGitExecutorType } from "../../src/kernel/worktree.js"

type GitFn = (args: string) => Promise<{ stdout: string; stderr: string }>

let setGit: typeof SetGitExecutorType
let projectDir: string

beforeAll(async () => {
const mod = await import("../../src/kernel/worktree.js")
setGit = mod.setWorktreeGitExecutor
})

beforeEach(async () => {
projectDir = await mkdtemp(path.join(os.tmpdir(), "cabbage-adv-worktree-"))
setGit(() => {
throw new Error("unexpected git call")
})
})

afterEach(async () => {
setGit(null)
await rm(projectDir, { recursive: true, force: true })
})

describe("adversarial #2 — 非法 branch/worktree 路径必须被拒绝", () => {
it("分支已存在未合并 → BRANCH_EXISTS_UNMERGED,不执行 worktree add,无 --force", async () => {
const calls: string[] = []
setGit(async (args) => {
calls.push(args)
if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc123", stderr: "" }
if (args.includes("merge-base --is-ancestor")) throw new Error("not an ancestor")
throw new Error(`unexpected git call: ${args}`)
})

const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe("BRANCH_EXISTS_UNMERGED")
expect(calls.some(c => c.startsWith("worktree add"))).toBe(false)
for (const c of calls) expect(c).not.toContain("--force")
})

it("分支已存在且已合并(残留)→ BRANCH_EXISTS,要求人工清理而非强制复用", async () => {
const calls: string[] = []
setGit(async (args) => {
calls.push(args)
if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc123", stderr: "" }
if (args.includes("merge-base --is-ancestor")) return { stdout: "", stderr: "" }
throw new Error(`unexpected git call: ${args}`)
})

const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" })
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe("BRANCH_EXISTS")
expect(result.message).toMatch(/manually/i)
}
expect(calls.some(c => c.startsWith("worktree add"))).toBe(false)
})

it("目录已存在但检出错误分支 → BRANCH_MISMATCH,拒绝复用", async () => {
await mkdir(path.join(projectDir, ".worktree/user-auth-login"), { recursive: true })
const calls: string[] = []
setGit(async (args) => {
calls.push(args)
if (args.includes("rev-parse --abbrev-ref HEAD")) return { stdout: "main", stderr: "" }
throw new Error(`unexpected git call: ${args}`)
})

const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe("BRANCH_MISMATCH")
expect(calls.some(c => c.startsWith("worktree add"))).toBe(false)
})

it("模型无法通过路径逃逸绕过:目录/分支不一致一律拒绝,不落任何写命令", async () => {
// 恶意提交 "./.worktree/../../other-dir" 式的路径由 slug 约束([a-z0-9-])阻断,
// 这里验证 worktreeStart 对已存在目录的严格校验本身不产生 --force
await mkdir(path.join(projectDir, ".worktree/user-auth-login"), { recursive: true })
const calls: string[] = []
setGit(async (args) => {
calls.push(args)
if (args.includes("rev-parse --abbrev-ref HEAD")) return { stdout: "feat/other", stderr: "" }
throw new Error(`unexpected git call: ${args}`)
})

const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe("BRANCH_MISMATCH")
for (const c of calls) expect(c).not.toContain("--force")
expect(calls.some(c => c.startsWith("worktree add"))).toBe(false)
})
})
164 changes: 164 additions & 0 deletions test/adversarial/03-submit-no-red-evidence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* 对抗测试 #3 — 缺 RED evidence 直接提交 PR:
* 低质量模型跳过 TDD 直接 submit-task → 必须被 TDD_EVIDENCE_INCOMPLETE 拒绝,
* 且不得创建 PR(无 gh pr create / git push)。
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import { mkdtemp, mkdir, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { submitTask, setTaskGhExecutor, setTaskGitExecutor, freezeTddPolicy } from "../../src/kernel/task.js"
import { setRecordsGhExecutor } from "../../src/kernel/records.js"
import { setWorktreeGitExecutor } from "../../src/kernel/worktree.js"
import { setMergeGhExecutor } from "../../src/kernel/review.js"

function writeTaskState(dir: string) {
return (async () => {
const { writeFile } = await import("node:fs/promises")
await mkdir(join(dir, ".opencode", "opencode-cabbage", "task-state"), { recursive: true })
await writeFile(
join(dir, ".opencode", "opencode-cabbage", "task-state", "user-auth-login.json"),
JSON.stringify({
slug: "user-auth-login",
parentIssueNumber: 12,
issueNumber: 13,
branch: "feat/user-auth-login",
worktreePath: join(dir, ".worktree", "user-auth-login"),
policy: freezeTddPolicy({
testCommand: "cabbage-test-tool run",
regressionCommand: null,
testFilePatterns: [],
implementationFilePatterns: [],
tddDefaultMode: "strict",
versionBumpRule: null,
versionFile: null,
tagFormat: null,
releaseWorkflowPath: null,
riskPatterns: [],
}),
baseline: {
digest: { algorithm: "sha256-content-v1", value: "abc" },
capturedAt: new Date().toISOString(),
testFilePatterns: [],
implementationFilePatterns: [],
},
startedAt: new Date().toISOString(),
}),
"utf8",
)
})()
}

function buildTaskBody(criteriaLine: string): string {
return [
"# Task: user-auth-login",
"",
"## Acceptance Criteria",
"",
criteriaLine,
"",
"## Dependencies",
"",
"- none",
"",
"## Status",
"",
"- [ ] running",
"- [ ] reviewing",
"- [ ] merged",
"",
].join("\n")
}

describe("adversarial #3 — 缺 RED evidence 直接 submit 必须被拒", () => {
beforeEach(async () => {
setTaskGhExecutor(() => {
throw new Error("unexpected task gh")
})
setTaskGitExecutor(() => {
throw new Error("unexpected task git")
})
setRecordsGhExecutor(() => {
throw new Error("unexpected records gh")
})
setWorktreeGitExecutor(() => {
throw new Error("unexpected worktree git")
})
setMergeGhExecutor(() => {
throw new Error("unexpected merge gh")
})
})

afterEach(() => {
setTaskGhExecutor(null)
setTaskGitExecutor(null)
setRecordsGhExecutor(null)
setWorktreeGitExecutor(null)
setMergeGhExecutor(() => {
throw new Error("unexpected merge gh")
})
})

it("完全没有 evidence comment → TDD_EVIDENCE_INCOMPLETE,且无任何写调用", async () => {
const dir = await mkdtemp(join(tmpdir(), "cabbage-adv-submit-"))
try {
await writeTaskState(dir)
const calls: string[] = []
setTaskGhExecutor(async args => {
calls.push(args)
if (args.includes("issue list")) return { stdout: "13", stderr: "" }
if (args.includes("issue view 13 --json body")) return { stdout: buildTaskBody("- [ ] c1: x [tdd]"), stderr: "" }
throw new Error(`unexpected task gh: ${args}`)
})
setRecordsGhExecutor(async args => {
if (args.includes("--json comments")) return { stdout: "null", stderr: "" }
throw new Error(`unexpected records gh: ${args}`)
})

const result = await submitTask(dir, 12, "user-auth-login")
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe("TDD_EVIDENCE_INCOMPLETE")
expect(result.missing).toContain("evidence-comment")
}
expect(calls.some(c => c.includes("pr create"))).toBe(false)
expect(calls.some(c => c.includes("push"))).toBe(false)
} finally {
await rm(dir, { recursive: true, force: true })
}
})

it("只有 RED 记录、无 final-regression/verification → 仍被拒(证据不完整)", async () => {
const dir = await mkdtemp(join(tmpdir(), "cabbage-adv-submit-"))
try {
await writeTaskState(dir)
const calls: string[] = []
setTaskGhExecutor(async args => {
calls.push(args)
if (args.includes("issue list")) return { stdout: "13", stderr: "" }
if (args.includes("issue view 13 --json body")) {
return { stdout: buildTaskBody("- [ ] c1: x [tdd]"), stderr: "" }
}
throw new Error(`unexpected task gh: ${args}`)
})
const redOnlyComment =
"<!-- cabbage-tdd-evidence:start --> revision:1\n### stage: red\n- criterion: c1\n- cycle: cyc-1\n- status: red\n<!-- cabbage-tdd-evidence:end -->"
setRecordsGhExecutor(async args => {
if (args.includes("--json comments")) {
return { stdout: JSON.stringify({ id: 5, body: redOnlyComment }), stderr: "" }
}
throw new Error(`unexpected records gh: ${args}`)
})

const result = await submitTask(dir, 12, "user-auth-login")
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe("TDD_EVIDENCE_INCOMPLETE")
expect(result.missing?.length ?? 0).toBeGreaterThan(0)
}
expect(calls.some(c => c.includes("pr create"))).toBe(false)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})
Loading
Loading