fix: honor workflow guard opt-in for Bash blocks

This commit is contained in:
Colin
2026-05-25 10:07:40 -04:00
parent 69cc2f9fe2
commit 643efb1d76
3 changed files with 96 additions and 26 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 265
---
Workflow guard now respects its opt-in config before blocking forced git-add commands on worktree-agent branches.

View File

@@ -65,6 +65,17 @@ function currentBranch(cwd) {
return result.stdout.trim();
}
function workflowGuardEnabled(cwd) {
const configPath = path.join(cwd, '.planning', 'config.json');
if (!fs.existsSync(configPath)) return false;
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return Boolean(config.hooks?.workflow_guard);
} catch (e) {
return false;
}
}
let input = '';
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
process.stdin.setEncoding('utf8');
@@ -75,8 +86,12 @@ process.stdin.on('end', () => {
const data = JSON.parse(input);
const toolName = data.tool_name;
const cwd = data.cwd || process.cwd();
const isWorkflowGuardEnabled = workflowGuardEnabled(cwd);
if (toolName === 'Bash') {
if (!isWorkflowGuardEnabled) {
process.exit(0);
}
const command = data.tool_input?.command || '';
for (const gitCwd of forceGitAddCwds(command, cwd)) {
const branch = currentBranch(gitCwd);
@@ -92,8 +107,8 @@ process.stdin.on('end', () => {
process.exit(0);
}
// Only guard Write and Edit tool calls
if (toolName !== 'Write' && toolName !== 'Edit') {
// Only guard Write, Edit, and MultiEdit tool calls
if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) {
process.exit(0);
}
@@ -125,19 +140,8 @@ process.stdin.on('end', () => {
process.exit(0);
}
// Check if workflow guard is enabled
const configPath = path.join(cwd, '.planning', 'config.json');
if (fs.existsSync(configPath)) {
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (!config.hooks?.workflow_guard) {
process.exit(0); // Guard disabled (default)
}
} catch (e) {
process.exit(0);
}
} else {
process.exit(0); // No GSD project — don't guard
if (!isWorkflowGuardEnabled) {
process.exit(0); // Guard disabled (default) or no GSD project
}
// If we get here: GSD project, guard enabled, file edit outside .planning/,

View File

@@ -26,23 +26,36 @@ function makeRepo(branch) {
return dir;
}
function runHook(cwd, command) {
function setWorkflowGuard(dir, enabled) {
const planningDir = path.join(dir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ hooks: { workflow_guard: enabled } }, null, 2)
);
}
function runHookInput(cwd, input) {
return spawnSync(process.execPath, [HOOK_PATH], {
cwd,
encoding: 'utf8',
input: JSON.stringify({
cwd,
tool_name: 'Bash',
tool_input: { command },
}),
input: JSON.stringify({ cwd, ...input }),
});
}
function runBashHook(cwd, command) {
return runHookInput(cwd, {
tool_name: 'Bash',
tool_input: { command },
});
}
describe('bug #261: workflow guard blocks forced git add on worktree-agent branches', () => {
test('blocks git add -f on worktree-agent branch before it can stage gitignored files', () => {
test('blocks git add -f on worktree-agent branch when workflow guard is enabled', () => {
const dir = makeRepo('worktree-agent-a1');
try {
const result = runHook(dir, 'git add -f .planning/phases/01/01-01-SUMMARY.md');
setWorkflowGuard(dir, true);
const result = runBashHook(dir, 'git add -f .planning/phases/01/01-01-SUMMARY.md');
assert.strictEqual(result.status, 2);
const envelope = JSON.parse(result.stdout);
assert.strictEqual(envelope.decision, 'block');
@@ -55,7 +68,8 @@ describe('bug #261: workflow guard blocks forced git add on worktree-agent branc
test('blocks git add --force with git global options on worktree-agent branch', () => {
const dir = makeRepo('worktree-agent-b2');
try {
const result = runHook(path.dirname(dir), `git -C "${dir}" add --force .planning/SUMMARY.md`);
setWorkflowGuard(dir, true);
const result = runBashHook(dir, `git -C "${dir}" add --force .planning/SUMMARY.md`);
assert.strictEqual(result.status, 2);
assert.strictEqual(JSON.parse(result.stdout).code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN');
} finally {
@@ -66,7 +80,8 @@ describe('bug #261: workflow guard blocks forced git add on worktree-agent branc
test('allows ordinary git add on worktree-agent branch', () => {
const dir = makeRepo('worktree-agent-c3');
try {
const result = runHook(dir, 'git add .planning/SUMMARY.md');
setWorkflowGuard(dir, true);
const result = runBashHook(dir, 'git add .planning/SUMMARY.md');
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
} finally {
@@ -77,11 +92,57 @@ describe('bug #261: workflow guard blocks forced git add on worktree-agent branc
test('allows git add -f outside worktree-agent branches', () => {
const dir = makeRepo('feature-docs');
try {
const result = runHook(dir, 'git add -f .planning/SUMMARY.md');
setWorkflowGuard(dir, true);
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('allows git add -f on worktree-agent branch when workflow guard is disabled', () => {
const dir = makeRepo('worktree-agent-d4');
try {
setWorkflowGuard(dir, false);
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('allows git add -f on worktree-agent branch when no GSD config exists', () => {
const dir = makeRepo('worktree-agent-e5');
try {
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('applies the advisory path to MultiEdit when workflow guard is enabled', () => {
const dir = makeRepo('feature-multiedit');
try {
setWorkflowGuard(dir, true);
const result = runHookInput(dir, {
tool_name: 'MultiEdit',
tool_input: {
file_path: path.join(dir, 'src.js'),
edits: [],
},
});
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout);
assert.match(
envelope.hookSpecificOutput.additionalContext,
/WORKFLOW ADVISORY/
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});