diff --git a/plugin/gateway-core/dist/hooks/primary-worktree-guard/index.js b/plugin/gateway-core/dist/hooks/primary-worktree-guard/index.js index 36d224d..e576c05 100644 --- a/plugin/gateway-core/dist/hooks/primary-worktree-guard/index.js +++ b/plugin/gateway-core/dist/hooks/primary-worktree-guard/index.js @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { writeGatewayEventAudit } from "../../audit/event-audit.js"; @@ -26,9 +27,23 @@ function stripQuotes(token) { function shellQuote(value) { return JSON.stringify(value); } -const MAINTENANCE_HELPER = fileURLToPath(new URL("../../../../../scripts/worktree_helper_command.py", import.meta.url)); +const DEFAULT_MAINTENANCE_HELPER = fileURLToPath(new URL("../../../../../scripts/worktree_helper_command.py", import.meta.url)); +function maintenanceHelperPath() { + const override = process.env.OPENCODE_MAINTENANCE_HELPER_PATH?.trim(); + return override || DEFAULT_MAINTENANCE_HELPER; +} function maintenanceHelperCommand(directory, originalCommand) { - return `python3 ${shellQuote(MAINTENANCE_HELPER)} maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json`; + return `python3 ${shellQuote(maintenanceHelperPath())} maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json`; +} +function maintenanceHelperError(directory, originalCommand) { + const helperPath = maintenanceHelperPath(); + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand); + return new Error(`Protected primary-worktree command reroute failed because the maintenance helper does not exist at '${helperPath}'. Original command: ${originalCommand}. Target repo: ${directory}. Intended reroute: ${rewrittenCommand}.`); +} +function rerouteGuidance(directory, originalCommand) { + const helperPath = maintenanceHelperPath(); + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand); + return `The command was blocked in the primary worktree and would be rerouted through '${helperPath}'. Original command: ${originalCommand}. Rerouted command: ${rewrittenCommand}.`; } const GIT_PREFIX = String.raw `(?:^|&&|\|\||;)\s*(?:env\s+(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*)?(?:(?:[^\s;&|]*/)?rtk\s+)?(?:[^\s;&|]*/)?git\s+`; function matchBranchTarget(command, pattern) { @@ -65,13 +80,34 @@ export function createPrimaryWorktreeGuardHook(options) { if (!args || !originalCommand) { return false; } - args.command = maintenanceHelperCommand(directory, originalCommand); + const helperPath = maintenanceHelperPath(); + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand); + if (!existsSync(helperPath)) { + writeGatewayEventAudit(directory, { + hook: "primary-worktree-guard", + stage: "skip", + reason_code: "maintenance_helper_missing", + session_id: sessionId, + blocked_command: originalCommand, + original_command: originalCommand, + helper_path: helperPath, + helper_exists: false, + repo_root: directory, + }); + throw maintenanceHelperError(directory, originalCommand); + } + args.command = rewrittenCommand; writeGatewayEventAudit(directory, { hook: "primary-worktree-guard", stage: "state", reason_code: reasonCode, session_id: sessionId, blocked_command: originalCommand, + original_command: originalCommand, + rewritten_command: rewrittenCommand, + helper_path: helperPath, + helper_exists: true, + repo_root: directory, }); return true; } @@ -122,7 +158,8 @@ export function createPrimaryWorktreeGuardHook(options) { if (rerouteToMaintenanceHelper(eventPayload, directory, sessionId, "bash_in_primary_worktree_rerouted")) { return; } - throw new Error("Bash commands in the primary project folder are limited to inspection, validation, and exact default-branch sync commands (`git fetch`, `git fetch --prune`, and `git pull --rebase`). Create or use a dedicated git worktree branch for task mutations."); + throw new Error(`Bash commands in the primary project folder are limited to inspection, validation, and exact default-branch sync commands ( +\`git fetch\`, \`git fetch --prune\`, and \`git pull --rebase\`). Create or use a dedicated git worktree branch for task mutations. ${rerouteGuidance(directory, command)}`.replace(/\n/g, "")); }, }; } diff --git a/plugin/gateway-core/dist/hooks/workflow-conformance-guard/index.js b/plugin/gateway-core/dist/hooks/workflow-conformance-guard/index.js index c562372..6e624f6 100644 --- a/plugin/gateway-core/dist/hooks/workflow-conformance-guard/index.js +++ b/plugin/gateway-core/dist/hooks/workflow-conformance-guard/index.js @@ -1,4 +1,5 @@ import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { writeGatewayEventAudit } from "../../audit/event-audit.js"; @@ -39,9 +40,23 @@ function protectedBranchWorktreeHint(directory) { function shellQuote(value) { return JSON.stringify(value); } -const MAINTENANCE_HELPER = fileURLToPath(new URL("../../../../../scripts/worktree_helper_command.py", import.meta.url)); +const DEFAULT_MAINTENANCE_HELPER = fileURLToPath(new URL("../../../../../scripts/worktree_helper_command.py", import.meta.url)); +function maintenanceHelperPath() { + const override = process.env.OPENCODE_MAINTENANCE_HELPER_PATH?.trim(); + return override || DEFAULT_MAINTENANCE_HELPER; +} function maintenanceHelperCommand(directory, originalCommand) { - return `python3 ${shellQuote(MAINTENANCE_HELPER)} maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json`; + return `python3 ${shellQuote(maintenanceHelperPath())} maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json`; +} +function maintenanceHelperError(directory, originalCommand) { + const helperPath = maintenanceHelperPath(); + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand); + return new Error(`Protected-branch command reroute failed because the maintenance helper does not exist at '${helperPath}'. Original command: ${originalCommand}. Target repo: ${directory}. Intended reroute: ${rewrittenCommand}.`); +} +function rerouteGuidance(directory, originalCommand) { + const helperPath = maintenanceHelperPath(); + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand); + return `The command was blocked on a protected branch and would be rerouted through '${helperPath}'. Original command: ${originalCommand}. Rerouted command: ${rewrittenCommand}.`; } function rerouteToMaintenanceHelper(payload, directory, sessionId, reasonCode) { const args = payload.output?.args; @@ -49,13 +64,34 @@ function rerouteToMaintenanceHelper(payload, directory, sessionId, reasonCode) { if (!args || !originalCommand) { return false; } - args.command = maintenanceHelperCommand(directory, originalCommand); + const helperPath = maintenanceHelperPath(); + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand); + if (!existsSync(helperPath)) { + writeGatewayEventAudit(directory, { + hook: "workflow-conformance-guard", + stage: "skip", + reason_code: "maintenance_helper_missing", + session_id: sessionId, + blocked_command: originalCommand, + original_command: originalCommand, + helper_path: helperPath, + helper_exists: false, + repo_root: directory, + }); + throw maintenanceHelperError(directory, originalCommand); + } + args.command = rewrittenCommand; writeGatewayEventAudit(directory, { hook: "workflow-conformance-guard", stage: "state", reason_code: reasonCode, session_id: sessionId, blocked_command: originalCommand, + original_command: originalCommand, + rewritten_command: rewrittenCommand, + helper_path: helperPath, + helper_exists: true, + repo_root: directory, }); return true; } @@ -107,7 +143,7 @@ export function createWorkflowConformanceGuardHook(options) { if (rerouteToMaintenanceHelper(eventPayload, directory, sessionId, "bash_on_protected_branch_rerouted")) { return; } - throw new Error(`Bash commands on protected branch '${branch}' are limited to inspection, validation, and exact sync commands (\`git fetch\`, \`git fetch --prune\`, and \`git pull --rebase\`). Use a worktree feature branch for task mutations. ${protectedBranchWorktreeHint(directory)}`); + throw new Error(`Bash commands on protected branch '${branch}' are limited to inspection, validation, and exact sync commands (\`git fetch\`, \`git fetch --prune\`, and \`git pull --rebase\`). Use a worktree feature branch for task mutations. ${protectedBranchWorktreeHint(directory)} ${rerouteGuidance(directory, command)}`); }, }; } diff --git a/plugin/gateway-core/src/hooks/primary-worktree-guard/index.ts b/plugin/gateway-core/src/hooks/primary-worktree-guard/index.ts index 3e105e3..e62df20 100644 --- a/plugin/gateway-core/src/hooks/primary-worktree-guard/index.ts +++ b/plugin/gateway-core/src/hooks/primary-worktree-guard/index.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process" +import { existsSync } from "node:fs" import { resolve } from "node:path" import { fileURLToPath } from "node:url" @@ -47,10 +48,29 @@ function shellQuote(value: string): string { return JSON.stringify(value) } -const MAINTENANCE_HELPER = fileURLToPath(new URL("../../../../../scripts/worktree_helper_command.py", import.meta.url)) +const DEFAULT_MAINTENANCE_HELPER = fileURLToPath(new URL("../../../../../scripts/worktree_helper_command.py", import.meta.url)) + +function maintenanceHelperPath(): string { + const override = process.env.OPENCODE_MAINTENANCE_HELPER_PATH?.trim() + return override || DEFAULT_MAINTENANCE_HELPER +} function maintenanceHelperCommand(directory: string, originalCommand: string): string { - return `python3 ${shellQuote(MAINTENANCE_HELPER)} maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json` + return `python3 ${shellQuote(maintenanceHelperPath())} maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json` +} + +function maintenanceHelperError(directory: string, originalCommand: string): Error { + const helperPath = maintenanceHelperPath() + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand) + return new Error( + `Protected primary-worktree command reroute failed because the maintenance helper does not exist at '${helperPath}'. Original command: ${originalCommand}. Target repo: ${directory}. Intended reroute: ${rewrittenCommand}.` + ) +} + +function rerouteGuidance(directory: string, originalCommand: string): string { + const helperPath = maintenanceHelperPath() + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand) + return `The command was blocked in the primary worktree and would be rerouted through '${helperPath}'. Original command: ${originalCommand}. Rerouted command: ${rewrittenCommand}.` } const GIT_PREFIX = String.raw`(?:^|&&|\|\||;)\s*(?:env\s+(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*)?(?:(?:[^\s;&|]*/)?rtk\s+)?(?:[^\s;&|]*/)?git\s+` @@ -111,13 +131,34 @@ export function createPrimaryWorktreeGuardHook(options: { if (!args || !originalCommand) { return false } - args.command = maintenanceHelperCommand(directory, originalCommand) + const helperPath = maintenanceHelperPath() + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand) + if (!existsSync(helperPath)) { + writeGatewayEventAudit(directory, { + hook: "primary-worktree-guard", + stage: "skip", + reason_code: "maintenance_helper_missing", + session_id: sessionId, + blocked_command: originalCommand, + original_command: originalCommand, + helper_path: helperPath, + helper_exists: false, + repo_root: directory, + }) + throw maintenanceHelperError(directory, originalCommand) + } + args.command = rewrittenCommand writeGatewayEventAudit(directory, { hook: "primary-worktree-guard", stage: "state", reason_code: reasonCode, session_id: sessionId, blocked_command: originalCommand, + original_command: originalCommand, + rewritten_command: rewrittenCommand, + helper_path: helperPath, + helper_exists: true, + repo_root: directory, }) return true } @@ -173,7 +214,7 @@ export function createPrimaryWorktreeGuardHook(options: { return } throw new Error( - "Bash commands in the primary project folder are limited to inspection, validation, and exact default-branch sync commands (`git fetch`, `git fetch --prune`, and `git pull --rebase`). Create or use a dedicated git worktree branch for task mutations." + `Bash commands in the primary project folder are limited to inspection, validation, and exact default-branch sync commands (\`git fetch\`, \`git fetch --prune\`, and \`git pull --rebase\`). Create or use a dedicated git worktree branch for task mutations. ${rerouteGuidance(directory, command)}` ) }, } diff --git a/plugin/gateway-core/src/hooks/workflow-conformance-guard/index.ts b/plugin/gateway-core/src/hooks/workflow-conformance-guard/index.ts index 688d5bc..e196871 100644 --- a/plugin/gateway-core/src/hooks/workflow-conformance-guard/index.ts +++ b/plugin/gateway-core/src/hooks/workflow-conformance-guard/index.ts @@ -1,4 +1,5 @@ import { execSync } from "node:child_process" +import { existsSync } from "node:fs" import { basename, resolve } from "node:path" import { fileURLToPath } from "node:url" @@ -66,10 +67,29 @@ function shellQuote(value: string): string { return JSON.stringify(value) } -const MAINTENANCE_HELPER = fileURLToPath(new URL("../../../../../scripts/worktree_helper_command.py", import.meta.url)) +const DEFAULT_MAINTENANCE_HELPER = fileURLToPath(new URL("../../../../../scripts/worktree_helper_command.py", import.meta.url)) + +function maintenanceHelperPath(): string { + const override = process.env.OPENCODE_MAINTENANCE_HELPER_PATH?.trim() + return override || DEFAULT_MAINTENANCE_HELPER +} function maintenanceHelperCommand(directory: string, originalCommand: string): string { - return `python3 ${shellQuote(MAINTENANCE_HELPER)} maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json` + return `python3 ${shellQuote(maintenanceHelperPath())} maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json` +} + +function maintenanceHelperError(directory: string, originalCommand: string): Error { + const helperPath = maintenanceHelperPath() + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand) + return new Error( + `Protected-branch command reroute failed because the maintenance helper does not exist at '${helperPath}'. Original command: ${originalCommand}. Target repo: ${directory}. Intended reroute: ${rewrittenCommand}.` + ) +} + +function rerouteGuidance(directory: string, originalCommand: string): string { + const helperPath = maintenanceHelperPath() + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand) + return `The command was blocked on a protected branch and would be rerouted through '${helperPath}'. Original command: ${originalCommand}. Rerouted command: ${rewrittenCommand}.` } function rerouteToMaintenanceHelper(payload: ToolBeforePayload, directory: string, sessionId: string, reasonCode: string): boolean { @@ -78,13 +98,34 @@ function rerouteToMaintenanceHelper(payload: ToolBeforePayload, directory: strin if (!args || !originalCommand) { return false } - args.command = maintenanceHelperCommand(directory, originalCommand) + const helperPath = maintenanceHelperPath() + const rewrittenCommand = maintenanceHelperCommand(directory, originalCommand) + if (!existsSync(helperPath)) { + writeGatewayEventAudit(directory, { + hook: "workflow-conformance-guard", + stage: "skip", + reason_code: "maintenance_helper_missing", + session_id: sessionId, + blocked_command: originalCommand, + original_command: originalCommand, + helper_path: helperPath, + helper_exists: false, + repo_root: directory, + }) + throw maintenanceHelperError(directory, originalCommand) + } + args.command = rewrittenCommand writeGatewayEventAudit(directory, { hook: "workflow-conformance-guard", stage: "state", reason_code: reasonCode, session_id: sessionId, blocked_command: originalCommand, + original_command: originalCommand, + rewritten_command: rewrittenCommand, + helper_path: helperPath, + helper_exists: true, + repo_root: directory, }) return true } @@ -143,7 +184,7 @@ export function createWorkflowConformanceGuardHook(options: { return } throw new Error( - `Bash commands on protected branch '${branch}' are limited to inspection, validation, and exact sync commands (\`git fetch\`, \`git fetch --prune\`, and \`git pull --rebase\`). Use a worktree feature branch for task mutations. ${protectedBranchWorktreeHint(directory)}` + `Bash commands on protected branch '${branch}' are limited to inspection, validation, and exact sync commands (\`git fetch\`, \`git fetch --prune\`, and \`git pull --rebase\`). Use a worktree feature branch for task mutations. ${protectedBranchWorktreeHint(directory)} ${rerouteGuidance(directory, command)}` ) }, } diff --git a/plugin/gateway-core/test/primary-worktree-guard-hook.test.mjs b/plugin/gateway-core/test/primary-worktree-guard-hook.test.mjs index 3fbbe05..7189f5d 100644 --- a/plugin/gateway-core/test/primary-worktree-guard-hook.test.mjs +++ b/plugin/gateway-core/test/primary-worktree-guard-hook.test.mjs @@ -396,3 +396,42 @@ test("primary-worktree-guard reroutes mutating bash commands in the primary work rmSync(directory, { recursive: true, force: true }) } }) + +test("primary-worktree-guard explains reroute failures when helper path is missing", async () => { + const directory = mkdtempSync(join(tmpdir(), "gateway-primary-worktree-")) + const originalHelper = process.env.OPENCODE_MAINTENANCE_HELPER_PATH + process.env.OPENCODE_MAINTENANCE_HELPER_PATH = join(directory, "missing-helper.py") + try { + execSync("git init -b main", { cwd: directory, stdio: ["ignore", "pipe", "pipe"] }) + writeFileSync(join(directory, "file.txt"), "v1\n", "utf-8") + commitAll(directory, "init") + + const plugin = GatewayCorePlugin({ + directory, + config: { + hooks: { enabled: true, order: ["primary-worktree-guard"], disabled: [] }, + primaryWorktreeGuard: { + enabled: true, + allowedBranches: ["main", "master"], + blockEdits: true, + blockBranchSwitches: true, + }, + }, + }) + + await assert.rejects( + plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-primary-helper-missing" }, + { args: { command: "echo hi > file.txt" } } + ), + /Intended reroute:/, + ) + } finally { + if (originalHelper === undefined) { + delete process.env.OPENCODE_MAINTENANCE_HELPER_PATH + } else { + process.env.OPENCODE_MAINTENANCE_HELPER_PATH = originalHelper + } + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/plugin/gateway-core/test/workflow-conformance-guard-hook.test.mjs b/plugin/gateway-core/test/workflow-conformance-guard-hook.test.mjs index 50040fb..c3329ed 100644 --- a/plugin/gateway-core/test/workflow-conformance-guard-hook.test.mjs +++ b/plugin/gateway-core/test/workflow-conformance-guard-hook.test.mjs @@ -355,6 +355,41 @@ test("workflow-conformance-guard reroutes mutating bash commands on protected br } }) +test("workflow-conformance-guard explains reroute failures when helper path is missing", async () => { + const directory = mkdtempSync(join(tmpdir(), "gateway-workflow-guard-")) + const originalHelper = process.env.OPENCODE_MAINTENANCE_HELPER_PATH + process.env.OPENCODE_MAINTENANCE_HELPER_PATH = join(directory, "missing-helper.py") + try { + execSync("git init -b main", { cwd: directory, stdio: ["ignore", "pipe", "pipe"] }) + const plugin = GatewayCorePlugin({ + directory, + config: { + hooks: { enabled: true, order: ["workflow-conformance-guard"], disabled: [] }, + workflowConformanceGuard: { + enabled: true, + protectedBranches: ["main"], + blockEditsOnProtectedBranches: true, + }, + }, + }) + + await assert.rejects( + plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-workflow-helper-missing" }, + { args: { command: 'git commit -m "msg"' } }, + ), + /Intended reroute:/, + ) + } finally { + if (originalHelper === undefined) { + delete process.env.OPENCODE_MAINTENANCE_HELPER_PATH + } else { + process.env.OPENCODE_MAINTENANCE_HELPER_PATH = originalHelper + } + rmSync(directory, { recursive: true, force: true }) + } +}) + test("workflow-conformance-guard allows linked worktree edits even when the linked branch is main", async () => { const directory = mkdtempSync(join(tmpdir(), "gateway-workflow-guard-")) const linked = `${directory}-linked`