From 9832f66dc4359f00f7bcc81f3c91bff9cebdaad5 Mon Sep 17 00:00:00 2001 From: Terada Kousuke Date: Fri, 10 Apr 2026 01:09:56 +0900 Subject: [PATCH 1/2] refactor(guardrails): split guardrail.ts into 6 modules (Issue #150) Split monolithic guardrail.ts (1944 lines) into modular architecture: - guardrail-patterns.ts (225L): constants, pure utilities - guardrail-context.ts (343L): GuardrailContext type, createContext() - guardrail-review.ts (198L): auto-review pipeline, dual review gate - guardrail-git.ts (432L): branch hygiene, merge gates, CI checks - guardrail-access.ts (296L): file access control, delegation, tracking - guardrail.ts (568L): entry point, hook registration Architecture: Context Object + Handler Factories pattern. Dependency DAG: patterns <- context <- {review, git, access} <- entry. All files under 800-line limit. 34/34 scenario tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) Co-Authored-By: Codex CLI --- .../profile/plugins/guardrail-access.ts | 296 +++ .../profile/plugins/guardrail-context.ts | 343 ++++ .../profile/plugins/guardrail-git.ts | 432 +++++ .../profile/plugins/guardrail-patterns.ts | 225 +++ .../profile/plugins/guardrail-review.ts | 198 ++ .../guardrails/profile/plugins/guardrail.ts | 1626 ++--------------- 6 files changed, 1619 insertions(+), 1501 deletions(-) create mode 100644 packages/guardrails/profile/plugins/guardrail-access.ts create mode 100644 packages/guardrails/profile/plugins/guardrail-context.ts create mode 100644 packages/guardrails/profile/plugins/guardrail-git.ts create mode 100644 packages/guardrails/profile/plugins/guardrail-patterns.ts create mode 100644 packages/guardrails/profile/plugins/guardrail-review.ts diff --git a/packages/guardrails/profile/plugins/guardrail-access.ts b/packages/guardrails/profile/plugins/guardrail-access.ts new file mode 100644 index 000000000000..4427b8545c2d --- /dev/null +++ b/packages/guardrails/profile/plugins/guardrail-access.ts @@ -0,0 +1,296 @@ +import path from "path" +import { MUTATING_TOOLS, bash, cfg, has, json, list, num, pick, rel, sec, stash, str, text } from "./guardrail-patterns" +import type { GuardrailContext } from "./guardrail-context" + +export function createAccessHandlers(ctx: GuardrailContext) { + async function toolBeforeAccess( + item: { tool: string; args?: unknown; callID?: unknown }, + out: { args: Record }, + data?: Record, + ) { + const file = pick(out.args ?? item.args) + if (file && (item.tool === "read" || MUTATING_TOOLS.has(item.tool))) { + const err = ctx.deny(file, item.tool === "read" ? "read" : "edit") + if (err) { + await ctx.mark({ last_block: item.tool, last_file: rel(ctx.input.worktree, file), last_reason: err }) + throw new Error(text(err)) + } + } + + if (MUTATING_TOOLS.has(item.tool)) { + const err = await ctx.version(out.args ?? {}) + if (err) { + await ctx.mark({ last_block: item.tool, last_file: file ? rel(ctx.input.worktree, file) : "", last_reason: err }) + throw new Error(text(err)) + } + } + + if (MUTATING_TOOLS.has(item.tool) && file && ctx.code(file)) { + const count = await ctx.budget() + if (count >= 4) { + const budgetData = await stash(ctx.state) + const readFiles = list(budgetData.read_files).slice(-5).join(", ") + const err = `context budget exceeded after ${count} source reads (recent: ${readFiles || "unknown"}). Recovery options:\n(1) call \`team\` tool to delegate edit to isolated worker\n(2) use \`background\` tool for side work\n(3) narrow edit scope to a specific function/section rather than whole file\n(4) start a new session and continue from where you left off` + await ctx.mark({ last_block: item.tool, last_file: rel(ctx.input.worktree, file), last_reason: err }) + throw new Error(text(err)) + } + } + + if (item.tool === "bash") { + const cmd = typeof out.args?.command === "string" ? out.args.command : "" + const file = cmd.replaceAll("\\", "/") + if (!cmd) return + if (has(file, sec) || file.includes(".opencode/guardrails/")) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "shell access to protected files" }) + throw new Error(text("shell access to protected files")) + } + if (/\bdocker\s+build\b/i.test(cmd)) { + const secretPatterns = [ + /^(AKIA[A-Z0-9]{16})/, + /^(sk-[a-zA-Z0-9]{20,})/, + /^(ghp_[a-zA-Z0-9]{36})/, + /^(gho_[a-zA-Z0-9]{36})/, + /^(ghs_[a-zA-Z0-9]{36})/, + /^(glpat-[a-zA-Z0-9-]{20,})/, + /^(xox[bprs]-[a-zA-Z0-9-]+)/, + /^(npm_[a-zA-Z0-9]{36})/, + /BEGIN\s+(RSA|EC|PRIVATE)/, + ] + const buildArgMatches = cmd.matchAll(/--build-arg\s+(\w+)=(\S+)/gi) + for (const item of buildArgMatches) { + const argName = item[1].toUpperCase() + const argValue = item[2] + const nameHit = /(SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL|API_KEY|PRIVATE|AUTH)/i.test(argName) + const valueHit = secretPatterns.some((pattern) => pattern.test(argValue)) + if (nameHit || valueHit) { + await ctx.mark({ docker_secret_warning: true, docker_secret_arg: item[1], last_block: "bash", last_reason: "docker secret in build-arg" }) + await ctx.seen("docker.secret_in_build_arg", { arg_name: item[1], pattern: "redacted" }) + throw new Error(text("docker build --build-arg contains secrets: use Docker build secrets (--secret) or multi-stage builds instead")) + } + } + } + if (!bash(cmd)) return + if (!cfg.some((rule) => rule.test(file)) && !file.includes(".opencode/guardrails/")) return + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "protected runtime or config mutation" }) + throw new Error(text("protected runtime or config mutation")) + } + + if (item.tool === "write" && file) { + const relFile = rel(ctx.input.worktree, file) + const content = typeof out.args?.content === "string" ? out.args.content : "" + if (/seed_knowledge|knowledge\.(yaml|yml|json)$/i.test(relFile)) { + if (content && /(電話|phone|営業時間|hours|休[館日]|holiday|料金|price|住所|address)/i.test(content)) { + if (!/(verified|検証済|参照元|source:|ref:)/i.test(content)) { + await ctx.mark({ last_block: "write", last_file: relFile, last_reason: "seed data without verification source" }) + throw new Error(text("knowledge/seed data write blocked: content contains factual claims without verification source. Add 'verified' or 'source:' comment.")) + } + } + } + } + + if (item.tool === "task") { + const taskData = data ?? await stash(ctx.state) + const activeTasks = json(taskData.active_tasks) + const staleThreshold = 5 * 60 * 1000 + for (const [id, ts] of Object.entries(activeTasks)) { + if (typeof ts === "number" && Date.now() - ts > staleThreshold) { + await ctx.seen("delegation.stale_reset", { task_id: id, age_ms: Date.now() - ts }) + delete activeTasks[id] + } + } + const activeCount = Object.keys(activeTasks).length + if (activeCount >= ctx.maxParallelTasks) { + const err = `parallel task limit reached (${activeCount}/${ctx.maxParallelTasks}); wait for a running task to complete before delegating more` + await ctx.mark({ last_block: "task", last_reason: err, active_tasks: activeTasks }) + throw new Error(text(err)) + } + const callID = str(item.callID) || str((item.args as Record)?.callID) || `task_${Date.now()}` + activeTasks[callID] = Date.now() + await ctx.mark({ active_tasks: activeTasks, active_task_count: Object.keys(activeTasks).length }) + } + + if (item.tool === "write" && file) { + const relFile = rel(ctx.input.worktree, file) + const fileName = path.basename(relFile) + for (const [dir, pattern] of Object.entries(ctx.domainDirs)) { + if (relFile.startsWith(dir) && !pattern.test(fileName)) { + await ctx.mark({ domain_naming_warning: relFile, domain_naming_expected: pattern.source, domain_naming_dir: dir }) + await ctx.seen("domain_naming.mismatch", { file: relFile, expected_pattern: pattern.source, dir }) + } + } + } + } + + async function toolAfterAccess( + item: { tool: string; args?: Record; callID?: unknown }, + out: { title: string; output: string; metadata: Record }, + data: Record, + ) { + const now = new Date().toISOString() + const file = pick(item.args) + + if (item.tool === "read" && file) { + if (ctx.code(file)) { + const seenFiles = list(data.read_files) + const relFile = rel(ctx.input.worktree, file) + const next = seenFiles.includes(relFile) ? seenFiles : [...seenFiles, relFile] + await ctx.mark({ + read_files: next, + read_count: next.length, + last_read: relFile, + }) + } + if (ctx.fact(file)) { + await ctx.mark({ + factchecked: true, + factcheck_source: "DocRead", + factcheck_at: now, + edit_count_since_check: 0, + }) + } + } + + if (item.tool === "webfetch" || item.tool.startsWith("mcp__context7__")) { + await ctx.mark({ + factchecked: true, + factcheck_source: item.tool === "webfetch" ? "WebFetch" : "Context7", + factcheck_at: now, + edit_count_since_check: 0, + }) + } + + if (item.tool === "bash") { + const cmd = str(item.args?.command) + if (/(^|&&|\|\||;)\s*(gcloud|kubectl|aws)\s+/i.test(cmd)) { + await ctx.mark({ + factchecked: true, + factcheck_source: "CLI", + factcheck_at: now, + edit_count_since_check: 0, + }) + } + if (bash(cmd)) { + await ctx.mark({ + edits_since_review: num(data.edits_since_review) + 1, + review_glm_state: "", + review_codex_state: ctx.hasCodexMcp ? "" : "done", + review_state: "", + }) + } + } + + if (MUTATING_TOOLS.has(item.tool) && file) { + const editedFiles = list(data.edited_files) + const relFile = rel(ctx.input.worktree, file) + const next = editedFiles.includes(relFile) ? editedFiles : [...editedFiles, relFile] + const nextEditCount = num(data.edit_count) + 1 + await ctx.mark({ + edited_files: next, + edit_count: nextEditCount, + edit_count_since_check: num(data.edit_count_since_check) + 1, + edits_since_review: num(data.edits_since_review) + 1, + last_edit: relFile, + review_glm_state: "", + review_codex_state: ctx.hasCodexMcp ? "" : "done", + review_state: "", + }) + + if (/\.(test|spec)\.(ts|tsx|js|jsx)$|(^|\/)test_.*\.py$|_test\.go$/.test(relFile)) { + out.output += "\n\n🧪 Test file modified. Verify this test actually FAILS without the fix (test falsifiability)." + } + if (ctx.code(file) && nextEditCount > 0 && nextEditCount % 3 === 0) { + out.output += "\n\n📝 Source code edited (3+ operations). Check if related documentation (README, AGENTS.md, ADRs) needs updating." + } + if (ctx.code(file) && nextEditCount >= 3 && nextEditCount % 3 === 0) { + out.output = (out.output || "") + "\n🎨 " + nextEditCount + " source edits — consider running formatter (`prettier --write`, `biome format`, `go fmt`)." + } + } + + if (MUTATING_TOOLS.has(item.tool) && file && ctx.code(file)) { + const relFile = rel(ctx.input.worktree, file) + const content = typeof item.args?.content === "string" ? item.args.content : + typeof item.args?.newString === "string" ? item.args.newString : "" + if (content) { + const isUI = /^(src\/(ui|components|tui)\/)/i.test(relFile) + const isAPI = /^(src\/(api|routes)\/)/i.test(relFile) + const importsDB = /from\s+['"].*\/(db|database|model|sql)\//i.test(content) + const importsUI = /from\s+['"].*\/(ui|components|tui)\//i.test(content) + if (isUI && importsDB) { + out.output += "\n⚠️ Architecture: UI layer importing from DB layer directly. Consider using a service/repository layer." + } + if (isAPI && importsUI) { + out.output += "\n⚠️ Architecture: API layer importing from UI layer. This creates a circular dependency risk." + } + } + } + + if (item.tool === "write" && file) { + const fresh = await stash(ctx.state) + const warningFile = str(fresh.domain_naming_warning) + if (warningFile && warningFile === rel(ctx.input.worktree, file)) { + out.output = (out.output || "") + "\n📛 Domain naming mismatch: " + warningFile + " does not match expected pattern /" + str(fresh.domain_naming_expected) + "/ for " + str(fresh.domain_naming_dir) + await ctx.mark({ domain_naming_warning: "" }) + } + } + + if (MUTATING_TOOLS.has(item.tool) && file && ctx.code(file)) { + const relFile = rel(ctx.input.worktree, file) + const content = typeof item.args?.content === "string" ? item.args.content : + typeof item.args?.newString === "string" ? item.args.newString : "" + if (content && /\b(router\.(get|post|put|patch|delete)|app\.(get|post|put|patch|delete)|fetch\(|axios\.|\.handler)\b/i.test(content)) { + out.output = (out.output || "") + "\n🔄 Endpoint modification detected in " + relFile + ". Verify 4-point dataflow: client → API route → backend action → response format." + await ctx.seen("endpoint_dataflow.modified", { file: relFile }) + } + } + + if (MUTATING_TOOLS.has(item.tool) && file && ctx.code(file)) { + const editsSinceDocCheck = num(data.edits_since_doc_reminder) + if (editsSinceDocCheck >= 5) { + out.output = (out.output || "") + "\n📄 " + (editsSinceDocCheck + 1) + " source edits since last doc check. Grep for references to modified files in docs/ and README." + await ctx.mark({ edits_since_doc_reminder: 0 }) + } else { + await ctx.mark({ edits_since_doc_reminder: editsSinceDocCheck + 1 }) + } + } + + if (item.tool === "bash" && /\b(gh\s+issue\s+close)\b/i.test(str(item.args?.command))) { + const reviewed = data.reviewed === true + const factchecked = data.factchecked === true + if (!reviewed || !factchecked) { + out.output = (out.output || "") + "\n⚠️ Issue close without full verification: reviewed=" + reviewed + ", factchecked=" + factchecked + ". Ensure acceptance criteria have code-level evidence." + await ctx.seen("task_completion.incomplete", { reviewed, factchecked }) + } + if (reviewed && factchecked) { + await ctx.mark({ issue_verification_done: true }) + } + } + + if (item.tool === "bash" && /\bdocker\s+build\b/i.test(str(item.args?.command))) { + const fresh = await stash(ctx.state) + if (fresh.docker_secret_warning === true) { + out.output = (out.output || "") + "\n🔐 Security: --build-arg '" + str(fresh.docker_secret_arg) + "' may contain secrets. Use Docker build secrets (--secret) or multi-stage builds instead." + await ctx.mark({ docker_secret_warning: false }) + } + } + + const exitCode = typeof out.metadata?.exitCode === "number" ? out.metadata.exitCode : undefined + const isBashFail = item.tool === "bash" && exitCode !== undefined && exitCode !== 0 + const isToolError = out.title === "Error" || (typeof out.metadata?.error === "string" && out.metadata.error !== "") + if (isBashFail || isToolError) { + const failures = num(data.consecutive_failures) + 1 + await ctx.mark({ consecutive_failures: failures, last_failure_tool: item.tool }) + if (failures >= 3) { + out.output = (out.output || "") + "\n⚠️ " + failures + " consecutive tool failures detected. Consider: (1) checking error root cause, (2) trying alternate approach, (3) delegating to a specialist agent." + } + } else if (item.tool !== "read" && num(data.consecutive_failures) > 0) { + await ctx.mark({ consecutive_failures: 0 }) + } + } + + return { toolBeforeAccess, toolAfterAccess } +} + +export default { + id: "guardrail-access", + server: async () => ({}), +} diff --git a/packages/guardrails/profile/plugins/guardrail-context.ts b/packages/guardrails/profile/plugins/guardrail-context.ts new file mode 100644 index 000000000000..fffce0f2875c --- /dev/null +++ b/packages/guardrails/profile/plugins/guardrail-context.ts @@ -0,0 +1,343 @@ +import { mkdir } from "fs/promises" +import path from "path" +import { + cmp, + ext, + flag, + free, + has, + line, + list, + num, + pick, + preview, + rel, + save, + src, + stash, + str, + cfg, + sec, + vers, +} from "./guardrail-patterns" + +export type Client = { + session: { + create(input: { body: { parentID: string; title: string }; query: { directory: string } }): Promise<{ data: { id: string } }> + promptAsync(input: { + path: { id: string } + query: { directory: string } + body: { + agent?: string + model?: { providerID: string; modelID: string } + tools?: Record + variant?: string + parts: { type: "text"; text: string }[] + } + }): Promise + prompt(input: { + path: { id: string } + query: { directory: string } + body: { + noReply?: boolean + parts: { type: "text"; text: string }[] + } + }): Promise + status(input: { query: { directory: string } }): Promise<{ data?: Record }> + messages(input: { path: { id: string }; query: { directory: string } }): Promise<{ data?: Array<{ info: { role: string; error?: { data?: { message?: string } } }; parts: Array<{ type?: string; text?: string }> }> }> + abort(input: { path: { id: string }; query: { directory: string } }): Promise + } +} + +export type GuardrailInput = { + client: Client + directory: string + worktree: string +} + +export type GuardrailContext = { + input: GuardrailInput + mode: string + root: string + log: string + state: string + allow: Record> + hasCodexMcp: boolean + maxParallelTasks: number + maxSessionCost: number + agentModelTier: Record + tierModels: Record + domainDirs: Record + mark(data: Record): Promise + seen(type: string, data: Record): Promise + note(props: Record | undefined): { + sessionID: string | undefined + permission: string | undefined + patterns: unknown[] | undefined + } + hidden(file: string): boolean + code(file: string): boolean + fact(file: string): boolean + stale(data: Record, key: "edit_count_since_check" | "edits_since_review"): boolean + factLine(data: Record): string + reviewLine(data: Record): string + compact(data: Record): string + deny(file: string, kind: "read" | "edit"): string | undefined + baseline(old: string, next: string): string | undefined + version(args: Record): Promise + budget(): Promise + gate(data: { + agent?: string + model?: { + id?: unknown + providerID?: unknown + status?: unknown + cost?: { + input?: number + output?: number + cache?: { read?: number; write?: number } + } + } + }): string | undefined +} + +export async function createContext(input: GuardrailInput, opts?: Record) { + const mode = typeof opts?.mode === "string" ? opts.mode : "enforced" + const evals = new Set([]) + const evalAgent = "provider-eval" + const conf = true + const denyFree = true + const denyPreview = true + const root = path.join(input.directory, ".opencode", "guardrails") + const log = path.join(root, "events.jsonl") + const state = path.join(root, "state.json") + const allow: Record> = {} + + const maxParallelTasks = 5 + const maxSessionCost = 10.0 + const agentModelTier: Record = { + implement: "high", + security: "high", + "security-engineer": "high", + "security-reviewer": "high", + review: "standard", + "code-reviewer": "standard", + explore: "low", + planner: "standard", + architect: "high", + "build-error-resolver": "standard", + "tdd-guide": "standard", + investigate: "low", + "provider-eval": "low", + "doc-updater": "low", + "technical-writer": "low", + "refactor-cleaner": "standard", + "e2e-runner": "standard", + } + const tierModels: Record = { + high: ["glm-5.1", "glm-5", "gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"], + standard: ["glm-4.7", "glm-4.6", "gpt-5.2", "gpt-5.1-codex", "gpt-5.1-codex-mini"], + low: ["glm-4.5-flash", "glm-4.5-air", "gpt-5-mini", "gpt-5-nano"], + } + const domainDirs: Record = { + "src/ui/": /^[A-Z][a-zA-Z]*\.(tsx?|jsx?)$/, + "src/components/": /^[A-Z][a-zA-Z]*\.(tsx?|jsx?)$/, + "src/api/": /^[a-z][a-zA-Z]*\.(ts|js)$/, + "src/routes/": /^[a-z][a-zA-Z-]*\.(ts|js)$/, + "src/util/": /^[a-z][a-zA-Z-]*\.(ts|js)$/, + "src/lib/": /^[a-z][a-zA-Z-]*\.(ts|js)$/, + "test/": /\.(test|spec)\.(ts|tsx|js|jsx)$/, + } + + await mkdir(root, { recursive: true }) + + async function mark(data: Record) { + const prev = await stash(state) + await save(state, { ...prev, ...data, mode, updated_at: new Date().toISOString() }) + } + + async function seen(type: string, data: Record) { + await line(log, { type, time: new Date().toISOString(), ...data }) + } + + function note(props: Record | undefined) { + return { + sessionID: str(props?.sessionID) || undefined, + permission: str(props?.permission) || undefined, + patterns: Array.isArray(props?.patterns) ? props.patterns : undefined, + } + } + + function hidden(file: string) { + return rel(input.worktree, file).startsWith(".opencode/guardrails/") + } + + function code(file: string) { + const item = rel(input.worktree, file) + if (hidden(file)) return false + if (item === "AGENTS.md") return false + if (item.startsWith(".claude/")) return false + if (item.startsWith(".opencode/")) return false + if (item.startsWith("docs/")) return false + if (item.includes("/docs/")) return false + if (item.startsWith("node_modules/")) return false + if (item.includes("/node_modules/")) return false + if (item.startsWith("tmp/")) return false + if (item.includes("/tmp/")) return false + return src.has(ext(item)) + } + + function fact(file: string) { + const item = rel(input.worktree, file) + if (hidden(file)) return false + if (code(file)) return true + if (/(^|\/)(README|AGENTS)\.md$/i.test(item)) return true + if (item.startsWith("docs/") || item.includes("/docs/")) return true + if (item.startsWith("hooks/") || item.includes("/hooks/")) return true + if (item.startsWith("scripts/") || item.includes("/scripts/")) return true + if (item.startsWith("src/") || item.includes("/src/")) return true + return [".md", ".mdx", ".json", ".yaml", ".yml", ".toml"].includes(ext(item)) + } + + function stale(data: Record, key: "edit_count_since_check" | "edits_since_review") { + return num(data[key]) > 0 + } + + function factLine(data: Record) { + if (!flag(data.factchecked)) return "missing" + const source = str(data.factcheck_source) || "unknown" + const at = str(data.factcheck_at) || "unknown" + if (!stale(data, "edit_count_since_check")) return `fresh via ${source} at ${at}` + return `stale after ${num(data.edit_count_since_check)} edit(s) since ${source} at ${at}` + } + + function reviewLine(data: Record) { + const glm = str(data.review_glm_state) === "done" ? "done" : "pending" + const codex = str(data.review_codex_state) === "done" ? "done" : "pending" + const staleSuffix = stale(data, "edits_since_review") + ? ` (stale: ${num(data.edits_since_review)} edit(s) since last review)` + : "" + return `GLM: ${glm}, Codex: ${codex}${staleSuffix}` + } + + function compact(data: Record) { + const block = str(data.last_block) || "none" + const reason = str(data.last_reason) + return [ + "Guardrail runtime state:", + `- unique source reads: ${num(data.read_count)}`, + `- edit/write count: ${num(data.edit_count)}`, + `- fact-check: ${factLine(data)}`, + `- review state: ${reviewLine(data)}`, + `- last block: ${block}${reason ? ` (${reason})` : ""}`, + "Treat missing or stale fact-check/review state as an explicit gate.", + ].join("\n") + } + + function deny(file: string, kind: "read" | "edit") { + const item = rel(input.worktree, file) + if (kind === "read" && has(item, sec)) return "secret material is outside the allowed read surface" + if (hidden(file)) return "guardrail runtime state is plugin-owned" + if (kind === "edit" && has(item, cfg)) return "linter or formatter configuration is policy-protected" + } + + function baseline(old: string, next: string) { + if (/:latest\b/i.test(old) && vers(next).length > 0) { + return ":latest pin requires ADR-backed compatibility verification" + } + const left = vers(old) + const right = vers(next) + if (!left.length || !right.length) return + if (left.length !== right.length || left.length > 3) return + for (let i = 0; i < left.length; i++) { + if (cmp(right[i], left[i]) < 0) return `version baseline regression ${left[i]} -> ${right[i]}` + } + } + + async function version(args: Record) { + const file = pick(args) + if (!file || hidden(file)) return + if (typeof args.oldString === "string" && typeof args.newString === "string") { + return baseline(args.oldString, args.newString) + } + if (typeof args.content !== "string") return + const prev = await Bun.file(file).text().catch(() => "") + if (!prev) return + return baseline(prev, args.content) + } + + async function budget() { + const data = await stash(state) + return num(data.read_count) + } + + function gate(data: { + agent?: string + model?: { + id?: unknown + providerID?: unknown + status?: unknown + cost?: { + input?: number + output?: number + cache?: { read?: number; write?: number } + } + } + }) { + const provider = str(data.model?.providerID) + const agent = str(data.agent) + if (!provider) return + + if (evals.size > 0 && evals.has(provider) && agent !== evalAgent) { + return `${provider} is evaluation-only under confidential policy; use ${evalAgent}` + } + if (evals.size > 0 && agent === evalAgent && !evals.has(provider)) { + return `${evalAgent} is reserved for evaluation-lane providers` + } + + const ids = allow[provider] + const model = str(data.model?.id) + if (ids?.size && model && !ids.has(model)) { + return `${provider}/${model} is not admitted by provider policy` + } + + if (!conf) return + if (denyFree && free(data.model ?? {})) return `${provider}/${model || "unknown"} is a free-tier model` + if (denyPreview && preview(data.model ?? {})) return `${provider}/${model || "unknown"} is preview-only` + } + + return { + input, + mode, + root, + log, + state, + allow, + hasCodexMcp: false, + maxParallelTasks, + maxSessionCost, + agentModelTier, + tierModels, + domainDirs, + mark, + seen, + note, + hidden, + code, + fact, + stale, + factLine, + reviewLine, + compact, + deny, + baseline, + version, + budget, + gate, + } satisfies GuardrailContext +} + +export default { + id: "guardrail-context", + server: async () => ({}), +} diff --git a/packages/guardrails/profile/plugins/guardrail-git.ts b/packages/guardrails/profile/plugins/guardrail-git.ts new file mode 100644 index 000000000000..30daa29adf4a --- /dev/null +++ b/packages/guardrails/profile/plugins/guardrail-git.ts @@ -0,0 +1,432 @@ +import { flag, git, num, stash, str } from "./guardrail-patterns" +import type { GuardrailContext } from "./guardrail-context" + +type Review = { + checklist(data: Record): { + score: number + total: number + blocking: string[] + summary: string + } + reviewGate(data: Record): { + done: boolean + pending: string[] + message: string + } + syncReviewState(): Promise +} + +export function createGitHandlers(ctx: GuardrailContext, review: Review) { + async function bashBeforeGit(cmd: string, out: { output?: string }, data: Record) { + if (/\bgit\s+merge(\s|$)/i.test(cmd) || /\bgh\s+pr\s+merge(\s|$)/i.test(cmd)) { + const criticalCount = num(data.review_critical_count) + const highCount = num(data.review_high_count) + if (criticalCount > 0 || highCount > 0) { + const prNum = str(data.review_pr_number) + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: `unresolved CRITICAL=${criticalCount} HIGH=${highCount}` }) + throw new Error(`Guardrail policy blocked this action: merge blocked: PR #${prNum} has unresolved CRITICAL=${criticalCount} HIGH=${highCount} review findings`) + } + try { + const branchResult = await git(ctx.input.worktree, ["branch", "--show-current"]) + if (branchResult.code !== 0) throw new Error("git branch failed") + const branch = branchResult.stdout.trim() + const tier = /^(ci|chore|docs)\//.test(branch) ? "EXEMPT" : + /^fix\//.test(branch) ? "LIGHT" : "FULL" + if (tier === "EXEMPT") { + await ctx.seen("pre_merge.tier", { branch, tier, result: "pass" }) + } else if (tier === "LIGHT") { + const anyReviewDone = str(data.review_glm_state) === "done" || str(data.review_codex_state) === "done" + const checksRan = Boolean(str(data.review_checks_at)) + const noSevere = checksRan && criticalCount === 0 && highCount === 0 + if (!anyReviewDone && !noSevere) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "LIGHT tier: review or C/H=0 required" }) + throw new Error("Guardrail policy blocked this action: merge blocked (LIGHT tier): run code-reviewer agent OR Codex review OR run `gh pr checks` with CRITICAL=0 HIGH=0") + } + } else { + const gate = review.reviewGate(data) + if (!gate.done) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: `FULL tier: ${gate.message}` }) + throw new Error(`Guardrail policy blocked this action: merge blocked (FULL tier): ${gate.message}. Run both code-reviewer agent and Codex review before merging.`) + } + } + } catch (err) { + if (String(err).includes("blocked")) throw err + const gate = review.reviewGate(data) + if (!gate.done) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: `merge blocked: ${gate.message}` }) + throw new Error(`Guardrail policy blocked this action: merge blocked: ${gate.message}. Run /review and Codex review before merging.`) + } + } + } + + if (/\bgit\s+merge(\s|$)/i.test(cmd) || /\bgh\s+pr\s+merge(\s|$)/i.test(cmd)) { + const checks = review.checklist(data) + if (checks.score < 3) { + out.output = (out.output || "") + `\n\nCompletion checklist (${checks.score}/${checks.total}): ${checks.summary}\nBlocking: ${checks.blocking.join(", ")}` + } + } + + if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { + try { + const prMatch = cmd.match(/\bgh\s+pr\s+merge\s+(\d+)/i) + const prArg = prMatch ? [prMatch[1]] : [] + const proc = Bun.spawn(["gh", "pr", "checks", ...prArg], { + cwd: ctx.input.worktree, + stdout: "pipe", + stderr: "pipe", + }) + const [ciOut] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (proc.exitCode !== 0 || /fail|pending/i.test(ciOut)) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "CI checks not all green" }) + throw new Error("Guardrail policy blocked this action: merge blocked: CI checks not all green — run `gh pr checks` to verify") + } + } catch (err) { + if (String(err).includes("blocked")) throw err + await ctx.mark({ last_block: "bash:ci-warn", last_command: cmd, last_reason: "CI check verification failed" }) + await ctx.seen("ci.check_verification_failed", { error: String(err) }) + } + } + + if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { + try { + const repoRes = await git(ctx.input.worktree, ["remote", "get-url", "origin"]) + const repo = repoRes.code === 0 ? repoRes.stdout.trim().replace(/.*github\.com[:/]/, "").replace(/\.git$/, "") : "" + const prMatch = cmd.match(/\bgh\s+pr\s+merge\s+(\d+)/i) + const prNum = prMatch ? prMatch[1] : "" + if (repo && prNum) { + const proc = Bun.spawn(["gh", "api", `repos/${repo}/pulls/${prNum}/reviews`, "--jq", "[.[] | select(.state==\"CHANGES_REQUESTED\")] | length"], { + cwd: ctx.input.worktree, + stdout: "pipe", + stderr: "pipe", + }) + const [revOut] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) + if (parseInt(revOut.trim()) > 0) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "unresolved CHANGES_REQUESTED reviews" }) + throw new Error("Guardrail policy blocked this action: merge blocked: unresolved CHANGES_REQUESTED reviews — address reviewer feedback first") + } + } + } catch (err) { + if (String(err).includes("blocked")) throw err + } + } + + if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { + try { + const curBranchRes = await git(ctx.input.worktree, ["branch", "--show-current"]) + const branch = curBranchRes.code === 0 ? curBranchRes.stdout.trim() : "" + if (branch) { + const proc = Bun.spawn(["gh", "pr", "list", "--base", branch, "--json", "number,headRefName", "--jq", ".[].number"], { + cwd: ctx.input.worktree, + stdout: "pipe", + stderr: "pipe", + }) + const [childOut] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) + const childPRs = childOut.trim().split("\n").filter(Boolean) + if (childPRs.length > 0) { + out.output = (out.output || "") + `\n⚠️ Stacked PR detected: ${childPRs.length} child PR(s) depend on this branch. Rebase child PRs after merging.` + await ctx.seen("stacked_pr.children_detected", { count: childPRs.length, children: childPRs }) + } + } + } catch {} + } + + const protectedBranch = /^(main|master|develop|dev)$/ + if (/\bgit\s+push\b/i.test(cmd)) { + const explicitMatch = cmd.match(/\bgit\s+push\s+(?:(?:-\w+|--[\w-]+)\s+)*\S+\s+(?:HEAD:)?(\S+)/i) + if (explicitMatch && protectedBranch.test(explicitMatch[1])) { + throw new Error("Guardrail policy blocked this action: direct push to protected branch blocked — use a PR workflow") + } + const refspecMatch = cmd.match(/HEAD:(main|master|develop|dev)(?:\s|$)/i) + if (refspecMatch) { + throw new Error("Guardrail policy blocked this action: direct push to protected branch blocked — use a PR workflow") + } + if (!/\bgit\s+push\s+(?:(?:-\w+|--[\w-]+)\s+)*\S+\s+\S+/i.test(cmd)) { + try { + const result = await git(ctx.input.worktree, ["branch", "--show-current"]) + if (result.code === 0 && result.stdout && protectedBranch.test(result.stdout.trim())) { + throw new Error("Guardrail policy blocked this action: direct push to protected branch blocked — use a PR workflow") + } + } catch (err) { + if (String(err).includes("blocked")) throw err + } + } + } + + if (/\bgit\s+(checkout\s+-b|switch\s+-c)\b/i.test(cmd)) { + try { + const devCheck = await git(ctx.input.worktree, ["rev-parse", "--verify", "origin/develop"]) + if (devCheck.code === 0 && devCheck.stdout.trim()) { + const branchCheck = await git(ctx.input.worktree, ["branch", "--show-current"]) + const branch = branchCheck.code === 0 ? branchCheck.stdout.trim() : "" + if (/^(main|master)$/.test(branch)) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "branch creation from main blocked" }) + throw new Error("Guardrail policy blocked this action: branch creation from main blocked: checkout develop first, then create branch") + } + } + } catch (err) { + if (String(err).includes("blocked")) throw err + } + } + + if (/\bgit\s+(cherry-pick)\b/i.test(cmd) && !/--abort\b/i.test(cmd)) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "cherry-pick blocked: delegate to Codex CLI" }) + throw new Error("Guardrail policy blocked this action: cherry-pick blocked: delegate to Codex CLI for context-heavy merge operations") + } + + if (/\bgit\s+rebase\b/i.test(cmd) && !/--abort\b/i.test(cmd)) { + if (/\bgit\s+rebase\s+(origin\/)?(main|master|develop)\b/i.test(cmd)) { + await ctx.mark({ rebase_session_active: true, rebase_session_at: new Date().toISOString() }) + } else if (/\bgit\s+rebase\s+--(continue|skip)\b/i.test(cmd)) { + const data = await stash(ctx.state) + const at = str(data.rebase_session_at) + if (!at || Date.now() - new Date(at).getTime() > 3600_000) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "rebase --continue/--skip: no active session" }) + throw new Error("Guardrail policy blocked this action: rebase --continue/--skip blocked: no active permitted rebase session (1h expiry)") + } + } else { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "arbitrary rebase blocked" }) + throw new Error("Guardrail policy blocked this action: arbitrary rebase blocked: only sync from main/master/develop is permitted") + } + } + + if (/\bgit\s+branch\s+(-[mMfF]\b|--move\b|--force\b)/i.test(cmd)) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "branch rename/force-move blocked" }) + throw new Error("Guardrail policy blocked this action: branch rename/force-move blocked: prevents commit guard bypass") + } + + if (/\bgit\s+merge(\s|$)/i.test(cmd) || /\bgh\s+pr\s+merge(\s|$)/i.test(cmd)) { + const lastMerge = str(data.last_merge_at) + if (lastMerge) { + const elapsed = Date.now() - new Date(lastMerge).getTime() + const halfDay = 12 * 60 * 60 * 1000 + if (elapsed < halfDay) { + const hours = Math.round(elapsed / (60 * 60 * 1000) * 10) / 10 + await ctx.mark({ soak_time_warning: true, soak_time_elapsed_h: hours }) + await ctx.seen("soak_time.advisory", { elapsed_ms: elapsed, required_ms: halfDay }) + } + } + } + + if (/\bgh\s+pr\s+create\b/i.test(cmd) && num(data.consecutive_fix_prs) >= 2) { + await ctx.seen("follow_up.limit_reached", { consecutive: num(data.consecutive_fix_prs) }) + } + + if (/\bgh\s+issue\s+close\b/i.test(cmd) && !flag(data.issue_verification_done)) { + await ctx.seen("issue_close.unverified", { command: cmd }) + } + + if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { + const reviewAt = str(data.review_at) + const lastPushAt = str(data.last_push_at) + if (reviewAt && lastPushAt && new Date(reviewAt) < new Date(lastPushAt)) { + await ctx.mark({ review_reading_warning: true, last_block: "bash", last_reason: "stale review: push after review" }) + await ctx.seen("review_reading.stale", { review_at: reviewAt, last_push_at: lastPushAt }) + throw new Error("Guardrail policy blocked this action: merge blocked: code was pushed after the last review. Re-request review before merging.") + } + } + + if (/\bgh\s+pr\s+create\b/i.test(cmd)) { + try { + const diffRes = await git(ctx.input.worktree, ["diff", "--name-only", "origin/develop...HEAD"]) + if (diffRes.code !== 0) throw new Error("git diff failed") + const changedFiles = diffRes.stdout.trim() + const hasInfra = /^(hooks\/|scripts\/)[^/]+\.sh$/m.test(changedFiles) + if (hasInfra && !flag(data.deploy_verified)) { + await ctx.seen("deploy_verify.missing", { + files: changedFiles.split("\n").filter((item: string) => /^(hooks|scripts)\//.test(item)), + }) + await ctx.mark({ deploy_verify_warning: true }) + } + } catch {} + } + + if (/\bgh\s+pr\s+create\b/i.test(cmd)) { + if (/--base\s+main(\s|$)/i.test(cmd)) { + try { + const devCheck = await git(ctx.input.worktree, ["rev-parse", "--verify", "origin/develop"]) + if (devCheck.code === 0 && devCheck.stdout.trim()) { + const branchRes = await git(ctx.input.worktree, ["branch", "--show-current"]) + const branch = branchRes.code === 0 ? branchRes.stdout.trim() : "" + if (branch !== "develop" && !/--head\s+develop/i.test(cmd)) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "PR targeting main when develop exists" }) + throw new Error("Guardrail policy blocked this action: PR targeting main blocked: use --base develop. Release PRs must be from develop branch.") + } + } + } catch (err) { + if (String(err).includes("blocked")) throw err + } + } + if (!/\b(closes?|fixes?|resolves?)\s*#\d+/i.test(cmd) && !/#\d+/i.test(cmd)) { + await ctx.mark({ pr_guard_issue_ref_warning: true }) + await ctx.seen("pr_guard.missing_issue_ref", { command: cmd.slice(0, 200) }) + } + const testRan = flag(data.tests_executed) + const typeChecked = flag(data.type_checked) + if (!testRan || !typeChecked) { + await ctx.mark({ pr_guard_warning: true, pr_guard_tests: testRan, pr_guard_types: typeChecked }) + await ctx.seen("pr_guard.preflight_incomplete", { tests: testRan, types: typeChecked }) + } + } + + if (/\b(git\s+push|gh\s+pr\s+merge)\b/i.test(cmd) && !/\bfetch\b/i.test(cmd)) { + if (!flag(data.tests_executed) && num(data.edit_count) >= 3) { + await ctx.mark({ stop_test_warning: true }) + await ctx.seen("stop_test_gate.untested", { edit_count: num(data.edit_count) }) + } + } + } + + async function bashAfterGit( + item: { tool: string; args?: Record }, + out: { output: string; metadata: Record }, + data: Record, + ) { + const cmd = str(item.args?.command) + if (item.tool !== "bash" || !cmd) return + + if (/\b(git\s+push|gh\s+pr\s+create)\b/i.test(cmd)) { + out.output = (out.output || "") + "\n⚠️ Remember to verify CI status: `gh pr checks`" + try { + const proc = Bun.spawn(["gh", "pr", "view", "--json", "mergeable", "--jq", ".mergeable"], { + cwd: ctx.input.worktree, + stdout: "pipe", + stderr: "pipe", + }) + const [mergeOut] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) + if (mergeOut.trim() === "CONFLICTING") { + out.output += "\n⚠️ PR has merge conflicts. Rebase or merge the base branch before proceeding." + await ctx.seen("pr.merge_conflict_detected", {}) + } + } catch {} + } + + if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { + out.output = (out.output || "") + "\n🚀 Post-merge: verify deployment status and run smoke tests on the target environment." + try { + const prMatch = cmd.match(/\bgh\s+pr\s+merge\s+(\d+)/i) + if (prMatch) { + const repoRes = await git(ctx.input.worktree, ["remote", "get-url", "origin"]) + const repo = repoRes.code === 0 ? repoRes.stdout.trim().replace(/.*github\.com[:/]/, "").replace(/\.git$/, "") : "" + if (repo) { + const proc = Bun.spawn(["gh", "api", `repos/${repo}/pulls/${prMatch[1]}/files`, "--jq", ".[].filename"], { + cwd: ctx.input.worktree, + stdout: "pipe", + stderr: "pipe", + }) + const [filesOut] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) + const files = filesOut.trim() + const risks: string[] = [] + if (/^terraform\/|\.tf$/m.test(files)) risks.push("Terraform") + if (/migration|migrate|\.sql$/im.test(files)) risks.push("Migration/DDL") + if (/^\.github\/workflows\//m.test(files)) risks.push("GitHub Actions") + if (/Dockerfile|docker-compose|cloudbuild/im.test(files)) risks.push("Docker/Cloud Build") + if (/deploy|release/im.test(files)) risks.push("Deploy/Release") + if (risks.length > 0) { + out.output += "\n\n⚠️ [POST-MERGE VALIDATION] High-risk changes detected: " + risks.join(", ") + ".\nChecklist: HTTP 200, HTTPS, no errors in logs, migration rollback plan, Terraform plan attached, Docker --platform linux/amd64." + await ctx.seen("post_merge.validation_required", { pr: prMatch[1], risks }) + } + } + } + } catch {} + } + + if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { + await ctx.mark({ last_merge_at: new Date().toISOString() }) + if (/\b(fix(es)?|close[sd]?|resolve[sd]?)\s+#\d+/i.test(out.output)) { + out.output = (out.output || "") + "\n📋 Detected issue reference in merge output. Verify referenced issues are closed." + } + } + + if (/\bgit\s+merge(\s|$)/i.test(cmd) || /\bgh\s+pr\s+merge(\s|$)/i.test(cmd)) { + const fresh = await stash(ctx.state) + if (flag(fresh.soak_time_warning)) { + out.output = (out.output || "") + "\n⏳ Soak time advisory: only " + num(fresh.soak_time_elapsed_h) + "h since last merge (12h recommended). Consider waiting before merging to main." + await ctx.mark({ soak_time_warning: false }) + } + } + + if (/\bgit\s+push\b/i.test(cmd)) { + try { + const branchRes = await git(ctx.input.worktree, ["branch", "--show-current"]) + const branch = branchRes.code === 0 ? branchRes.stdout.trim() : "" + if (branch && !/^(main|master)$/.test(branch)) { + const diffRes = await git(ctx.input.worktree, ["diff", "--name-only", "main..HEAD", "--", ".github/workflows/"]) + const files = diffRes.code === 0 ? diffRes.stdout.trim() : "" + if (files) { + out.output += "\n\n⚠️ [WORKFLOW SYNC] .github/workflows/ files differ from main:\n" + + files.split("\n").map((item: string) => " - " + item).join("\n") + + "\nOIDC validation requires workflow files to match the default branch. Create a chore PR to sync." + await ctx.seen("workflow_sync.diverged", { branch, files: files.split("\n") }) + } + } + } catch {} + } + + if (/\bgit\s+commit\b/i.test(cmd) && num(data.edit_count) >= 5) { + out.output = (out.output || "") + "\n🧠 Significant changes committed (" + num(data.edit_count) + " edits). Consider updating memory with key decisions or learnings." + } + + if (/\bgh\s+pr\s+create\b/i.test(cmd)) { + const prNum = (out.output || "").match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/)?.[1] + if (prNum) { + await ctx.mark({ review_pending: true, review_pending_pr: prNum }) + out.output += "\n\n📋 [AUTO-REVIEW REQUIRED] PR #" + prNum + " created. Run code-reviewer agent and address all CRITICAL/HIGH findings before merging." + await ctx.seen("post_pr_create.review_trigger", { pr: prNum }) + } + } + + if (/\bgh\s+pr\s+create\b/i.test(cmd)) { + if (/--title\s+["']?fix/i.test(cmd) || /\bfix\//i.test(cmd)) { + const fixes = num(data.consecutive_fix_prs) + 1 + await ctx.mark({ consecutive_fix_prs: fixes }) + if (fixes >= 2) { + out.output = (out.output || "") + "\n⚠️ Feature freeze warning: " + fixes + " consecutive fix PRs on the same feature. Consider stabilizing before adding more changes." + } + } else { + await ctx.mark({ consecutive_fix_prs: 0 }) + } + } + + if (/\bgh\s+pr\s+create\b/i.test(cmd)) { + const fresh = await stash(ctx.state) + if (flag(fresh.pr_guard_warning)) { + const tests = flag(fresh.pr_guard_tests) + const types = flag(fresh.pr_guard_types) + const missing = [!tests && "tests", !types && "typecheck"].filter(Boolean).join(", ") + out.output = (out.output || "") + "\n🛡️ PR guard: " + missing + " not yet run this session. Run `bun turbo test:ci && bun turbo typecheck` before creating PR." + await ctx.mark({ pr_guard_warning: false }) + } + if (flag(fresh.deploy_verify_warning)) { + out.output = (out.output || "") + "\n🚀 Deploy verification: changed hooks/scripts detected but not yet deployed. Run setup.sh and verify firing before merging." + await ctx.mark({ deploy_verify_warning: false }) + } + } + + if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { + const fresh = await stash(ctx.state) + if (flag(fresh.review_reading_warning)) { + out.output = (out.output || "") + "\n📖 Stale review: code was pushed after the last review. Re-request review before merging." + await ctx.mark({ review_reading_warning: false }) + } + } + + if (/\b(git\s+push|gh\s+pr\s+merge)\b/i.test(cmd)) { + const fresh = await stash(ctx.state) + if (flag(fresh.stop_test_warning)) { + out.output = (out.output || "") + "\n🚫 Test gate: " + num(fresh.edit_count) + " edits without running tests. Run tests before pushing/merging." + await ctx.mark({ stop_test_warning: false }) + } + } + } + + return { bashBeforeGit, bashAfterGit } +} + +export default { + id: "guardrail-git", + server: async () => ({}), +} diff --git a/packages/guardrails/profile/plugins/guardrail-patterns.ts b/packages/guardrails/profile/plugins/guardrail-patterns.ts new file mode 100644 index 000000000000..8a1fea278747 --- /dev/null +++ b/packages/guardrails/profile/plugins/guardrail-patterns.ts @@ -0,0 +1,225 @@ +import path from "path" + +export const sec = [ + /(^|\/)\.env($|\.)/i, + /(^|\/).*\.pem$/i, + /(^|\/).*\.key$/i, + /(^|\/).*\.p12$/i, + /(^|\/).*\.pfx$/i, + /(^|\/).*\.crt$/i, + /(^|\/).*\.cer$/i, + /(^|\/).*\.der$/i, + /(^|\/).*id_rsa.*$/i, + /(^|\/).*id_ed25519.*$/i, + /(^|\/).*credentials.*$/i, +] + +export const cfg = [ + /(^|\/)eslint\.config\.[^/]+$/i, + /(^|\/)\.eslintrc(\.[^/]+)?$/i, + /(^|\/)biome\.json(c)?$/i, + /(^|\/)prettier\.config\.[^/]+$/i, + /(^|\/)\.prettierrc(\.[^/]+)?$/i, +] + +export const mut = [ + /\brm\s+/i, + /\bmv\s+/i, + /\bcp\s+/i, + /\bchmod\b/i, + /\bchown\b/i, + /\btouch\b/i, + /\btruncate\b/i, + /\btee\b/i, + /\bsed\s+-i\b/i, + /\bperl\s+-pi\b/i, + /\s>\s*[\/~$._a-zA-Z]|^>/, +] + +export const MUTATING_TOOLS = new Set(["edit", "write", "apply_patch", "multiedit"]) + +export const src = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".py", + ".go", + ".rs", + ".swift", + ".kt", + ".java", + ".rb", + ".php", + ".vue", + ".svelte", + ".css", + ".scss", + ".sql", + ".prisma", + ".graphql", + ".sh", +]) + +export const paid: Record> = { + "zai-coding-plan": new Set([ + "glm-4.5", + "glm-4.5-air", + "glm-4.5-flash", + "glm-4.5v", + "glm-4.6", + "glm-4.6v", + "glm-4.7", + "glm-4.7-flash", + "glm-4.7-flashx", + "glm-5", + "glm-5-turbo", + "glm-5.1", + ]), + openai: new Set([ + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.4", + "gpt-5.4-mini", + ]), +} + +export const secEnvExempt = /\.env\.(example|sample|template)$/i + +export function norm(file: string) { + return path.resolve(file).replaceAll("\\", "/") +} + +export function rel(root: string, file: string) { + const abs = norm(file) + const dir = norm(root) + if (!abs.startsWith(dir + "/")) return abs + return abs.slice(dir.length + 1) +} + +export function has(file: string, list: RegExp[]) { + if (list === sec && secEnvExempt.test(file)) return false + return list.some((item) => item.test(file)) +} + +export function ext(file: string) { + return path.extname(file).toLowerCase() +} + +export function stash(file: string) { + return Bun.file(file) + .json() + .catch(() => ({} as Record)) +} + +export async function save(file: string, data: Record) { + await Bun.write(file, JSON.stringify(data, null, 2) + "\n") +} + +export async function line(file: string, data: Record) { + const prev = await Bun.file(file).text().catch(() => "") + await Bun.write(file, prev + JSON.stringify(data) + "\n") +} + +export function text(err: string) { + return `Guardrail policy blocked this action: ${err}` +} + +export function pick(args: unknown) { + if (!args || typeof args !== "object") return + if ("filePath" in args && typeof args.filePath === "string") return args.filePath +} + +export function bash(cmd: string) { + return mut.some((item) => item.test(cmd)) +} + +export function list(data: unknown) { + return Array.isArray(data) ? data.filter((item): item is string => typeof item === "string" && item !== "") : [] +} + +export function num(data: unknown) { + return typeof data === "number" && Number.isFinite(data) ? data : 0 +} + +export function flag(data: unknown) { + return data === true +} + +export function str(data: unknown) { + return typeof data === "string" ? data : "" +} + +export function json(data: unknown): Record { + if (data && typeof data === "object" && !Array.isArray(data)) return data as Record + return {} +} + +export async function git(dir: string, args: string[]) { + const proc = Bun.spawn(["git", "-C", dir, ...args], { + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { stdout, stderr, code } +} + +export function free(data: { + id?: unknown + providerID?: unknown + cost?: { + input?: number + output?: number + cache?: { read?: number; write?: number } + } +}) { + const inCost = data.cost?.input ?? 0 + const outCost = data.cost?.output ?? 0 + const readCost = data.cost?.cache?.read ?? 0 + const writeCost = data.cost?.cache?.write ?? 0 + if (!(inCost === 0 && outCost === 0 && readCost === 0 && writeCost === 0)) return false + const ids = paid[str(data.providerID)] + return !(ids && ids.has(str(data.id))) +} + +export function preview(data: { + id?: unknown + status?: unknown +}) { + const id = str(data.id) + const status = str(data.status) + if (status && status !== "active") return true + return /(preview|alpha|beta|exp|experimental|:free\b|\bfree\b)/i.test(id) +} + +export function vers(text: string) { + return [...text.matchAll(/\bv?\d+\.\d+\.\d+\b/g)].map((item) => item[0]).slice(0, 8) +} + +export function semver(text: string) { + const hit = text.match(/^v?(\d+)\.(\d+)\.(\d+)$/) + if (!hit) return + return hit.slice(1).map((item) => Number(item)) +} + +export function cmp(left: string, right: string) { + const a = semver(left) + const b = semver(right) + if (!a || !b) return 0 + if (a[0] !== b[0]) return a[0] - b[0] + if (a[1] !== b[1]) return a[1] - b[1] + return a[2] - b[2] +} + +export default { + id: "guardrail-patterns", + server: async () => ({}), +} diff --git a/packages/guardrails/profile/plugins/guardrail-review.ts b/packages/guardrails/profile/plugins/guardrail-review.ts new file mode 100644 index 000000000000..877f190c7280 --- /dev/null +++ b/packages/guardrails/profile/plugins/guardrail-review.ts @@ -0,0 +1,198 @@ +import { flag, list, num, stash, str } from "./guardrail-patterns" +import type { GuardrailContext } from "./guardrail-context" + +const REVIEW_POLL_GAP = 750 +const REVIEW_TIMEOUT_MS = 120_000 + +export function createReviewPipeline(ctx: GuardrailContext) { + function reviewGate(data: Record) { + const glm = str(data.review_glm_state) === "done" + const codex = str(data.review_codex_state) === "done" + const pending: string[] = [] + if (!glm) pending.push("GLM code-reviewer") + if (!codex) pending.push("Codex review") + return { + done: glm && codex, + pending, + message: pending.length === 0 ? "all reviews complete" : `pending: ${pending.join(" and ")}`, + } + } + + async function syncReviewState() { + const data = await stash(ctx.state) + const gate = reviewGate(data) + await ctx.mark({ + review_state: gate.done ? "done" : "", + ...(gate.done ? { edits_since_review: 0 } : {}), + }) + } + + async function pollIdle(sessionID: string) { + const start = Date.now() + for (;;) { + if (Date.now() - start > REVIEW_TIMEOUT_MS) { + throw new Error(`Auto-review timed out after ${REVIEW_TIMEOUT_MS}ms`) + } + const stat = await ctx.input.client.session.status({ query: { directory: ctx.input.directory } }) + const item = stat.data?.[sessionID] + if (!item || item.type === "idle") return + await Bun.sleep(REVIEW_POLL_GAP) + } + } + + async function readResult(sessionID: string) { + const msgs = await ctx.input.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.input.directory }, + }) + const msg = [...(msgs.data ?? [])].reverse().find((item) => item.info.role === "assistant") + if (!msg) return { text: "", error: "" } + const text = msg.parts.filter((item) => item.type === "text").map((item) => item.text ?? "").join("\n") + const error = msg.info.error?.data?.message ?? "" + return { text: text.slice(0, 4000), error } + } + + function parseFindings(raw: string) { + const lines = raw.split("\n") + let critical = 0, high = 0, medium = 0, low = 0 + for (const line of lines) { + const item = line.trim() + if (/\b(no|zero|0|none|without|aren't|isn't|not)\b/i.test(item)) continue + if (/CRITICAL=\d|HIGH=\d|checklist|Guardrail mode/i.test(item)) continue + if (/^[\s\-*]*\[?CRITICAL\]?[\s:*]/i.test(item) || /^\*\*CRITICAL\*\*/i.test(item)) critical++ + if (/^[\s\-*]*\[?HIGH\]?[\s:*]/i.test(item) || /^\*\*HIGH\*\*/i.test(item)) high++ + if (/^[\s\-*]*\[?MEDIUM\]?[\s:*]/i.test(item) || /^\*\*MEDIUM\*\*/i.test(item)) medium++ + if (/^[\s\-*]*\[?LOW\]?[\s:*]/i.test(item) || /^\*\*LOW\*\*/i.test(item)) low++ + } + return { critical, high, medium, low, total: critical + high + medium + low } + } + + async function autoReview(parentSession: string, data: Record) { + const made = await ctx.input.client.session.create({ + body: { parentID: parentSession, title: "Auto-review" }, + query: { directory: ctx.input.directory }, + }) + await ctx.input.client.session.promptAsync({ + path: { id: made.data.id }, + query: { directory: ctx.input.directory }, + body: { + agent: "code-reviewer", + tools: { edit: false, write: false, apply_patch: false, multiedit: false }, + parts: [{ + type: "text", + text: `Review the current working directory changes for quality, correctness, and security.\nEdited files: ${list(data.edited_files).join(", ") || "unknown"}\nEdit count: ${num(data.edit_count)}\nReport findings as CRITICAL, HIGH, MEDIUM, or LOW.`, + }], + }, + }) + await pollIdle(made.data.id) + const result = await readResult(made.data.id) + if (result.error || !result.text.trim()) { + await ctx.mark({ auto_review_in_progress: false }) + await ctx.seen("auto_review.errored", { error: result.error || "empty response" }) + return + } + const findings = parseFindings(result.text) + const attempts = num(data.workflow_review_attempts) + 1 + if (attempts >= 3) { + await ctx.mark({ auto_review_in_progress: false, workflow_phase: "blocked", workflow_review_attempts: attempts }) + await ctx.seen("auto_review.max_attempts", { attempts }) + return + } + await ctx.mark({ + auto_review_in_progress: false, + auto_review_session: made.data.id, + review_glm_state: "done", + review_glm_at: new Date().toISOString(), + reviewed: true, + workflow_review_attempts: attempts, + review_at: new Date().toISOString(), + edits_since_review: 0, + review_critical_count: findings.critical, + review_high_count: findings.high, + }) + await syncReviewState() + await ctx.seen("auto_review.completed", { findings: findings.total, critical: findings.critical, high: findings.high }) + if (findings.critical > 0 || findings.high > 0) { + await ctx.input.client.session.prompt({ + path: { id: parentSession }, + query: { directory: ctx.input.directory }, + body: { + noReply: true, + parts: [{ + type: "text", + text: `[Auto-review] CRITICAL=${findings.critical} HIGH=${findings.high}. Fix findings before merging.\n\n${result.text.slice(0, 2000)}`, + }], + }, + }) + await ctx.mark({ workflow_phase: "fixing" }) + } + } + + function checklist(data: Record) { + const items = [ + { name: "tests_pass", pass: flag(data.tests_executed) }, + { name: "review_glm", pass: str(data.review_glm_state) === "done" }, + { name: "review_codex", pass: str(data.review_codex_state) === "done" }, + { name: "review_fresh", pass: (str(data.review_glm_state) === "done" || str(data.review_codex_state) === "done") && num(data.edits_since_review) === 0 }, + { name: "ci_green", pass: flag(data.ci_green) }, + { name: "no_critical", pass: num(data.review_critical_count) === 0 && num(data.review_high_count) === 0 }, + ] + return { + score: items.filter((item) => item.pass).length, + total: items.length, + blocking: items.filter((item) => !item.pass).map((item) => item.name), + summary: items.map((item) => `[${item.pass ? "x" : " "}] ${item.name}`).join(", "), + } + } + + async function handleAutoReviewTrigger(sessionID: string) { + const data = await stash(ctx.state) + const edits = num(data.edit_count) + const pending = str(data.review_glm_state) !== "done" + const inProgress = flag(data.auto_review_in_progress) + if (edits < 3 || !pending || inProgress || !sessionID) return + await ctx.mark({ auto_review_in_progress: true }) + await ctx.seen("auto_review.triggered", { edit_count: edits, sessionID }) + void autoReview(sessionID, data).catch(async (err) => { + await ctx.mark({ auto_review_in_progress: false }) + await ctx.seen("auto_review.failed", { error: String(err) }) + }) + } + + async function handleCodexDetection( + item: { tool: string; args?: Record }, + out: { output: string }, + ) { + if (item.tool !== "mcp__codex__codex") return + const prompt = str(item.args?.prompt || item.args?.command || "") + if (!/\b(review|code[\.\-_]review|diff[\.\-_]review)\b/i.test(prompt)) return + const output = str(out.output).trim() + if (!output || output.length < 20) { + await ctx.seen("codex_review.empty_or_short", { length: output.length }) + return + } + const findings = parseFindings(output) + await ctx.mark({ + reviewed: true, + review_codex_state: "done", + review_codex_at: new Date().toISOString(), + }) + await syncReviewState() + await ctx.seen("codex_review.completed", { critical: findings.critical, high: findings.high }) + } + + return { + autoReview, + checklist, + parseFindings, + reviewGate, + syncReviewState, + handleAutoReviewTrigger, + handleCodexDetection, + } +} + +export default { + id: "guardrail-review", + server: async () => ({}), +} diff --git a/packages/guardrails/profile/plugins/guardrail.ts b/packages/guardrails/profile/plugins/guardrail.ts index 392c76bfb0dc..365fdfd722a2 100644 --- a/packages/guardrails/profile/plugins/guardrail.ts +++ b/packages/guardrails/profile/plugins/guardrail.ts @@ -1,656 +1,57 @@ -import { mkdir } from "fs/promises" import path from "path" - -const sec = [ - /(^|\/)\.env($|\.)/i, - /(^|\/).*\.pem$/i, - /(^|\/).*\.key$/i, - /(^|\/).*\.p12$/i, - /(^|\/).*\.pfx$/i, - /(^|\/).*\.crt$/i, - /(^|\/).*\.cer$/i, - /(^|\/).*\.der$/i, - /(^|\/).*id_rsa.*$/i, - /(^|\/).*id_ed25519.*$/i, - /(^|\/).*credentials.*$/i, -] - -const cfg = [ - /(^|\/)eslint\.config\.[^/]+$/i, - /(^|\/)\.eslintrc(\.[^/]+)?$/i, - /(^|\/)biome\.json(c)?$/i, - /(^|\/)prettier\.config\.[^/]+$/i, - /(^|\/)\.prettierrc(\.[^/]+)?$/i, -] - -const mut = [ - /\brm\s+/i, - /\bmv\s+/i, - /\bcp\s+/i, - /\bchmod\b/i, - /\bchown\b/i, - /\btouch\b/i, - /\btruncate\b/i, - /\btee\b/i, - /\bsed\s+-i\b/i, - /\bperl\s+-pi\b/i, - /\s>\s*[\/~$._a-zA-Z]|^>/, // redirect operator — matches > followed by path-start chars (excludes digits to avoid comparison false positives) -] - -const MUTATING_TOOLS = new Set(["edit", "write", "apply_patch", "multiedit"]) - -const src = new Set([ - ".ts", - ".tsx", - ".js", - ".jsx", - ".py", - ".go", - ".rs", - ".swift", - ".kt", - ".java", - ".rb", - ".php", - ".vue", - ".svelte", - ".css", - ".scss", - ".sql", - ".prisma", - ".graphql", - ".sh", -]) - -const paid: Record> = { - "zai-coding-plan": new Set([ - "glm-4.5", - "glm-4.5-air", - "glm-4.5-flash", - "glm-4.5v", - "glm-4.6", - "glm-4.6v", - "glm-4.7", - "glm-4.7-flash", - "glm-4.7-flashx", - "glm-5", - "glm-5-turbo", - "glm-5.1", - ]), - openai: new Set([ - "gpt-5.1-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5.2", - "gpt-5.2-codex", - "gpt-5.3-codex", - "gpt-5.4", - "gpt-5.4-mini", - ]), -} - -function norm(file: string) { - return path.resolve(file).replaceAll("\\", "/") -} - -function rel(root: string, file: string) { - const abs = norm(file) - const dir = norm(root) - if (!abs.startsWith(dir + "/")) return abs - return abs.slice(dir.length + 1) -} - -// Only exempt .env.example/.env.sample/.env.template — not *.key.template etc. -const secEnvExempt = /\.env\.(example|sample|template)$/i - -function has(file: string, list: RegExp[]) { - if (list === sec && secEnvExempt.test(file)) return false - return list.some((item) => item.test(file)) -} - -function ext(file: string) { - return path.extname(file).toLowerCase() -} - -function stash(file: string) { - return Bun.file(file) - .json() - .catch(() => ({} as Record)) -} - -async function save(file: string, data: Record) { - await Bun.write(file, JSON.stringify(data, null, 2) + "\n") -} - -async function line(file: string, data: Record) { - const prev = await Bun.file(file).text().catch(() => "") - await Bun.write(file, prev + JSON.stringify(data) + "\n") -} - -function text(err: string) { - return `Guardrail policy blocked this action: ${err}` -} - -function pick(args: unknown) { - if (!args || typeof args !== "object") return - if ("filePath" in args && typeof args.filePath === "string") return args.filePath -} - -function bash(cmd: string) { - return mut.some((item) => item.test(cmd)) -} - -function list(data: unknown) { - return Array.isArray(data) ? data.filter((item): item is string => typeof item === "string" && item !== "") : [] -} - -function num(data: unknown) { - return typeof data === "number" && Number.isFinite(data) ? data : 0 -} - -function flag(data: unknown) { - return data === true -} - -function str(data: unknown) { - return typeof data === "string" ? data : "" -} - -function json(data: unknown): Record { - if (data && typeof data === "object" && !Array.isArray(data)) return data as Record - return {} -} - -async function git(dir: string, args: string[]) { - const proc = Bun.spawn(["git", "-C", dir, ...args], { - stdout: "pipe", - stderr: "pipe", - }) - const [stdout, stderr, code] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]) - return { stdout, stderr, code } -} - -function free(data: { - id?: unknown - providerID?: unknown - cost?: { - input?: number - output?: number - cache?: { read?: number; write?: number } - } -}) { - const inCost = data.cost?.input ?? 0 - const outCost = data.cost?.output ?? 0 - const readCost = data.cost?.cache?.read ?? 0 - const writeCost = data.cost?.cache?.write ?? 0 - if (!(inCost === 0 && outCost === 0 && readCost === 0 && writeCost === 0)) return false - const ids = paid[str(data.providerID)] - return !(ids && ids.has(str(data.id))) -} - -function preview(data: { - id?: unknown - status?: unknown -}) { - const id = str(data.id) - const status = str(data.status) - if (status && status !== "active") return true - return /(preview|alpha|beta|exp|experimental|:free\b|\bfree\b)/i.test(id) -} - -function vers(text: string) { - return [...text.matchAll(/\bv?\d+\.\d+\.\d+\b/g)].map((item) => item[0]).slice(0, 8) -} - -function semver(text: string) { - const hit = text.match(/^v?(\d+)\.(\d+)\.(\d+)$/) - if (!hit) return - return hit.slice(1).map((item) => Number(item)) -} - -function cmp(left: string, right: string) { - const a = semver(left) - const b = semver(right) - if (!a || !b) return 0 - if (a[0] !== b[0]) return a[0] - b[0] - if (a[1] !== b[1]) return a[1] - b[1] - return a[2] - b[2] -} - -type Client = { - session: { - create(input: { body: { parentID: string; title: string }; query: { directory: string } }): Promise<{ data: { id: string } }> - promptAsync(input: { - path: { id: string } - query: { directory: string } - body: { - agent?: string - model?: { providerID: string; modelID: string } - tools?: Record - variant?: string - parts: { type: "text"; text: string }[] - } - }): Promise - prompt(input: { - path: { id: string } - query: { directory: string } - body: { - noReply?: boolean - parts: { type: "text"; text: string }[] - } - }): Promise - status(input: { query: { directory: string } }): Promise<{ data?: Record }> - messages(input: { path: { id: string }; query: { directory: string } }): Promise<{ data?: Array<{ info: { role: string; error?: { data?: { message?: string } } }; parts: Array<{ type?: string; text?: string }> }> }> - abort(input: { path: { id: string }; query: { directory: string } }): Promise - } -} - -export default async function guardrail(input: { - client: Client - directory: string - worktree: string -}, opts?: Record) { - const mode = typeof opts?.mode === "string" ? opts.mode : "enforced" - const evals = new Set([]) - const evalAgent = "provider-eval" - const conf = true - const denyFree = true - const denyPreview = true - const root = path.join(input.directory, ".opencode", "guardrails") - const log = path.join(root, "events.jsonl") - const state = path.join(root, "state.json") - const allow: Record> = {} - let hasCodexMcp = false // assume unavailable; set true at session.created if configured - - // --- Delegation gate config --- - const maxParallelTasks = 5 - const maxSessionCost = 10.0 // USD - const agentModelTier: Record = { - implement: "high", - security: "high", - "security-engineer": "high", - "security-reviewer": "high", - review: "standard", - "code-reviewer": "standard", - explore: "low", - planner: "standard", - architect: "high", - "build-error-resolver": "standard", - "tdd-guide": "standard", - investigate: "low", - "provider-eval": "low", - "doc-updater": "low", - "technical-writer": "low", - "refactor-cleaner": "standard", - "e2e-runner": "standard", - } - const tierModels: Record = { - high: ["glm-5.1", "glm-5", "gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"], - standard: ["glm-4.7", "glm-4.6", "gpt-5.2", "gpt-5.1-codex", "gpt-5.1-codex-mini"], - low: ["glm-4.5-flash", "glm-4.5-air", "gpt-5-mini", "gpt-5-nano"], - } - - // --- Domain naming patterns --- - const domainDirs: Record = { - "src/ui/": /^[A-Z][a-zA-Z]*\.(tsx?|jsx?)$/, - "src/components/": /^[A-Z][a-zA-Z]*\.(tsx?|jsx?)$/, - "src/api/": /^[a-z][a-zA-Z]*\.(ts|js)$/, - "src/routes/": /^[a-z][a-zA-Z-]*\.(ts|js)$/, - "src/util/": /^[a-z][a-zA-Z-]*\.(ts|js)$/, - "src/lib/": /^[a-z][a-zA-Z-]*\.(ts|js)$/, - "test/": /\.(test|spec)\.(ts|tsx|js|jsx)$/, - } - - await mkdir(root, { recursive: true }) - - async function mark(data: Record) { - const prev = await stash(state) - await save(state, { ...prev, ...data, mode, updated_at: new Date().toISOString() }) - } - - async function seen(type: string, data: Record) { - await line(log, { type, time: new Date().toISOString(), ...data }) - } - - function note(props: Record | undefined) { - return { - sessionID: str(props?.sessionID) || undefined, - permission: str(props?.permission) || undefined, - patterns: Array.isArray(props?.patterns) ? props.patterns : undefined, - } - } - - function hidden(file: string) { - return rel(input.worktree, file).startsWith(".opencode/guardrails/") - } - - function code(file: string) { - const item = rel(input.worktree, file) - if (hidden(file)) return false - if (item === "AGENTS.md") return false - if (item.startsWith(".claude/")) return false - if (item.startsWith(".opencode/")) return false - if (item.startsWith("docs/")) return false - if (item.includes("/docs/")) return false - if (item.startsWith("node_modules/")) return false - if (item.includes("/node_modules/")) return false - if (item.startsWith("tmp/")) return false - if (item.includes("/tmp/")) return false - return src.has(ext(item)) - } - - function fact(file: string) { - const item = rel(input.worktree, file) - if (hidden(file)) return false - if (code(file)) return true - if (/(^|\/)(README|AGENTS)\.md$/i.test(item)) return true - if (item.startsWith("docs/") || item.includes("/docs/")) return true - if (item.startsWith("hooks/") || item.includes("/hooks/")) return true - if (item.startsWith("scripts/") || item.includes("/scripts/")) return true - if (item.startsWith("src/") || item.includes("/src/")) return true - return [".md", ".mdx", ".json", ".yaml", ".yml", ".toml"].includes(ext(item)) - } - - function stale(data: Record, key: "edit_count_since_check" | "edits_since_review") { - return num(data[key]) > 0 - } - - function factLine(data: Record) { - if (!flag(data.factchecked)) return "missing" - const source = str(data.factcheck_source) || "unknown" - const at = str(data.factcheck_at) || "unknown" - if (!stale(data, "edit_count_since_check")) return `fresh via ${source} at ${at}` - return `stale after ${num(data.edit_count_since_check)} edit(s) since ${source} at ${at}` - } - - function reviewLine(data: Record) { - const glm = str(data.review_glm_state) === "done" ? "done" : "pending" - const codex = str(data.review_codex_state) === "done" ? "done" : "pending" - const staleSuffix = stale(data, "edits_since_review") - ? ` (stale: ${num(data.edits_since_review)} edit(s) since last review)` - : "" - return `GLM: ${glm}, Codex: ${codex}${staleSuffix}` - } - - function compact(data: Record) { - const block = str(data.last_block) || "none" - const reason = str(data.last_reason) - return [ - "Guardrail runtime state:", - `- unique source reads: ${num(data.read_count)}`, - `- edit/write count: ${num(data.edit_count)}`, - `- fact-check: ${factLine(data)}`, - `- review state: ${reviewLine(data)}`, - `- last block: ${block}${reason ? ` (${reason})` : ""}`, - "Treat missing or stale fact-check/review state as an explicit gate.", - ].join("\n") - } - - function deny(file: string, kind: "read" | "edit") { - const item = rel(input.worktree, file) - if (kind === "read" && has(item, sec)) return "secret material is outside the allowed read surface" - if (hidden(file)) return "guardrail runtime state is plugin-owned" - if (kind === "edit" && has(item, cfg)) return "linter or formatter configuration is policy-protected" - } - - function baseline(old: string, next: string) { - if (/:latest\b/i.test(old) && vers(next).length > 0) { - return ":latest pin requires ADR-backed compatibility verification" - } - const left = vers(old) - const right = vers(next) - if (!left.length || !right.length) return - if (left.length !== right.length || left.length > 3) return - for (let i = 0; i < left.length; i++) { - if (cmp(right[i], left[i]) < 0) return `version baseline regression ${left[i]} -> ${right[i]}` - } - } - - async function version(args: Record) { - const file = pick(args) - if (!file || hidden(file)) return - if (typeof args.oldString === "string" && typeof args.newString === "string") { - return baseline(args.oldString, args.newString) - } - if (typeof args.content !== "string") return - const prev = await Bun.file(file).text().catch(() => "") - if (!prev) return - return baseline(prev, args.content) - } - - async function budget() { - const data = await stash(state) - return num(data.read_count) - } - - function gate(data: { - agent?: string - model?: { - id?: unknown - providerID?: unknown - status?: unknown - cost?: { - input?: number - output?: number - cache?: { read?: number; write?: number } - } - } - }) { - const provider = str(data.model?.providerID) - const agent = str(data.agent) - if (!provider) return - - if (evals.size > 0 && evals.has(provider) && agent !== evalAgent) { - return `${provider} is evaluation-only under confidential policy; use ${evalAgent}` - } - if (evals.size > 0 && agent === evalAgent && !evals.has(provider)) { - return `${evalAgent} is reserved for evaluation-lane providers` - } - - const ids = allow[provider] - const model = str(data.model?.id) - if (ids?.size && model && !ids.has(model)) { - return `${provider}/${model} is not admitted by provider policy` - } - - if (!conf) return - if (denyFree && free(data.model ?? {})) return `${provider}/${model || "unknown"} is a free-tier model` - if (denyPreview && preview(data.model ?? {})) return `${provider}/${model || "unknown"} is preview-only` - } - - // --- Dual review gate helper --- - function reviewGate(data: Record) { - const glm = str(data.review_glm_state) === "done" - const codex = str(data.review_codex_state) === "done" - const pending: string[] = [] - if (!glm) pending.push("GLM code-reviewer") - if (!codex) pending.push("Codex review") - return { - done: glm && codex, - pending, - message: pending.length === 0 ? "all reviews complete" : `pending: ${pending.join(" and ")}`, - } - } - - // Sync composite review_state AFTER individual state writes (avoids TOCTOU) - async function syncReviewState() { - const current = await stash(state) - const gate = reviewGate(current) - await mark({ - review_state: gate.done ? "done" : "", - ...(gate.done ? { edits_since_review: 0 } : {}), - }) - } - - // --- Auto-review pipeline (models team.ts idle/snap pattern) --- - const REVIEW_POLL_GAP = 750 - - const REVIEW_TIMEOUT_MS = 120_000 // 2 minutes max for auto-review - async function pollIdle(sessionID: string) { - const start = Date.now() - for (;;) { - if (Date.now() - start > REVIEW_TIMEOUT_MS) { - throw new Error(`Auto-review timed out after ${REVIEW_TIMEOUT_MS}ms`) - } - const stat = await input.client.session.status({ query: { directory: input.directory } }) - const item = stat.data?.[sessionID] - if (!item || item.type === "idle") return - await Bun.sleep(REVIEW_POLL_GAP) - } - } - - async function readResult(sessionID: string) { - const msgs = await input.client.session.messages({ path: { id: sessionID }, query: { directory: input.directory } }) - const msg = [...(msgs.data ?? [])].reverse().find((m) => m.info.role === "assistant") - if (!msg) return { text: "", error: "" } - const txt = msg.parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("\n") - const err = msg.info.error?.data?.message ?? "" - return { text: txt.slice(0, 4000), error: err } - } - - function parseFindings(raw: string) { - // Match structured finding patterns: "[CRITICAL]", "**CRITICAL**", "CRITICAL:", "- CRITICAL" - // Exclude negations, injected context lines, and prose mentions - const lines = raw.split("\n") - let critical = 0, high = 0, medium = 0, low = 0 - for (const line of lines) { - const trimmed = line.trim() - // Skip negation lines - if (/\b(no|zero|0|none|without|aren't|isn't|not)\b/i.test(trimmed)) continue - // Skip injected guardrail context lines (e.g., "CRITICAL=0 HIGH=0") - if (/CRITICAL=\d|HIGH=\d|checklist|Guardrail mode/i.test(trimmed)) continue - // Match structured patterns: leading marker or bracketed/bold severity - if (/^[\s\-*]*\[?CRITICAL\]?[\s:*]/i.test(trimmed) || /^\*\*CRITICAL\*\*/i.test(trimmed)) critical++ - if (/^[\s\-*]*\[?HIGH\]?[\s:*]/i.test(trimmed) || /^\*\*HIGH\*\*/i.test(trimmed)) high++ - if (/^[\s\-*]*\[?MEDIUM\]?[\s:*]/i.test(trimmed) || /^\*\*MEDIUM\*\*/i.test(trimmed)) medium++ - if (/^[\s\-*]*\[?LOW\]?[\s:*]/i.test(trimmed) || /^\*\*LOW\*\*/i.test(trimmed)) low++ - } - return { critical, high, medium, low, total: critical + high + medium + low } - } - - async function autoReview(parentSession: string, data: Record) { - const made = await input.client.session.create({ - body: { parentID: parentSession, title: "Auto-review" }, - query: { directory: input.directory }, - }) - await input.client.session.promptAsync({ - path: { id: made.data.id }, - query: { directory: input.directory }, - body: { - agent: "code-reviewer", - tools: { edit: false, write: false, apply_patch: false, multiedit: false }, - parts: [{ - type: "text", - text: `Review the current working directory changes for quality, correctness, and security.\nEdited files: ${list(data.edited_files).join(", ") || "unknown"}\nEdit count: ${num(data.edit_count)}\nReport findings as CRITICAL, HIGH, MEDIUM, or LOW.`, - }], - }, - }) - await pollIdle(made.data.id) - const result = await readResult(made.data.id) - // Do not mark review as done if the session errored or returned empty - if (result.error || !result.text.trim()) { - await mark({ auto_review_in_progress: false }) - await seen("auto_review.errored", { error: result.error || "empty response" }) - return - } - const findings = parseFindings(result.text) - const attempts = num(data.workflow_review_attempts) + 1 - if (attempts >= 3) { - await mark({ auto_review_in_progress: false, workflow_phase: "blocked", workflow_review_attempts: attempts }) - await seen("auto_review.max_attempts", { attempts }) - return - } - await mark({ - auto_review_in_progress: false, - auto_review_session: made.data.id, - review_glm_state: "done", - review_glm_at: new Date().toISOString(), - reviewed: true, - workflow_review_attempts: attempts, - review_at: new Date().toISOString(), - edits_since_review: 0, - review_critical_count: findings.critical, - review_high_count: findings.high, - }) - await syncReviewState() - await seen("auto_review.completed", { findings: findings.total, critical: findings.critical, high: findings.high }) - if (findings.critical > 0 || findings.high > 0) { - await input.client.session.prompt({ - path: { id: parentSession }, - query: { directory: input.directory }, - body: { - noReply: true, - parts: [{ - type: "text", - text: `[Auto-review] CRITICAL=${findings.critical} HIGH=${findings.high}. Fix findings before merging.\n\n${result.text.slice(0, 2000)}`, - }], - }, - }) - await mark({ workflow_phase: "fixing" }) - } - } - - function checklist(data: Record) { - const items = [ - { name: "tests_pass", pass: flag(data.tests_executed) }, - { name: "review_glm", pass: str(data.review_glm_state) === "done" }, - { name: "review_codex", pass: str(data.review_codex_state) === "done" }, - { name: "review_fresh", pass: (str(data.review_glm_state) === "done" || str(data.review_codex_state) === "done") && num(data.edits_since_review) === 0 }, - { name: "ci_green", pass: flag(data.ci_green) }, - { name: "no_critical", pass: num(data.review_critical_count) === 0 && num(data.review_high_count) === 0 }, - ] - return { - score: items.filter((i) => i.pass).length, - total: items.length, - blocking: items.filter((i) => !i.pass).map((i) => i.name), - summary: items.map((i) => `[${i.pass ? "x" : " "}] ${i.name}`).join(", "), - } - } +import { createAccessHandlers } from "./guardrail-access" +import { createContext, type GuardrailInput } from "./guardrail-context" +import { createGitHandlers } from "./guardrail-git" +import { createReviewPipeline } from "./guardrail-review" +import { flag, git, json, list, num, save, stash, str } from "./guardrail-patterns" + +export default async function guardrail( + input: GuardrailInput, + opts?: Record, +) { + const ctx = await createContext(input, opts) + const access = createAccessHandlers(ctx) + const review = createReviewPipeline(ctx) + const gitHandlers = createGitHandlers(ctx, review) return { config: async (cfg: { provider?: Record }) => { - for (const key of Object.keys(allow)) delete allow[key] + for (const key of Object.keys(ctx.allow)) delete ctx.allow[key] for (const [key, val] of Object.entries(cfg.provider ?? {})) { const ids = list(val.whitelist) if (!ids.length) continue - allow[key] = new Set(ids) + ctx.allow[key] = new Set(ids) } }, event: async ({ event }: { event: { type?: string; properties?: Record } }) => { if (!event.type) return if (!["session.created", "permission.asked", "session.idle", "session.compacted"].includes(event.type)) return - await seen(event.type, note(event.properties)) + await ctx.seen(event.type, ctx.note(event.properties)) if (event.type === "session.created") { - // [W9] auto-init-permissions: detect project stack on session start const stacks: string[] = [] try { const { existsSync } = await import("fs") - if (existsSync(path.join(input.worktree, "package.json"))) stacks.push("node") - if (existsSync(path.join(input.worktree, "pyproject.toml")) || existsSync(path.join(input.worktree, "requirements.txt"))) stacks.push("python") - if (existsSync(path.join(input.worktree, "go.mod"))) stacks.push("go") - if (existsSync(path.join(input.worktree, "Dockerfile")) || existsSync(path.join(input.worktree, "terraform"))) stacks.push("infra") - } catch { /* fs check may fail */ } + if (existsSync(path.join(ctx.input.worktree, "package.json"))) stacks.push("node") + if (existsSync(path.join(ctx.input.worktree, "pyproject.toml")) || existsSync(path.join(ctx.input.worktree, "requirements.txt"))) stacks.push("python") + if (existsSync(path.join(ctx.input.worktree, "go.mod"))) stacks.push("go") + if (existsSync(path.join(ctx.input.worktree, "Dockerfile")) || existsSync(path.join(ctx.input.worktree, "terraform"))) stacks.push("infra") + } catch {} - // [W9] enforce-branch-workflow: check branch on session start let branchWarning = "" try { - const branchRes = await git(input.worktree, ["branch", "--show-current"]) + const branchRes = await git(ctx.input.worktree, ["branch", "--show-current"]) if (branchRes.code !== 0) throw new Error("git branch failed") const currentBranch = branchRes.stdout.trim() if (/^(main|master)$/.test(currentBranch)) { branchWarning = `WARNING: on ${currentBranch} branch. Create a feature branch: git checkout -b feat/ develop` } else if (currentBranch === "develop") { - branchWarning = `On develop branch. Use feature branch for implementation: git checkout -b feat/` + branchWarning = "On develop branch. Use feature branch for implementation: git checkout -b feat/" } - } catch { /* git may fail */ } + } catch {} - await mark({ + await ctx.mark({ last_session: event.properties?.sessionID, last_event: event.type, read_files: [], @@ -672,7 +73,6 @@ export default async function guardrail(input: { review_codex_state: "", review_glm_at: "", review_codex_at: "", - // Delegation gates (Map-based tracking for race safety) active_tasks: {}, active_task_count: 0, llm_call_count: 0, @@ -683,7 +83,6 @@ export default async function guardrail(input: { last_merge_at: "", issue_verification_done: false, edits_since_doc_reminder: 0, - // Workflow orchestration state workflow_phase: "idle", workflow_review_attempts: 0, workflow_pr_url: "", @@ -693,71 +92,52 @@ export default async function guardrail(input: { review_critical_count: 0, review_high_count: 0, ci_green: false, - // [W9] auto-init-permissions: detected stacks detected_stacks: stacks, - // [W9] enforce-branch-workflow: branch status branch_warning: branchWarning, }) if (stacks.length > 0) { - await seen("auto_init.stacks_detected", { stacks }) + await ctx.seen("auto_init.stacks_detected", { stacks }) } - // [Phase6] Ignore hygiene: check if .opencode/ is in .gitignore try { - const gitignore = await Bun.file(path.join(input.worktree, ".gitignore")).text() + const gitignore = await Bun.file(path.join(ctx.input.worktree, ".gitignore")).text() if (!gitignore.includes(".opencode")) { - await mark({ gitignore_missing_opencode: true }) - await seen("ignore_hygiene.missing", { pattern: ".opencode/" }) + await ctx.mark({ gitignore_missing_opencode: true }) + await ctx.seen("ignore_hygiene.missing", { pattern: ".opencode/" }) } - } catch { /* .gitignore may not exist */ } + } catch {} if (branchWarning) { - await seen("branch_workflow.warning", { warning: branchWarning }) + await ctx.seen("branch_workflow.warning", { warning: branchWarning }) } - // Codex MCP availability: cache flag and auto-satisfy codex review if not configured try { const settingsPath = path.join(process.env.HOME || "~", ".claude", "settings.json") const settings = JSON.parse(await Bun.file(settingsPath).text().catch(() => "{}")) const mcpServers = settings.mcpServers ?? settings.mcp_servers ?? {} - hasCodexMcp = Object.keys(mcpServers).some((k) => /^codex$/i.test(k) || /^mcp[_-]codex$/i.test(k)) - if (!hasCodexMcp) { - await mark({ review_codex_state: "done", review_codex_at: "auto:no-codex-mcp" }) - await seen("codex_mcp.not_configured", { auto_satisfied: true }) + ctx.hasCodexMcp = Object.keys(mcpServers).some((item) => /^codex$/i.test(item) || /^mcp[_-]codex$/i.test(item)) + if (!ctx.hasCodexMcp) { + await ctx.mark({ review_codex_state: "done", review_codex_at: "auto:no-codex-mcp" }) + await ctx.seen("codex_mcp.not_configured", { auto_satisfied: true }) } - } catch { /* settings check is best-effort */ } + } catch {} } if (event.type === "permission.asked") { - await mark({ + await ctx.mark({ last_permission: event.properties?.permission, last_patterns: event.properties?.patterns, last_event: event.type, }) } if (event.type === "session.idle") { - const data = await stash(state) - const edits = num(data.edit_count) - const pending = str(data.review_glm_state) !== "done" - const inProgress = flag(data.auto_review_in_progress) - const sessionID = str(event.properties?.sessionID) - - if (edits >= 3 && pending && !inProgress && sessionID) { - await mark({ auto_review_in_progress: true }) - await seen("auto_review.triggered", { edit_count: edits, sessionID }) - void autoReview(sessionID, data).catch(async (err) => { - await mark({ auto_review_in_progress: false }) - await seen("auto_review.failed", { error: String(err) }) - }) - } + await review.handleAutoReviewTrigger(str(event.properties?.sessionID)) } if (event.type === "session.compacted") { - await mark({ + await ctx.mark({ last_compacted: event.properties?.sessionID, last_event: event.type, }) } }, "chat.message": async ( - item: { - sessionID: string - }, + _item: { sessionID: string }, out: { message: { id: string @@ -774,8 +154,7 @@ export default async function guardrail(input: { }, ) => { if (out.message.role !== "user") return - const data = await stash(state) - // Surface deferred git freshness advisory from previous fire-and-forget fetch + const data = await stash(ctx.state) const pendingFreshness = str(data.git_freshness_advisory) if (pendingFreshness) { out.parts.push({ @@ -785,14 +164,13 @@ export default async function guardrail(input: { type: "text", text: pendingFreshness, }) - await mark({ git_freshness_advisory: "" }) + await ctx.mark({ git_freshness_advisory: "" }) } if (!flag(data.git_freshness_checked)) { - await mark({ git_freshness_checked: true }) - // Fire-and-forget: do not block chat.message handler on slow git fetch + await ctx.mark({ git_freshness_checked: true }) void (async () => { try { - const proc = Bun.spawn(["git", "-C", input.worktree, "fetch", "--dry-run"], { + const proc = Bun.spawn(["git", "-C", ctx.input.worktree, "fetch", "--dry-run"], { stdout: "pipe", stderr: "pipe", }) @@ -808,17 +186,14 @@ export default async function guardrail(input: { }), ]) if (fetchResult && fetchResult.code === 0 && (fetchResult.stdout.trim() || fetchResult.stderr.includes("From"))) { - await mark({ git_freshness_advisory: "⚠️ Your branch may be behind origin. Consider running `git pull` before making changes." }) + await ctx.mark({ git_freshness_advisory: "⚠️ Your branch may be behind origin. Consider running `git pull` before making changes." }) } - } catch { - // git fetch may fail in offline or no-remote scenarios; skip silently - } + } catch {} })() } - // Branch hygiene: surface stored branch warning from session.created const branchWarn = str(data.branch_warning) if (branchWarn) { - const statusCheck = await git(input.worktree, ["status", "--porcelain"]).catch(() => ({ stdout: "", stderr: "", code: 1 })) + const statusCheck = await git(ctx.input.worktree, ["status", "--porcelain"]).catch(() => ({ stdout: "", stderr: "", code: 1 })) const dirty = statusCheck.stdout.trim().length > 0 && !statusCheck.stderr.trim() out.parts.push({ id: crypto.randomUUID(), @@ -829,9 +204,8 @@ export default async function guardrail(input: { ? `${branchWarn} Uncommitted changes detected — stash or commit before switching branches.` : branchWarn, }) - await mark({ branch_warning: "" }) + await ctx.mark({ branch_warning: "" }) } - // [Phase6] Ignore hygiene advisory if (data.gitignore_missing_opencode) { out.parts.push({ id: crypto.randomUUID(), @@ -840,400 +214,31 @@ export default async function guardrail(input: { type: "text", text: "⚠️ `.opencode/` is not in `.gitignore`. Add it to reduce noise in `git status`.", }) - await mark({ gitignore_missing_opencode: false }) + await ctx.mark({ gitignore_missing_opencode: false }) } }, "tool.execute.before": async ( - item: { tool: string; args?: unknown }, - out: { args: Record }, + item: { tool: string; args?: unknown; callID?: unknown }, + out: { args: Record; output?: string }, ) => { - const file = pick(out.args ?? item.args) - if (file && (item.tool === "read" || MUTATING_TOOLS.has(item.tool))) { - const err = deny(file, item.tool === "read" ? "read" : "edit") - if (err) { - await mark({ last_block: item.tool, last_file: rel(input.worktree, file), last_reason: err }) - throw new Error(text(err)) - } - } - if (MUTATING_TOOLS.has(item.tool)) { - const err = await version(out.args ?? {}) - if (err) { - await mark({ last_block: item.tool, last_file: file ? rel(input.worktree, file) : "", last_reason: err }) - throw new Error(text(err)) - } - } - if ((MUTATING_TOOLS.has(item.tool)) && file && code(file)) { - const count = await budget() - if (count >= 4) { - const budgetData = await stash(state) - const readFiles = list(budgetData.read_files).slice(-5).join(", ") - const err = `context budget exceeded after ${count} source reads (recent: ${readFiles || "unknown"}). Recovery options:\n(1) call \`team\` tool to delegate edit to isolated worker\n(2) use \`background\` tool for side work\n(3) narrow edit scope to a specific function/section rather than whole file\n(4) start a new session and continue from where you left off` - await mark({ last_block: item.tool, last_file: rel(input.worktree, file), last_reason: err }) - throw new Error(text(err)) - } - } if (item.tool === "bash") { + const bashData = await stash(ctx.state) + await access.toolBeforeAccess(item, out, bashData) const cmd = typeof out.args?.command === "string" ? out.args.command : "" - const file = cmd.replaceAll("\\", "/") - if (!cmd) return - // [HIGH-1 fix] Read state once for all bash checks - const bashData = await stash(state) - if (has(file, sec) || file.includes(".opencode/guardrails/")) { - await mark({ last_block: "bash", last_command: cmd, last_reason: "shell access to protected files" }) - throw new Error(text("shell access to protected files")) - } - // [W9] pre-merge: tier-aware gate + CRITICAL/HIGH block (consolidated) - if (/\bgit\s+merge(\s|$)/i.test(cmd) || /\bgh\s+pr\s+merge(\s|$)/i.test(cmd)) { - // Check CRITICAL/HIGH first (applies to all tiers) - const criticalCount = num(bashData.review_critical_count) - const highCount = num(bashData.review_high_count) - if (criticalCount > 0 || highCount > 0) { - const prNum = str(bashData.review_pr_number) - await mark({ last_block: "bash", last_command: cmd, last_reason: `unresolved CRITICAL=${criticalCount} HIGH=${highCount}` }) - throw new Error(text(`merge blocked: PR #${prNum} has unresolved CRITICAL=${criticalCount} HIGH=${highCount} review findings`)) - } - try { - const branchResult = await git(input.worktree, ["branch", "--show-current"]) - if (branchResult.code !== 0) throw new Error("git branch failed") - const branch = branchResult.stdout.trim() - const tier = /^(ci|chore|docs)\//.test(branch) ? "EXEMPT" : - /^fix\//.test(branch) ? "LIGHT" : "FULL" - if (tier === "EXEMPT") { - await seen("pre_merge.tier", { branch, tier, result: "pass" }) - } else if (tier === "LIGHT") { - // LIGHT: at least one review (GLM or Codex) done OR (checks ran AND C/H=0) - const anyReviewDone = str(bashData.review_glm_state) === "done" || str(bashData.review_codex_state) === "done" - const checksRan = Boolean(str(bashData.review_checks_at)) - const noSevere = checksRan && criticalCount === 0 && highCount === 0 - if (!anyReviewDone && !noSevere) { - await mark({ last_block: "bash", last_command: cmd, last_reason: "LIGHT tier: review or C/H=0 required" }) - throw new Error(text("merge blocked (LIGHT tier): run code-reviewer agent OR Codex review OR run `gh pr checks` with CRITICAL=0 HIGH=0")) - } - } else { - // FULL: both GLM and Codex reviews required - const gate = reviewGate(bashData) - if (!gate.done) { - await mark({ last_block: "bash", last_command: cmd, last_reason: `FULL tier: ${gate.message}` }) - throw new Error(text(`merge blocked (FULL tier): ${gate.message}. Run both code-reviewer agent and Codex review before merging.`)) - } - } - } catch (e) { - if (String(e).includes("blocked")) throw e - const fallbackGate = reviewGate(bashData) - if (!fallbackGate.done) { - await mark({ last_block: "bash", last_command: cmd, last_reason: `merge blocked: ${fallbackGate.message}` }) - throw new Error(text(`merge blocked: ${fallbackGate.message}. Run /review and Codex review before merging.`)) - } - } - } - // Workflow checklist gate for merge commands - if (/\bgit\s+merge(\s|$)/i.test(cmd) || /\bgh\s+pr\s+merge(\s|$)/i.test(cmd)) { - const checks = checklist(bashData) - if (checks.score < 3) { - out.output = (out.output || "") + `\n\nCompletion checklist (${checks.score}/${checks.total}): ${checks.summary}\nBlocking: ${checks.blocking.join(", ")}` - } - } - // CI hard block: verify all checks are green before gh pr merge - if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { - try { - const prMatch = cmd.match(/\bgh\s+pr\s+merge\s+(\d+)/i) - const prArg = prMatch ? prMatch[1] : "" - const proc = Bun.spawn(["gh", "pr", "checks", ...(prArg ? [prArg] : [])], { - cwd: input.worktree, - stdout: "pipe", - stderr: "pipe", - }) - const [ciOut] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]) - if (proc.exitCode !== 0 || /fail|pending/i.test(ciOut)) { - await mark({ last_block: "bash", last_command: cmd, last_reason: "CI checks not all green" }) - throw new Error(text("merge blocked: CI checks not all green — run `gh pr checks` to verify")) - } - } catch (e) { - if (String(e).includes("blocked")) throw e - // gh unavailable or network failure — log so CI skip is observable - await mark({ last_block: "bash:ci-warn", last_command: cmd, last_reason: "CI check verification failed" }) - await seen("ci.check_verification_failed", { error: String(e) }) - } - } - // [Phase6] Unresolved review comments: block merge if CHANGES_REQUESTED - if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { - try { - const repoRes = await git(input.worktree, ["remote", "get-url", "origin"]) - const repo = repoRes.code === 0 ? repoRes.stdout.trim().replace(/.*github\.com[:/]/, "").replace(/\.git$/, "") : "" - const prMatch2 = cmd.match(/\bgh\s+pr\s+merge\s+(\d+)/i) - const prNum2 = prMatch2 ? prMatch2[1] : "" - if (repo && prNum2) { - const proc2 = Bun.spawn(["gh", "api", `repos/${repo}/pulls/${prNum2}/reviews`, "--jq", "[.[] | select(.state==\"CHANGES_REQUESTED\")] | length"], { - cwd: input.worktree, stdout: "pipe", stderr: "pipe", - }) - const [revOut] = await Promise.all([new Response(proc2.stdout).text(), proc2.exited]) - if (parseInt(revOut.trim()) > 0) { - await mark({ last_block: "bash", last_command: cmd, last_reason: "unresolved CHANGES_REQUESTED reviews" }) - throw new Error(text("merge blocked: unresolved CHANGES_REQUESTED reviews — address reviewer feedback first")) - } - } - } catch (e) { if (String(e).includes("blocked")) throw e } - } - // [Phase6] Stacked PR rebase gate: warn if child PRs exist and are stale - if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { - try { - const curBranchRes = await git(input.worktree, ["branch", "--show-current"]) - const curBranch = curBranchRes.code === 0 ? curBranchRes.stdout.trim() : "" - if (curBranch) { - const proc3 = Bun.spawn(["gh", "pr", "list", "--base", curBranch, "--json", "number,headRefName", "--jq", ".[].number"], { - cwd: input.worktree, stdout: "pipe", stderr: "pipe", - }) - const [childOut] = await Promise.all([new Response(proc3.stdout).text(), proc3.exited]) - const childPRs = childOut.trim().split("\n").filter(Boolean) - if (childPRs.length > 0) { - out.output = (out.output || "") + `\n⚠️ Stacked PR detected: ${childPRs.length} child PR(s) depend on this branch. Rebase child PRs after merging.` - await seen("stacked_pr.children_detected", { count: childPRs.length, children: childPRs }) - } - } - } catch { /* gh may fail */ } - } - // Direct push to protected branches - const protectedBranch = /^(main|master|develop|dev)$/ - if (/\bgit\s+push\b/i.test(cmd)) { - // Check explicit branch target - const explicitMatch = cmd.match(/\bgit\s+push\s+(?:(?:-\w+|--[\w-]+)\s+)*\S+\s+(?:HEAD:)?(\S+)/i) - if (explicitMatch && protectedBranch.test(explicitMatch[1])) { - throw new Error(text("direct push to protected branch blocked — use a PR workflow")) - } - // Check refspec form HEAD:branch - const refspecMatch = cmd.match(/HEAD:(main|master|develop|dev)(?:\s|$)/i) - if (refspecMatch) { - throw new Error(text("direct push to protected branch blocked — use a PR workflow")) - } - // Plain `git push` with no branch — check current branch - if (!/\bgit\s+push\s+(?:(?:-\w+|--[\w-]+)\s+)*\S+\s+\S+/i.test(cmd)) { - try { - const result = await git(input.worktree, ["branch", "--show-current"]) - if (result.code === 0 && result.stdout && protectedBranch.test(result.stdout.trim())) { - throw new Error(text("direct push to protected branch blocked — use a PR workflow")) - } - } catch (e) { if (String(e).includes("blocked")) throw e } - } - } - // [W9] enforce-develop-base: block branch creation from main when develop exists - if (/\bgit\s+(checkout\s+-b|switch\s+-c)\b/i.test(cmd)) { - try { - const devCheck = await git(input.worktree, ["rev-parse", "--verify", "origin/develop"]) - if (devCheck.code === 0 && devCheck.stdout.trim()) { - const branchCheck = await git(input.worktree, ["branch", "--show-current"]) - const branch = branchCheck.code === 0 ? branchCheck.stdout.trim() : "" - if (/^(main|master)$/.test(branch)) { - await mark({ last_block: "bash", last_command: cmd, last_reason: "branch creation from main blocked" }) - throw new Error(text("branch creation from main blocked: checkout develop first, then create branch")) - } - } - } catch (e) { if (String(e).includes("blocked")) throw e } - } - // [W9] block-manual-merge-ops: block cherry-pick, arbitrary rebase/merge, branch rename - if (/\bgit\s+(cherry-pick)\b/i.test(cmd) && !/--abort\b/i.test(cmd)) { - await mark({ last_block: "bash", last_command: cmd, last_reason: "cherry-pick blocked: delegate to Codex CLI" }) - throw new Error(text("cherry-pick blocked: delegate to Codex CLI for context-heavy merge operations")) - } - if (/\bgit\s+rebase\b/i.test(cmd) && !/--abort\b/i.test(cmd)) { - if (/\bgit\s+rebase\s+(origin\/)?(main|master|develop)\b/i.test(cmd)) { - await mark({ rebase_session_active: true, rebase_session_at: new Date().toISOString() }) - } else if (/\bgit\s+rebase\s+--(continue|skip)\b/i.test(cmd)) { - const d = await stash(state) - const at = str(d.rebase_session_at) - if (!at || Date.now() - new Date(at).getTime() > 3600_000) { - await mark({ last_block: "bash", last_command: cmd, last_reason: "rebase --continue/--skip: no active session" }) - throw new Error(text("rebase --continue/--skip blocked: no active permitted rebase session (1h expiry)")) - } - } else { - await mark({ last_block: "bash", last_command: cmd, last_reason: "arbitrary rebase blocked" }) - throw new Error(text("arbitrary rebase blocked: only sync from main/master/develop is permitted")) - } - } - if (/\bgit\s+branch\s+(-[mMfF]\b|--move\b|--force\b)/i.test(cmd)) { - await mark({ last_block: "bash", last_command: cmd, last_reason: "branch rename/force-move blocked" }) - throw new Error(text("branch rename/force-move blocked: prevents commit guard bypass")) - } - // Enforce soak time: develop→main merge requires half-day minimum - if (/\bgit\s+merge(\s|$)/i.test(cmd) || /\bgh\s+pr\s+merge(\s|$)/i.test(cmd)) { - const lastMerge = str(bashData.last_merge_at) - if (lastMerge) { - const elapsed = Date.now() - new Date(lastMerge).getTime() - const halfDay = 12 * 60 * 60 * 1000 - if (elapsed < halfDay) { - const hours = Math.round(elapsed / (60 * 60 * 1000) * 10) / 10 - await mark({ soak_time_warning: true, soak_time_elapsed_h: hours }) - await seen("soak_time.advisory", { elapsed_ms: elapsed, required_ms: halfDay }) - } - } - } - // Enforce follow-up limit: detect 2+ consecutive fix PRs on same feature - if (/\bgh\s+pr\s+create\b/i.test(cmd)) { - const consecutiveFixes = num(bashData.consecutive_fix_prs) - if (consecutiveFixes >= 2) { - await seen("follow_up.limit_reached", { consecutive: consecutiveFixes }) - } - } - // Enforce issue close verification: require evidence before gh issue close - if (/\bgh\s+issue\s+close\b/i.test(cmd)) { - if (!flag(bashData.issue_verification_done)) { - await seen("issue_close.unverified", { command: cmd }) - } - } - // [W9] audit-docker-build-args upgrade: full secret pattern scan + hard block - if (/\bdocker\s+build\b/i.test(cmd)) { - const secretPatterns = [ - /^(AKIA[A-Z0-9]{16})/, // AWS access key - /^(sk-[a-zA-Z0-9]{20,})/, // OpenAI/Stripe key - /^(ghp_[a-zA-Z0-9]{36})/, // GitHub PAT - /^(gho_[a-zA-Z0-9]{36})/, // GitHub OAuth - /^(ghs_[a-zA-Z0-9]{36})/, // GitHub App - /^(glpat-[a-zA-Z0-9-]{20,})/, // GitLab PAT - /^(xox[bprs]-[a-zA-Z0-9-]+)/, // Slack token - /^(npm_[a-zA-Z0-9]{36})/, // npm token - /BEGIN\s+(RSA|EC|PRIVATE)/, // Private key header - ] - const buildArgMatches = cmd.matchAll(/--build-arg\s+(\w+)=(\S+)/gi) - for (const m of buildArgMatches) { - const argName = m[1].toUpperCase() - const argValue = m[2] - const nameHit = /(SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL|API_KEY|PRIVATE|AUTH)/i.test(argName) - const valueHit = secretPatterns.some((p) => p.test(argValue)) - if (nameHit || valueHit) { - await mark({ docker_secret_warning: true, docker_secret_arg: m[1], last_block: "bash", last_reason: "docker secret in build-arg" }) - await seen("docker.secret_in_build_arg", { arg_name: m[1], pattern: "redacted" }) - throw new Error(text("docker build --build-arg contains secrets: use Docker build secrets (--secret) or multi-stage builds instead")) - } - } - } - // [W9] enforce-review-reading upgrade: hard block merge if review is stale - if (/\bgh\s+pr\s+merge\b/i.test(cmd)) { - const reviewAt = str(bashData.review_at) - const lastPushAt = str(bashData.last_push_at) - if (reviewAt && lastPushAt && new Date(reviewAt) < new Date(lastPushAt)) { - await mark({ review_reading_warning: true, last_block: "bash", last_reason: "stale review: push after review" }) - await seen("review_reading.stale", { review_at: reviewAt, last_push_at: lastPushAt }) - throw new Error(text("merge blocked: code was pushed after the last review. Re-request review before merging.")) - } - } - // [W9] enforce-deploy-verify-on-pr: require deploy evidence for infra changes - if (/\bgh\s+pr\s+create\b/i.test(cmd)) { - try { - const diffRes = await git(input.worktree, ["diff", "--name-only", "origin/develop...HEAD"]) - if (diffRes.code !== 0) throw new Error("git diff failed") - const changedFiles = diffRes.stdout.trim() - const hasInfra = /^(hooks\/|scripts\/)[^/]+\.sh$/m.test(changedFiles) - if (hasInfra) { - if (!flag(bashData.deploy_verified)) { - await seen("deploy_verify.missing", { files: changedFiles.split("\n").filter((f: string) => /^(hooks|scripts)\//.test(f)) }) - // Advisory only — not blocking, but strongly recommended - await mark({ deploy_verify_warning: true }) - } - } - } catch { /* git diff may fail — non-blocking */ } - } - // [W9] pr-guard upgrade: hard block for missing issue ref + --base main - if (/\bgh\s+pr\s+create\b/i.test(cmd)) { - // Block: --base main when develop exists - if (/--base\s+main(\s|$)/i.test(cmd)) { - try { - const devCheck = await git(input.worktree, ["rev-parse", "--verify", "origin/develop"]) - if (devCheck.code === 0 && devCheck.stdout.trim()) { - // Allow release PR from develop - const branchForPr = await git(input.worktree, ["branch", "--show-current"]) - const branch = branchForPr.code === 0 ? branchForPr.stdout.trim() : "" - if (branch !== "develop" && !/--head\s+develop/i.test(cmd)) { - await mark({ last_block: "bash", last_command: cmd, last_reason: "PR targeting main when develop exists" }) - throw new Error(text("PR targeting main blocked: use --base develop. Release PRs must be from develop branch.")) - } - } - } catch (e) { if (String(e).includes("blocked")) throw e } - } - // Advisory: missing issue reference (Closes/Fixes/Resolves #XX) - // Note: cmd may not contain --body content (editor-based flow), so advisory not hard block - if (!/\b(closes?|fixes?|resolves?)\s*#\d+/i.test(cmd) && !/#\d+/i.test(cmd)) { - await mark({ pr_guard_issue_ref_warning: true }) - await seen("pr_guard.missing_issue_ref", { command: cmd.slice(0, 200) }) - } - // Advisory: preflight checks - const testRan = flag(bashData.tests_executed) - const typeChecked = flag(bashData.type_checked) - if (!testRan || !typeChecked) { - await mark({ pr_guard_warning: true, pr_guard_tests: testRan, pr_guard_types: typeChecked }) - await seen("pr_guard.preflight_incomplete", { tests: testRan, types: typeChecked }) - } - } - // [NEW] stop-test-gate: block ship/deploy without test verification - if (/\b(git\s+push|gh\s+pr\s+merge)\b/i.test(cmd) && !/\bfetch\b/i.test(cmd)) { - if (!flag(bashData.tests_executed) && num(bashData.edit_count) >= 3) { - await mark({ stop_test_warning: true }) - await seen("stop_test_gate.untested", { edit_count: num(bashData.edit_count) }) - } - } - if (!bash(cmd)) return - if (!cfg.some((rule) => rule.test(file)) && !file.includes(".opencode/guardrails/")) return - await mark({ last_block: "bash", last_command: cmd, last_reason: "protected runtime or config mutation" }) - throw new Error(text("protected runtime or config mutation")) - } - // [W9] enforce-seed-data-verification: block seed/knowledge file writes without verification - if ((item.tool === "write") && file) { - const relFile = rel(input.worktree, file) - if (/seed_knowledge|knowledge\.(yaml|yml|json)$/i.test(relFile)) { - const content = typeof out.args?.content === "string" ? out.args.content : "" - if (content && /(電話|phone|営業時間|hours|休[館日]|holiday|料金|price|住所|address)/i.test(content)) { - if (!/(verified|検証済|参照元|source:|ref:)/i.test(content)) { - await mark({ last_block: "write", last_file: relFile, last_reason: "seed data without verification source" }) - throw new Error(text("knowledge/seed data write blocked: content contains factual claims without verification source. Add 'verified' or 'source:' comment.")) - } - } - } - } - // Delegation: parallel execution gate for task tool (Map-based to avoid race conditions) - if (item.tool === "task") { - const data = await stash(state) - const activeTasks = json(data.active_tasks) - const staleThreshold = 5 * 60 * 1000 - // Staleness recovery: remove entries older than 5 minutes (crash protection) - for (const [id, ts] of Object.entries(activeTasks)) { - if (typeof ts === "number" && Date.now() - ts > staleThreshold) { - await seen("delegation.stale_reset", { task_id: id, age_ms: Date.now() - ts }) - delete activeTasks[id] - } - } - const activeCount = Object.keys(activeTasks).length - if (activeCount >= maxParallelTasks) { - const err = `parallel task limit reached (${activeCount}/${maxParallelTasks}); wait for a running task to complete before delegating more` - await mark({ last_block: "task", last_reason: err, active_tasks: activeTasks }) - throw new Error(text(err)) - } - const callID = str((item as Record).callID) || str((item.args as Record)?.callID) || `task_${Date.now()}` - activeTasks[callID] = Date.now() - await mark({ active_tasks: activeTasks, active_task_count: Object.keys(activeTasks).length }) - } - // Domain naming advisory for new file creation (flag for user-visible output in after hook) - if (item.tool === "write" && file) { - const relFile = rel(input.worktree, file) - const fileName = path.basename(relFile) - for (const [dir, pattern] of Object.entries(domainDirs)) { - if (relFile.startsWith(dir) && !pattern.test(fileName)) { - await mark({ domain_naming_warning: relFile, domain_naming_expected: pattern.source, domain_naming_dir: dir }) - await seen("domain_naming.mismatch", { file: relFile, expected_pattern: pattern.source, dir }) - } + if (cmd) { + await gitHandlers.bashBeforeGit(cmd, out, bashData) } + return } + await access.toolBeforeAccess(item, out) }, "tool.execute.after": async ( - item: { tool: string; args?: Record }, + item: { tool: string; args?: Record; callID?: unknown }, out: { title: string; output: string; metadata: Record }, ) => { const now = new Date().toISOString() - const file = pick(item.args) - const data = await stash(state) + const data = await stash(ctx.state) - // [HIGH-3 fix] TOCTOU check at START of after-hook (before any mark() calls) try { const prevSha = str(data.state_sha256) if (prevSha) { @@ -1242,129 +247,29 @@ export default async function guardrail(input: { hasher.update(JSON.stringify(rest)) const actualSha = hasher.digest("hex") if (prevSha !== actualSha) { - await seen("state_integrity.toctou_detected", { expected: prevSha, actual: actualSha }) + await ctx.seen("state_integrity.toctou_detected", { expected: prevSha, actual: actualSha }) } } - } catch { /* hash check is best-effort */ } + } catch {} - // [Issue #148] Plan→Auto chain: auto-start implementing after plan approval - // Only chain when no active workflow is already running (idle/undefined) if (item.tool === "plan_exit") { try { const currentPhase = str(data.workflow_phase) if (!currentPhase || currentPhase === "idle") { - await mark({ workflow_phase: "implementing", workflow_review_attempts: 0 }) - await seen("workflow.plan_approved", { sessionID: "plan_exit" }) + await ctx.mark({ workflow_phase: "implementing", workflow_review_attempts: 0 }) + await ctx.seen("workflow.plan_approved", { sessionID: "plan_exit" }) } - } catch { /* plan chain is best-effort */ } - } - - if (item.tool === "read" && file) { - if (code(file)) { - const seen = list(data.read_files) - const next = seen.includes(rel(input.worktree, file)) ? seen : [...seen, rel(input.worktree, file)] - await mark({ - read_files: next, - read_count: next.length, - last_read: rel(input.worktree, file), - }) - } - if (fact(file)) { - await mark({ - factchecked: true, - factcheck_source: "DocRead", - factcheck_at: now, - edit_count_since_check: 0, - }) - } - } - - if (item.tool === "webfetch" || item.tool.startsWith("mcp__context7__")) { - await mark({ - factchecked: true, - factcheck_source: item.tool === "webfetch" ? "WebFetch" : "Context7", - factcheck_at: now, - edit_count_since_check: 0, - }) + } catch {} } - if (item.tool === "bash") { - const cmd = typeof item.args?.command === "string" ? item.args.command : "" - if (/(^|&&|\|\||;)\s*(gcloud|kubectl|aws)\s+/i.test(cmd)) { - await mark({ - factchecked: true, - factcheck_source: "CLI", - factcheck_at: now, - edit_count_since_check: 0, - }) - } - // Reset review_state on mutating bash commands (sed -i, redirects, etc.) - if (bash(cmd)) { - await mark({ - edits_since_review: num(data.edits_since_review) + 1, - review_glm_state: "", - review_codex_state: hasCodexMcp ? "" : "done", - review_state: "", - }) - } - } + await access.toolAfterAccess(item, out, data) - if (MUTATING_TOOLS.has(item.tool) && file) { - const seen = list(data.edited_files) - const next = seen.includes(rel(input.worktree, file)) ? seen : [...seen, rel(input.worktree, file)] - const nextEditCount = num(data.edit_count) + 1 - await mark({ - edited_files: next, - edit_count: nextEditCount, - edit_count_since_check: num(data.edit_count_since_check) + 1, - edits_since_review: num(data.edits_since_review) + 1, - last_edit: rel(input.worktree, file), - review_glm_state: "", - review_codex_state: hasCodexMcp ? "" : "done", - review_state: "", - }) - - if (/\.(test|spec)\.(ts|tsx|js|jsx)$|(^|\/)test_.*\.py$|_test\.go$/.test(rel(input.worktree, file))) { - out.output += "\n\n🧪 Test file modified. Verify this test actually FAILS without the fix (test falsifiability)." - } - - if (code(file) && nextEditCount > 0 && nextEditCount % 3 === 0) { - out.output += "\n\n📝 Source code edited (3+ operations). Check if related documentation (README, AGENTS.md, ADRs) needs updating." - } - // Auto-format reminder after 3+ source edits - if (code(file) && nextEditCount >= 3 && nextEditCount % 3 === 0) { - out.output = (out.output || "") + "\n🎨 " + nextEditCount + " source edits — consider running formatter (`prettier --write`, `biome format`, `go fmt`)." - } - } - - // Architecture layer advisory - if (MUTATING_TOOLS.has(item.tool) && file && code(file)) { - const relFile = rel(input.worktree, file) - const content = typeof item.args?.content === "string" ? item.args.content : - typeof item.args?.newString === "string" ? item.args.newString : "" - if (content) { - const isUI = /^(src\/(ui|components|tui)\/)/i.test(relFile) - const isAPI = /^(src\/(api|routes)\/)/i.test(relFile) - const importsDB = /from\s+['"].*\/(db|database|model|sql)\//i.test(content) - const importsUI = /from\s+['"].*\/(ui|components|tui)\//i.test(content) - if (isUI && importsDB) { - out.output += "\n⚠️ Architecture: UI layer importing from DB layer directly. Consider using a service/repository layer." - } - if (isAPI && importsUI) { - out.output += "\n⚠️ Architecture: API layer importing from UI layer. This creates a circular dependency risk." - } - } - } - - // [W9] inject-claude-review-on-checks: track review severity from gh pr checks output if (item.tool === "bash" && /\bgh\s+pr\s+checks\b/i.test(str(item.args?.command))) { - // Parse gh pr checks output for review comment severity hints - const checksOutput = out.output || "" - const criticalMatches = checksOutput.match(/CRITICAL[=:]?\s*(\d+)/i) - const highMatches = checksOutput.match(/HIGH[=:]?\s*(\d+)/i) + const criticalMatches = out.output.match(/CRITICAL[=:]?\s*(\d+)/i) + const highMatches = out.output.match(/HIGH[=:]?\s*(\d+)/i) const prNumMatch = str(item.args?.command).match(/\bgh\s+pr\s+checks\s+(\d+)/i) if (criticalMatches || highMatches || prNumMatch) { - await mark({ + await ctx.mark({ review_critical_count: criticalMatches ? parseInt(criticalMatches[1]) : 0, review_high_count: highMatches ? parseInt(highMatches[1]) : 0, review_pr_number: prNumMatch ? prNumMatch[1] : "", @@ -1373,61 +278,13 @@ export default async function guardrail(input: { } } - // CI status advisory after push/PR create - if (item.tool === "bash" && /\b(git\s+push|gh\s+pr\s+create)\b/i.test(str(item.args?.command))) { - out.output = (out.output || "") + "\n⚠️ Remember to verify CI status: `gh pr checks`" - // [Phase6] PR mergeable conflict check - try { - const proc = Bun.spawn(["gh", "pr", "view", "--json", "mergeable", "--jq", ".mergeable"], { - cwd: input.worktree, stdout: "pipe", stderr: "pipe", - }) - const [mergeOut] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) - const mergeable = mergeOut.trim() - if (mergeable === "CONFLICTING") { - out.output += "\n⚠️ PR has merge conflicts. Rebase or merge the base branch before proceeding." - await seen("pr.merge_conflict_detected", {}) - } - } catch { /* gh may fail in offline/no-PR scenarios */ } - } - - // Post-merge deployment verification advisory - if (item.tool === "bash" && /\bgh\s+pr\s+merge\b/i.test(str(item.args?.command))) { - out.output = (out.output || "") + "\n🚀 Post-merge: verify deployment status and run smoke tests on the target environment." - // [W9] enforce-post-merge-validation: detect high-risk changes in merged PR - try { - const prMatch = str(item.args?.command).match(/\bgh\s+pr\s+merge\s+(\d+)/i) - if (prMatch) { - const prNum = prMatch[1] - const repoRes = await git(input.worktree, ["remote", "get-url", "origin"]) - const repo = repoRes.code === 0 ? repoRes.stdout.trim().replace(/.*github\.com[:/]/, "").replace(/\.git$/, "") : "" - if (repo) { - const proc = Bun.spawn(["gh", "api", `repos/${repo}/pulls/${prNum}/files`, "--jq", ".[].filename"], { - cwd: input.worktree, stdout: "pipe", stderr: "pipe", - }) - const [filesOut] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) - const files = filesOut.trim() - const risks: string[] = [] - if (/^terraform\/|\.tf$/m.test(files)) risks.push("Terraform") - if (/migration|migrate|\.sql$/im.test(files)) risks.push("Migration/DDL") - if (/^\.github\/workflows\//m.test(files)) risks.push("GitHub Actions") - if (/Dockerfile|docker-compose|cloudbuild/im.test(files)) risks.push("Docker/Cloud Build") - if (/deploy|release/im.test(files)) risks.push("Deploy/Release") - if (risks.length > 0) { - out.output += "\n\n⚠️ [POST-MERGE VALIDATION] High-risk changes detected: " + risks.join(", ") + ".\nChecklist: HTTP 200, HTTPS, no errors in logs, migration rollback plan, Terraform plan attached, Docker --platform linux/amd64." - await seen("post_merge.validation_required", { pr: prNum, risks }) - } - } - } - } catch { /* gh api may fail — non-blocking */ } - } - if (item.tool === "task") { const cmd = typeof item.args?.command === "string" ? item.args.command : "" const agent = typeof item.args?.subagent_type === "string" ? item.args.subagent_type : "" if (cmd === "review" || agent.includes("review")) { const isCodexReview = /codex/i.test(agent) || /codex/i.test(cmd) if (isCodexReview) { - await mark({ + await ctx.mark({ reviewed: true, review_at: now, review_agent: agent, @@ -1435,7 +292,7 @@ export default async function guardrail(input: { review_codex_at: now, }) } else { - await mark({ + await ctx.mark({ reviewed: true, review_at: now, review_agent: agent, @@ -1443,315 +300,98 @@ export default async function guardrail(input: { review_glm_at: now, }) } - await syncReviewState() + await review.syncReviewState() } - // Delegation: remove completed task from active tasks map const activeTasks = json(data.active_tasks) - const callID = str((item as Record).callID) || str(item.args?.callID) || "" + const callID = str(item.callID) || str(item.args?.callID) || "" if (callID && activeTasks[callID]) { delete activeTasks[callID] - await mark({ active_tasks: activeTasks, active_task_count: Object.keys(activeTasks).length }) + await ctx.mark({ active_tasks: activeTasks, active_task_count: Object.keys(activeTasks).length }) } else if (Object.keys(activeTasks).length > 0) { - // Fallback: remove oldest entry if callID not found const oldest = Object.entries(activeTasks).sort((a, b) => a[1] - b[1])[0] if (oldest) delete activeTasks[oldest[0]] - await mark({ active_tasks: activeTasks, active_task_count: Object.keys(activeTasks).length }) + await ctx.mark({ active_tasks: activeTasks, active_task_count: Object.keys(activeTasks).length }) } - // Delegation: track per-agent delegation count const agentDelegations = num(data[`delegation_${agent}`]) - await mark({ [`delegation_${agent}`]: agentDelegations + 1 }) - - // Verify agent output: parse payload to detect empty responses + await ctx.mark({ [`delegation_${agent}`]: agentDelegations + 1 }) const rawOutput = str(out.output) const taskResultMatch = rawOutput.match(/([\s\S]*?)<\/task_result>/) const payload = taskResultMatch ? taskResultMatch[1].trim() : rawOutput.trim() if (agent && payload.length < 20) { out.output = (out.output || "") + "\n⚠️ Agent output appears empty or trivially short (" + payload.length + " chars). Verify the agent completed its task." - await seen("verify_agent.short_output", { agent, payload_length: payload.length }) - } - } - - // Detect Codex review completion (input-based detection to prevent spoofing) - if (item.tool === "mcp__codex__codex") { - const prompt = str(item.args?.prompt || item.args?.command || "") - if (/\b(review|code[\.\-_]review|diff[\.\-_]review)\b/i.test(prompt)) { - const codexOutput = str(out.output).trim() - if (!codexOutput || codexOutput.length < 20) { - await seen("codex_review.empty_or_short", { length: codexOutput.length }) - } else { - const codexFindings = parseFindings(codexOutput) - await mark({ - reviewed: true, - review_codex_state: "done", - review_codex_at: new Date().toISOString(), - }) - await syncReviewState() - await seen("codex_review.completed", { critical: codexFindings.critical, high: codexFindings.high }) - } - } - } - - // Tool failure recovery: detect consecutive failures via metadata exit code - const exitCode = typeof out.metadata?.exitCode === "number" ? out.metadata.exitCode : undefined - const isBashFail = item.tool === "bash" && exitCode !== undefined && exitCode !== 0 - const isToolError = out.title === "Error" || (typeof out.metadata?.error === "string" && out.metadata.error !== "") - if (isBashFail || isToolError) { - const failures = num(data.consecutive_failures) + 1 - await mark({ consecutive_failures: failures, last_failure_tool: item.tool }) - if (failures >= 3) { - out.output = (out.output || "") + "\n⚠️ " + failures + " consecutive tool failures detected. Consider: (1) checking error root cause, (2) trying alternate approach, (3) delegating to a specialist agent." - } - } else if (item.tool !== "read") { - if (num(data.consecutive_failures) > 0) { - await mark({ consecutive_failures: 0 }) - } - } - - // Post-merge: track merge timestamp for soak time and suggest issue close - if (item.tool === "bash" && /\bgh\s+pr\s+merge\b/i.test(str(item.args?.command))) { - await mark({ last_merge_at: now }) - if (/\b(fix(es)?|close[sd]?|resolve[sd]?)\s+#\d+/i.test(out.output)) { - out.output = (out.output || "") + "\n📋 Detected issue reference in merge output. Verify referenced issues are closed." - } - } - // Soak time advisory: surface warning set during tool.execute.before - if (item.tool === "bash" && (/\bgit\s+merge(\s|$)/i.test(str(item.args?.command)) || /\bgh\s+pr\s+merge(\s|$)/i.test(str(item.args?.command)))) { - const freshData = await stash(state) - if (flag(freshData.soak_time_warning)) { - const hours = num(freshData.soak_time_elapsed_h) - out.output = (out.output || "") + "\n⏳ Soak time advisory: only " + hours + "h since last merge (12h recommended). Consider waiting before merging to main." - await mark({ soak_time_warning: false }) - } - } - - // [W9] workflow-sync-guard: warn when workflow files differ from main after push - if (item.tool === "bash" && /\bgit\s+push\b/i.test(str(item.args?.command))) { - try { - const wfBranch = await git(input.worktree, ["branch", "--show-current"]) - const branch = wfBranch.code === 0 ? wfBranch.stdout.trim() : "" - if (branch && !/^(main|master)$/.test(branch)) { - const wfDiff = await git(input.worktree, ["diff", "--name-only", "main..HEAD", "--", ".github/workflows/"]) - const wfFiles = wfDiff.code === 0 ? wfDiff.stdout.trim() : "" - if (wfFiles) { - out.output += "\n\n⚠️ [WORKFLOW SYNC] .github/workflows/ files differ from main:\n" + - wfFiles.split("\n").map((f: string) => " - " + f).join("\n") + - "\nOIDC validation requires workflow files to match the default branch. Create a chore PR to sync." - await seen("workflow_sync.diverged", { branch, files: wfFiles.split("\n") }) - } - } - } catch { /* git may fail — non-blocking */ } - } - - // Memory update reminder after git commit - if (item.tool === "bash" && /\bgit\s+commit\b/i.test(str(item.args?.command))) { - const editCount = num(data.edit_count) - if (editCount >= 5) { - out.output = (out.output || "") + "\n🧠 Significant changes committed (" + editCount + " edits). Consider updating memory with key decisions or learnings." - } - } - - // [W9] post-pr-create-review-trigger: suggest review after PR creation - if (item.tool === "bash" && /\bgh\s+pr\s+create\b/i.test(str(item.args?.command))) { - const prUrl = (out.output || "").match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/)?.[1] - if (prUrl) { - await mark({ review_pending: true, review_pending_pr: prUrl }) - out.output += "\n\n📋 [AUTO-REVIEW REQUIRED] PR #" + prUrl + " created. Run code-reviewer agent and address all CRITICAL/HIGH findings before merging." - await seen("post_pr_create.review_trigger", { pr: prUrl }) - } - } - - // Track fix PR creation for follow-up limit - if (item.tool === "bash" && /\bgh\s+pr\s+create\b/i.test(str(item.args?.command))) { - const cmd = str(item.args?.command) - if (/--title\s+["']?fix/i.test(cmd) || /\bfix\//i.test(cmd)) { - const consecutiveFixes = num(data.consecutive_fix_prs) + 1 - await mark({ consecutive_fix_prs: consecutiveFixes }) - if (consecutiveFixes >= 2) { - out.output = (out.output || "") + "\n⚠️ Feature freeze warning: " + consecutiveFixes + " consecutive fix PRs on the same feature. Consider stabilizing before adding more changes." - } - } else { - await mark({ consecutive_fix_prs: 0 }) - } - } - - // Domain naming advisory: surface warning set during tool.execute.before - if ((item.tool === "write") && file) { - const freshData = await stash(state) - const warningFile = str(freshData.domain_naming_warning) - if (warningFile && warningFile === rel(input.worktree, file)) { - out.output = (out.output || "") + "\n📛 Domain naming mismatch: " + warningFile + " does not match expected pattern /" + str(freshData.domain_naming_expected) + "/ for " + str(freshData.domain_naming_dir) - await mark({ domain_naming_warning: "" }) - } - } - // Endpoint dataflow advisory: detect API endpoint modifications - if (MUTATING_TOOLS.has(item.tool) && file && code(file)) { - const relFile = rel(input.worktree, file) - const content = typeof item.args?.content === "string" ? item.args.content : - typeof item.args?.newString === "string" ? item.args.newString : "" - if (content && /\b(router\.(get|post|put|patch|delete)|app\.(get|post|put|patch|delete)|fetch\(|axios\.|\.handler)\b/i.test(content)) { - out.output = (out.output || "") + "\n🔄 Endpoint modification detected in " + relFile + ". Verify 4-point dataflow: client → API route → backend action → response format." - await seen("endpoint_dataflow.modified", { file: relFile }) - } - } - - // Doc update scope: remind about related documentation when modifying source - if (MUTATING_TOOLS.has(item.tool) && file && code(file)) { - const relFile = rel(input.worktree, file) - const editsSinceDocCheck = num(data.edits_since_doc_reminder) - if (editsSinceDocCheck >= 5) { - out.output = (out.output || "") + "\n📄 " + (editsSinceDocCheck + 1) + " source edits since last doc check. Grep for references to modified files in docs/ and README." - await mark({ edits_since_doc_reminder: 0 }) - } else { - await mark({ edits_since_doc_reminder: editsSinceDocCheck + 1 }) + await ctx.seen("verify_agent.short_output", { agent, payload_length: payload.length }) } } - // Task completion gate: ensure task claims are backed by evidence - if (item.tool === "bash" && /\b(gh\s+issue\s+close)\b/i.test(str(item.args?.command))) { - const reviewed = flag(data.reviewed) - const factchecked = flag(data.factchecked) - if (!reviewed || !factchecked) { - out.output = (out.output || "") + "\n⚠️ Issue close without full verification: reviewed=" + reviewed + ", factchecked=" + factchecked + ". Ensure acceptance criteria have code-level evidence." - await seen("task_completion.incomplete", { reviewed, factchecked }) - } - // Only mark verified when both conditions are met — prevents suppressing future reminders - if (reviewed && factchecked) { - await mark({ issue_verification_done: true }) - } - } + await review.handleCodexDetection(item, out) + await gitHandlers.bashAfterGit(item, out, data) - // [NEW] Track test execution for pr-guard and stop-test-gate if (item.tool === "bash") { const cmd = str(item.args?.command) if (/\b(bun\s+test|bun\s+turbo\s+test|jest|vitest|pytest|go\s+test|cargo\s+test)\b/i.test(cmd)) { const exitCode = typeof out.metadata?.exitCode === "number" ? out.metadata.exitCode : undefined - await mark({ tests_executed: true, last_test_at: now, last_test_exit: exitCode ?? "unknown" }) + await ctx.mark({ tests_executed: true, last_test_at: now, last_test_exit: exitCode ?? "unknown" }) } if (/\b(bun\s+turbo\s+typecheck|tsc|tsgo)\b/i.test(cmd)) { - await mark({ type_checked: true, last_typecheck_at: now }) + await ctx.mark({ type_checked: true, last_typecheck_at: now }) } - // Track push for review-reading staleness detection if (/\bgit\s+push\b/i.test(cmd)) { - await mark({ last_push_at: now }) + await ctx.mark({ last_push_at: now }) } - } - // Workflow phase transitions (auto-pipeline orchestration) - // Only transition when the pipeline is active — prevents state pollution from manual operations - if (item.tool === "bash") { - const cmd = str(item.args?.command) const output = str(out.output) const currentPhase = str(data.workflow_phase) const pipelineActive = currentPhase && currentPhase !== "idle" && currentPhase !== "done" && currentPhase !== "blocked" - - // PR creation detection — only when implementing if (pipelineActive && currentPhase === "implementing" && /\bgh\s+pr\s+create\b/.test(cmd) && output.includes("github.com")) { const prUrl = output.trim().match(/https:\/\/github\.com\/[^\s]+/) if (prUrl) { - await mark({ workflow_phase: "testing", workflow_pr_url: prUrl[0] }) - await seen("workflow.pr_created", { url: prUrl[0] }) + await ctx.mark({ workflow_phase: "testing", workflow_pr_url: prUrl[0] }) + await ctx.seen("workflow.pr_created", { url: prUrl[0] }) out.output += "\n\n[WORKFLOW] PR created. Next: run tests, then /review, then /ship." } } - - // Test execution detection — only when testing if (pipelineActive && currentPhase === "testing" && /\b(bun\s+test|pytest|go\s+test|npm\s+test|vitest|jest)\b/.test(cmd)) { const testExit = out.metadata?.exitCode ?? (output.includes("FAIL") ? 1 : 0) if (testExit === 0 && !output.includes("FAIL")) { - await mark({ workflow_phase: "reviewing", tests_executed: true }) - await seen("workflow.tests_passed", {}) + await ctx.mark({ workflow_phase: "reviewing", tests_executed: true }) + await ctx.seen("workflow.tests_passed", {}) out.output += "\n\n[WORKFLOW] Tests passed. Next: run /review." } } - - // Merge detection — only when shipping if (pipelineActive && currentPhase === "shipping" && /\bgh\s+pr\s+merge\b/.test(cmd) && !output.includes("error") && !output.includes("failed")) { - await mark({ workflow_phase: "done" }) - await seen("workflow.merged", {}) + await ctx.mark({ workflow_phase: "done" }) + await ctx.seen("workflow.merged", {}) out.output += "\n\n[WORKFLOW] Merge complete. Create follow-up issues for any discovered problems." } - - // CI check detection — any active phase if (pipelineActive && /\bgh\s+pr\s+checks\b/.test(cmd)) { const allPass = !output.toLowerCase().includes("fail") && !output.toLowerCase().includes("pending") - await mark({ ci_green: allPass }) + await ctx.mark({ ci_green: allPass }) } - - // Issue creation tracking if (/\bgh\s+issue\s+create\b/.test(cmd) && output.includes("github.com")) { const issueUrl = output.trim().match(/https:\/\/github\.com\/[^\s]+/) if (issueUrl) { const created = list(data.workflow_issues_created) created.push(issueUrl[0]) - await mark({ workflow_issues_created: created }) - await seen("workflow.issue_created", { url: issueUrl[0] }) + await ctx.mark({ workflow_issues_created: created }) + await ctx.seen("workflow.issue_created", { url: issueUrl[0] }) } } } - // [NEW] audit-docker-build-args: surface warning - if (item.tool === "bash" && /\bdocker\s+build\b/i.test(str(item.args?.command))) { - const freshData = await stash(state) - if (flag(freshData.docker_secret_warning)) { - out.output = (out.output || "") + "\n🔐 Security: --build-arg '" + str(freshData.docker_secret_arg) + "' may contain secrets. Use Docker build secrets (--secret) or multi-stage builds instead." - await mark({ docker_secret_warning: false }) - } - } - - // [NEW] enforce-review-reading: surface stale review warning - if (item.tool === "bash" && /\bgh\s+pr\s+merge\b/i.test(str(item.args?.command))) { - const freshData = await stash(state) - if (flag(freshData.review_reading_warning)) { - out.output = (out.output || "") + "\n📖 Stale review: code was pushed after the last review. Re-request review before merging." - await mark({ review_reading_warning: false }) - } - } - - // [NEW] pr-guard: surface preflight warning - if (item.tool === "bash" && /\bgh\s+pr\s+create\b/i.test(str(item.args?.command))) { - const freshData = await stash(state) - if (flag(freshData.pr_guard_warning)) { - const tests = flag(freshData.pr_guard_tests) - const types = flag(freshData.pr_guard_types) - const missing = [!tests && "tests", !types && "typecheck"].filter(Boolean).join(", ") - out.output = (out.output || "") + "\n🛡️ PR guard: " + missing + " not yet run this session. Run `bun turbo test:ci && bun turbo typecheck` before creating PR." - await mark({ pr_guard_warning: false }) - } - } - - // [W9] enforce-deploy-verify-on-pr: surface deploy verification warning - if (item.tool === "bash" && /\bgh\s+pr\s+create\b/i.test(str(item.args?.command))) { - const freshData = await stash(state) - if (flag(freshData.deploy_verify_warning)) { - out.output = (out.output || "") + "\n🚀 Deploy verification: changed hooks/scripts detected but not yet deployed. Run setup.sh and verify firing before merging." - await mark({ deploy_verify_warning: false }) - } - } - - // [NEW] stop-test-gate: surface untested push/merge warning - if (item.tool === "bash" && /\b(git\s+push|gh\s+pr\s+merge)\b/i.test(str(item.args?.command))) { - const freshData = await stash(state) - if (flag(freshData.stop_test_warning)) { - out.output = (out.output || "") + "\n🚫 Test gate: " + num(freshData.edit_count) + " edits without running tests. Run tests before pushing/merging." - await mark({ stop_test_warning: false }) - } - } - - // [W9] verify-state-file-integrity: update SHA-256 at end of hook cycle try { - const finalData = await stash(state) + const finalData = await stash(ctx.state) if (finalData && typeof finalData === "object" && !Array.isArray(finalData)) { const { state_sha256: _s, updated_at: _u, ...rest } = finalData const hasher = new Bun.CryptoHasher("sha256") hasher.update(JSON.stringify(rest)) - await save(state, { ...finalData, state_sha256: hasher.digest("hex"), updated_at: now }) + await save(ctx.state, { ...finalData, state_sha256: hasher.digest("hex"), updated_at: now }) } else { - await seen("state_integrity.corrupted", { reason: Array.isArray(finalData) ? "array" : "non-object" }) - await save(state, { mode, repaired_at: now, repair_reason: "corrupted state repaired" }) + await ctx.seen("state_integrity.corrupted", { reason: Array.isArray(finalData) ? "array" : "non-object" }) + await save(ctx.state, { mode: ctx.mode, repaired_at: now, repair_reason: "corrupted state repaired" }) } } catch { - await seen("state_integrity.parse_error", { file: state }) - await save(state, { mode, repaired_at: now, repair_reason: "JSON parse failure" }) + await ctx.seen("state_integrity.parse_error", { file: ctx.state }) + await save(ctx.state, { mode: ctx.mode, repaired_at: now, repair_reason: "JSON parse failure" }) } }, "command.execute.before": async ( @@ -1763,11 +403,10 @@ export default async function guardrail(input: { }[] }, ) => { - // Workflow initialization for /implement and /auto commands if (item.command === "implement" || item.command === "auto") { - await mark({ workflow_phase: "implementing", workflow_review_attempts: 0 }) - await seen("workflow.started", { command: item.command }) - const wfPart = out.parts.find((p) => p.type === "subtask" && typeof p.prompt === "string") + await ctx.mark({ workflow_phase: "implementing", workflow_review_attempts: 0 }) + await ctx.seen("workflow.started", { command: item.command }) + const wfPart = out.parts.find((part) => part.type === "subtask" && typeof part.prompt === "string") if (wfPart) { wfPart.prompt = (wfPart.prompt || "") + "\n\nAfter implementation:\n" + "1. Run relevant tests (bun test / pytest / go test)\n" + @@ -1779,15 +418,15 @@ export default async function guardrail(input: { } } if (!["review", "ship", "handoff", "implement", "auto"].includes(item.command)) return - const data = await stash(state) + const data = await stash(ctx.state) const part = out.parts.find((item) => item.type === "subtask" && typeof item.prompt === "string") if (!part?.prompt) return - part.prompt = `${part.prompt}\n\n${compact(data)}` + part.prompt = `${part.prompt}\n\n${ctx.compact(data)}` }, "shell.env": async (_item: { cwd: string }, out: { env: Record }) => { - out.env.OPENCODE_GUARDRAIL_MODE = mode - out.env.OPENCODE_GUARDRAIL_ROOT = root - out.env.OPENCODE_GUARDRAIL_STATE = state + out.env.OPENCODE_GUARDRAIL_MODE = ctx.mode + out.env.OPENCODE_GUARDRAIL_ROOT = ctx.root + out.env.OPENCODE_GUARDRAIL_STATE = ctx.state }, "chat.params": async ( item: { @@ -1812,33 +451,30 @@ export default async function guardrail(input: { options: Record }, ) => { - // Provider admission gate (existing) - const err = gate(item) + const err = ctx.gate(item) if (err) { - await mark({ + await ctx.mark({ last_block: "chat.params", last_provider: item.model.providerID, last_model: item.model.id, last_agent: item.agent, last_reason: err, }) - throw new Error(text(err)) + throw new Error(`Guardrail policy blocked this action: ${err}`) } - // Multi-model delegation: provider-aware routing + tier advisory (OpenCode advantage) const provider = str(item.model.providerID) const modelId = str(item.model.id) - const tier = agentModelTier[item.agent] + const tier = ctx.agentModelTier[item.agent] if (tier && modelId) { - const currentTier = Object.entries(tierModels).find(([, models]) => models.includes(modelId))?.[0] - // Detect tier mismatch: high-tier agent on low-tier model (or vice versa for cost waste) + const currentTier = Object.entries(ctx.tierModels).find(([, models]) => models.includes(modelId))?.[0] if (currentTier) { const tierOrder = { high: 3, standard: 2, low: 1 } const expected = tierOrder[tier] ?? 2 const actual = tierOrder[currentTier as keyof typeof tierOrder] ?? 2 if (expected > actual) { - const recommended = tierModels[tier] ?? [] - await seen("delegation.model_mismatch", { + const recommended = ctx.tierModels[tier] ?? [] + await ctx.seen("delegation.model_mismatch", { agent: item.agent, expected_tier: tier, actual_tier: currentTier, @@ -1846,15 +482,12 @@ export default async function guardrail(input: { provider, recommended: recommended.slice(0, 3), }) - // User-visible advisory via stderr injection is not available in chat.params, - // so we log to state for compacting context to pick up - await mark({ + await ctx.mark({ last_model_mismatch: `${item.agent} (${tier}-tier) running on ${modelId} (${currentTier}-tier). Recommended: ${recommended.slice(0, 3).join(", ")}`, }) } - // Cost waste detection: low-tier agent using high-tier model if (expected < actual && tier === "low" && currentTier === "high") { - await seen("delegation.cost_waste", { + await ctx.seen("delegation.cost_waste", { agent: item.agent, tier, model: modelId, @@ -1862,7 +495,6 @@ export default async function guardrail(input: { }) } } - // Provider-tier recommendation: suggest optimal providers per tier const providerTiers: Record = { high: ["zai-coding-plan", "openai"], standard: ["zai-coding-plan", "openai", "openrouter"], @@ -1870,7 +502,7 @@ export default async function guardrail(input: { } const recommendedProviders = providerTiers[tier] ?? [] if (recommendedProviders.length > 0 && !recommendedProviders.includes(provider)) { - await seen("delegation.provider_suboptimal", { + await ctx.seen("delegation.provider_suboptimal", { agent: item.agent, tier, current_provider: provider, @@ -1879,14 +511,13 @@ export default async function guardrail(input: { } } - // Per-provider cost tracking: count LLM invocations by provider (OpenCode multi-model tracking) - const data = await stash(state) + const data = await stash(ctx.state) const llmCalls = num(data.llm_call_count) + 1 const providerCalls = json(data.llm_calls_by_provider) providerCalls[provider] = (providerCalls[provider] ?? 0) + 1 const sessionProviders = list(data.session_providers) const updatedProviders = sessionProviders.includes(provider) ? sessionProviders : [...sessionProviders, provider] - await mark({ + await ctx.mark({ llm_call_count: llmCalls, llm_calls_by_provider: providerCalls, session_providers: updatedProviders, @@ -1899,17 +530,17 @@ export default async function guardrail(input: { _item: { sessionID: string }, out: { context: string[]; prompt?: string }, ) => { - const data = await stash(state) + const data = await stash(ctx.state) out.context.push( [ - `Guardrail mode: ${mode}.`, - `Preserve policy state from ${rel(input.worktree, state)} when handing work to the next agent.`, + `Guardrail mode: ${ctx.mode}.`, + `Preserve policy state from ${ctx.state.replace(ctx.input.worktree + "/", "")} when handing work to the next agent.`, `Last guardrail event: ${str(data.last_event) || "none"}.`, `Last guardrail block: ${str(data.last_block) || "none"}.`, `Unique source reads: ${num(data.read_count)}.`, `Edit/write count: ${num(data.edit_count)}.`, - `Fact-check state: ${factLine(data)}.`, - `Review state: ${reviewLine(data)}.`, + `Fact-check state: ${ctx.factLine(data)}.`, + `Review state: ${ctx.reviewLine(data)}.`, `Workflow phase: ${str(data.workflow_phase) || "idle"}.`, `Workflow PR: ${str(data.workflow_pr_url) || "none"}.`, `Review attempts: ${num(data.workflow_review_attempts)}.`, @@ -1926,18 +557,11 @@ export default async function guardrail(input: { _item: {}, out: { system: string[] }, ) => { - const data = await stash(state) + const data = await stash(ctx.state) const phase = str(data.workflow_phase) if (phase && phase !== "idle" && phase !== "done") { - out.system.push( - `[WORKFLOW] Phase: ${phase}. Pipeline: implement→test→review→fix→ship. Complete autonomously. Do not stop at PR creation.` - ) - } - // Follow-up issue guidance — only during active pipeline - if (phase && phase !== "idle" && phase !== "done") { - out.system.push( - "When you discover problems outside the current scope, create follow-up issues: `gh issue create --title '' --body '
' --label 'tech-debt'`. Do NOT fix out-of-scope problems inline." - ) + out.system.push("[WORKFLOW] Phase: " + phase + ". Pipeline: implement→test→review→fix→ship. Complete autonomously. Do not stop at PR creation.") + out.system.push("When you discover problems outside the current scope, create follow-up issues: `gh issue create --title '' --body '
' --label 'tech-debt'`. Do NOT fix out-of-scope problems inline.") } }, } From 9ad1d1396ffd27c5777011048a23f10446ae46b3 Mon Sep 17 00:00:00 2001 From: Terada Kousuke Date: Fri, 10 Apr 2026 01:18:46 +0900 Subject: [PATCH 2/2] fix(guardrails): remove spurious default exports from helper modules Codex CLI review P2: helper modules had `export default { id, server }` which matches the plugin shape. While opencode.json explicitly lists plugins (no auto-discovery), removing these prevents confusion and keeps helper modules as pure library code. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/guardrails/profile/plugins/guardrail-access.ts | 4 ---- packages/guardrails/profile/plugins/guardrail-context.ts | 4 ---- packages/guardrails/profile/plugins/guardrail-git.ts | 4 ---- packages/guardrails/profile/plugins/guardrail-patterns.ts | 4 ---- packages/guardrails/profile/plugins/guardrail-review.ts | 4 ---- 5 files changed, 20 deletions(-) diff --git a/packages/guardrails/profile/plugins/guardrail-access.ts b/packages/guardrails/profile/plugins/guardrail-access.ts index 4427b8545c2d..5787f33a996c 100644 --- a/packages/guardrails/profile/plugins/guardrail-access.ts +++ b/packages/guardrails/profile/plugins/guardrail-access.ts @@ -290,7 +290,3 @@ export function createAccessHandlers(ctx: GuardrailContext) { return { toolBeforeAccess, toolAfterAccess } } -export default { - id: "guardrail-access", - server: async () => ({}), -} diff --git a/packages/guardrails/profile/plugins/guardrail-context.ts b/packages/guardrails/profile/plugins/guardrail-context.ts index fffce0f2875c..f96f13af9b58 100644 --- a/packages/guardrails/profile/plugins/guardrail-context.ts +++ b/packages/guardrails/profile/plugins/guardrail-context.ts @@ -337,7 +337,3 @@ export async function createContext(input: GuardrailInput, opts?: Record ({}), -} diff --git a/packages/guardrails/profile/plugins/guardrail-git.ts b/packages/guardrails/profile/plugins/guardrail-git.ts index 30daa29adf4a..1d4ad14a8a2a 100644 --- a/packages/guardrails/profile/plugins/guardrail-git.ts +++ b/packages/guardrails/profile/plugins/guardrail-git.ts @@ -426,7 +426,3 @@ export function createGitHandlers(ctx: GuardrailContext, review: Review) { return { bashBeforeGit, bashAfterGit } } -export default { - id: "guardrail-git", - server: async () => ({}), -} diff --git a/packages/guardrails/profile/plugins/guardrail-patterns.ts b/packages/guardrails/profile/plugins/guardrail-patterns.ts index 8a1fea278747..905e420bff88 100644 --- a/packages/guardrails/profile/plugins/guardrail-patterns.ts +++ b/packages/guardrails/profile/plugins/guardrail-patterns.ts @@ -219,7 +219,3 @@ export function cmp(left: string, right: string) { return a[2] - b[2] } -export default { - id: "guardrail-patterns", - server: async () => ({}), -} diff --git a/packages/guardrails/profile/plugins/guardrail-review.ts b/packages/guardrails/profile/plugins/guardrail-review.ts index 877f190c7280..cf8da937ec61 100644 --- a/packages/guardrails/profile/plugins/guardrail-review.ts +++ b/packages/guardrails/profile/plugins/guardrail-review.ts @@ -192,7 +192,3 @@ export function createReviewPipeline(ctx: GuardrailContext) { } } -export default { - id: "guardrail-review", - server: async () => ({}), -}