From 80e578b10018f41d42a8f1709e26f455bcfc6703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AF=E5=9F=BA=E9=AD=81?= <1412414664@qq.com> Date: Wed, 8 Jul 2026 03:22:32 +0800 Subject: [PATCH] fix: guard openspec plan bash permissions --- package.json | 1 + src/config.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++++++++ src/config.ts | 74 ++++++++++++++++++++++++++--------------- 3 files changed, 130 insertions(+), 27 deletions(-) create mode 100644 src/config.test.ts diff --git a/package.json b/package.json index b5289c6..f5c1480 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ ], "scripts": { "build": "bun build ./src/index.ts --outfile ./dist/index.js --target node", + "test": "bun test", "watch": "bun build ./src/index.ts --outfile ./dist/index.js --target node --watch", "prepublishOnly": "bun run build" }, diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..5f44380 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createConfigHook } from "./config"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function createOpenSpecProject() { + const dir = await mkdtemp(join(tmpdir(), "openspec-plugin-")); + tempDirs.push(dir); + await mkdir(join(dir, "openspec"), { recursive: true }); + await writeFile(join(dir, "openspec", "config.yaml"), "project: test\n"); + return dir; +} + +async function loadOpenSpecAgent() { + const directory = await createOpenSpecProject(); + const config: Record = {}; + await createConfigHook({ directory })!(config); + return config.agent["openspec-plan"]; +} + +function wildcardMatch(input: string, pattern: string) { + let escaped = pattern + .replaceAll("\\", "/") + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + + if (escaped.endsWith(" .*")) { + escaped = escaped.slice(0, -3) + "( .*)?"; + } + + return new RegExp(`^${escaped}$`, "s").test(input.replaceAll("\\", "/")); +} + +function evaluate(rules: Record, value: string) { + return ( + Object.entries(rules) + .filter(([pattern]) => wildcardMatch(value, pattern)) + .at(-1)?.[1] ?? "ask" + ); +} + +describe("createConfigHook", () => { + test("keeps implementation files read-only while allowing OpenSpec documents", async () => { + const agent = await loadOpenSpecAgent(); + + expect(evaluate(agent.permission.edit, "src/app.ts")).toBe("deny"); + expect(evaluate(agent.permission.edit, "project.md")).toBe("allow"); + expect(evaluate(agent.permission.edit, "AGENTS.md")).toBe("allow"); + expect(evaluate(agent.permission.edit, "openspec/AGENTS.md")).toBe("allow"); + expect(evaluate(agent.permission.edit, "specs/feature/spec.md")).toBe("allow"); + }); + + test("denies bash write bypasses without blocking read-only exploration", async () => { + const agent = await loadOpenSpecAgent(); + const bash = agent.permission.bash; + + expect(evaluate(bash, "cat README.md")).toBe("allow"); + expect(evaluate(bash, "echo ready")).toBe("allow"); + expect(evaluate(bash, "find specs -name '*.md'")).toBe("allow"); + expect(evaluate(bash, "git diff -- specs/feature/spec.md")).toBe("allow"); + expect(evaluate(bash, "openspec new change add-login")).toBe("allow"); + + expect(evaluate(bash, "cat > src/app.ts")).toBe("deny"); + expect(evaluate(bash, "echo ready > src/app.ts")).toBe("deny"); + expect(evaluate(bash, "tee src/app.ts")).toBe("deny"); + expect(evaluate(bash, "cp specs/a.md src/app.ts")).toBe("deny"); + expect(evaluate(bash, "mv specs/a.md src/app.ts")).toBe("deny"); + expect(evaluate(bash, "rm src/app.ts")).toBe("deny"); + expect(evaluate(bash, "mkdir src/generated")).toBe("deny"); + expect(evaluate(bash, "find . -delete")).toBe("deny"); + expect(evaluate(bash, "find . -exec rm {} ;")).toBe("deny"); + expect(evaluate(bash, "git diff > src/app.patch")).toBe("deny"); + }); +}); diff --git a/src/config.ts b/src/config.ts index 4c66f67..5936bc9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,51 @@ import type { Hooks } from "@opencode-ai/plugin"; import { isOpenSpecProject } from "./utils/detection"; import { OPENSPEC_SYSTEM_PROMPT } from "./prompts"; +const OPEN_SPEC_EDIT_PERMISSIONS = { + "*": "deny", + "project.md": "allow", + "AGENTS.md": "allow", + "openspec/**": "allow", + "specs/**": "allow" +} as const; + +const OPEN_SPEC_BASH_PERMISSIONS = { + "*": "deny", + "openspec": "allow", + "openspec *": "allow", + "grep *": "allow", + "ls": "allow", + "ls *": "allow", + "cat *": "allow", + "find *": "allow", + "echo": "allow", + "echo *": "allow", + "pwd": "allow", + "which *": "allow", + "env": "allow", + "printenv *": "allow", + "git status*": "allow", + "git log*": "allow", + "git diff*": "allow", + "git show*": "allow", + + // Rules are last-match-wins. Keep write-capable shell forms after the + // read-oriented allow list so they cannot bypass OpenSpec planning scope. + "*>*": "deny", + "tee *": "deny", + "cp *": "deny", + "mv *": "deny", + "rm *": "deny", + "mkdir *": "deny", + "touch *": "deny", + "chmod *": "deny", + "chown *": "deny", + "find * -delete*": "deny", + "find * -exec*": "deny", + "find * -execdir*": "deny", + "xargs *": "deny" +} as const; + export function createConfigHook(ctx: { directory: string }): Hooks["config"] { return async (config) => { // 1. Check if this is an OpenSpec project @@ -51,35 +96,10 @@ export function createConfigHook(ctx: { directory: string }): Hooks["config"] { // --- Edit: deny everything, allow only spec files --- // Rules are evaluated last-match-wins, so "*": "deny" must come first - edit: { - "*": "deny", - "project.md": "allow", - "AGENTS.md": "allow", - "openspec/**": "allow", - "specs/**": "allow" - }, + edit: OPEN_SPEC_EDIT_PERMISSIONS, // --- Bash: deny all by default, allow read-only filesystem + git read --- - bash: { - "*": "deny", - "openspec": "allow", - "openspec *": "allow", - "grep *": "allow", - "ls": "allow", - "ls *": "allow", - "cat *": "allow", - "find *": "allow", - "echo": "allow", - "echo *": "allow", - "pwd": "allow", - "which *": "allow", - "env": "allow", - "printenv *": "allow", - "git status*": "allow", - "git log*": "allow", - "git diff*": "allow", - "git show*": "allow" - } + bash: OPEN_SPEC_BASH_PERMISSIONS }, color: "#FF6B6B" // Distinctive color for the agent };