Skip to content

Commit a34669e

Browse files
author
NagyVikt
committed
Prevent accidental in-place agent starts on protected branches
The branch start helper now requires explicit --allow-in-place alongside --in-place, keeping the default path isolated in agent worktrees.\n\nAlso added regression tests for default worktree behavior and the in-place guard/override flow, and documented the explicit override in README. Constraint: Preserve existing default branch/worktree flow for current users Rejected: Remove --in-place entirely | some advanced local workflows still need an explicit escape hatch Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep main/dev workflows worktree-first; do not relax in-place guard without adding equivalent safety Tested: npm test; node --check bin/multiagent-safety.js; npm pack --dry-run Not-tested: End-to-end GitHub Actions run for this commit
1 parent 8b511fa commit a34669e

3 files changed

Lines changed: 108 additions & 5 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ By default this writes:
9494

9595
![musafety branch start protocol screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/workflow-branch-start.svg)
9696

97+
`agent-branch-start` defaults to isolated worktrees. In-place starts are blocked unless you pass both
98+
`--in-place --allow-in-place` explicitly.
99+
97100
### 2) Lock claim + deletion guard protocol
98101

99102
![musafety lock and delete guard screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/workflow-lock-guard.svg)

templates/scripts/agent-branch-start.sh

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4-
TASK_NAME="${1:-task}"
5-
AGENT_NAME="${2:-agent}"
6-
BASE_BRANCH="${3:-dev}"
4+
TASK_NAME="task"
5+
AGENT_NAME="agent"
6+
BASE_BRANCH="dev"
77
WORKTREE_MODE=1
8+
ALLOW_IN_PLACE=0
89
WORKTREE_ROOT_REL=".omx/agent-worktrees"
10+
POSITIONAL_ARGS=()
911

1012
while [[ $# -gt 0 ]]; do
1113
case "$1" in
@@ -25,25 +27,52 @@ while [[ $# -gt 0 ]]; do
2527
WORKTREE_MODE=0
2628
shift
2729
;;
30+
--allow-in-place)
31+
ALLOW_IN_PLACE=1
32+
shift
33+
;;
2834
--worktree-root)
2935
WORKTREE_ROOT_REL="${2:-.omx/agent-worktrees}"
3036
shift 2
3137
;;
3238
--)
3339
shift
40+
while [[ $# -gt 0 ]]; do
41+
POSITIONAL_ARGS+=("$1")
42+
shift
43+
done
3444
break
3545
;;
3646
-*)
3747
echo "[agent-branch-start] Unknown option: $1" >&2
38-
echo "Usage: $0 [task] [agent] [base] [--in-place] [--worktree-root <path>]" >&2
48+
echo "Usage: $0 [task] [agent] [base] [--in-place --allow-in-place] [--worktree-root <path>]" >&2
3949
exit 1
4050
;;
4151
*)
42-
break
52+
POSITIONAL_ARGS+=("$1")
53+
shift
4354
;;
4455
esac
4556
done
4657

58+
if [[ "${#POSITIONAL_ARGS[@]}" -gt 3 ]]; then
59+
echo "[agent-branch-start] Too many positional arguments." >&2
60+
echo "Usage: $0 [task] [agent] [base] [--in-place --allow-in-place] [--worktree-root <path>]" >&2
61+
exit 1
62+
fi
63+
64+
if [[ "${#POSITIONAL_ARGS[@]}" -ge 1 ]]; then
65+
TASK_NAME="${POSITIONAL_ARGS[0]}"
66+
fi
67+
68+
if [[ "${#POSITIONAL_ARGS[@]}" -ge 2 ]]; then
69+
AGENT_NAME="${POSITIONAL_ARGS[1]}"
70+
fi
71+
72+
if [[ "${#POSITIONAL_ARGS[@]}" -ge 3 ]]; then
73+
BASE_BRANCH="${POSITIONAL_ARGS[2]}"
74+
fi
75+
4776
sanitize_slug() {
4877
local raw="$1"
4978
local slug
@@ -83,6 +112,12 @@ if git show-ref --verify --quiet "refs/heads/${branch_name}"; then
83112
fi
84113

85114
if [[ "$WORKTREE_MODE" -eq 0 ]]; then
115+
if [[ "$ALLOW_IN_PLACE" -ne 1 ]]; then
116+
echo "[agent-branch-start] --in-place is blocked by default to prevent accidental edits on protected branches." >&2
117+
echo "[agent-branch-start] If you really need it, pass both: --in-place --allow-in-place" >&2
118+
exit 1
119+
fi
120+
86121
if ! git diff --quiet || ! git diff --cached --quiet; then
87122
echo "[agent-branch-start] Working tree is not clean. Commit/stash changes before starting an in-place branch." >&2
88123
exit 1

test/install.test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,71 @@ test('setup --no-gitignore skips creating managed gitignore block', () => {
310310
assert.equal(fs.existsSync(path.join(repoDir, '.gitignore')), false);
311311
});
312312

313+
test('agent-branch-start keeps main worktree branch unchanged by default', () => {
314+
const repoDir = initRepo();
315+
seedCommit(repoDir);
316+
317+
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
318+
assert.equal(result.status, 0, result.stderr || result.stdout);
319+
320+
result = runCmd('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoDir);
321+
assert.equal(result.status, 0, result.stderr);
322+
const beforeBranch = result.stdout.trim();
323+
assert.equal(beforeBranch, 'dev');
324+
325+
result = runCmd(
326+
'bash',
327+
['scripts/agent-branch-start.sh', 'verify-default-worktree', 'doctor'],
328+
repoDir,
329+
);
330+
assert.equal(result.status, 0, result.stderr || result.stdout);
331+
assert.match(result.stdout, /Created branch: agent\/doctor\//);
332+
assert.match(result.stdout, /Worktree: /);
333+
334+
const branchMatch = result.stdout.match(/Created branch: ([^\n]+)/);
335+
assert.notEqual(branchMatch, null);
336+
const createdBranch = branchMatch[1].trim();
337+
338+
result = runCmd('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoDir);
339+
assert.equal(result.status, 0, result.stderr);
340+
assert.equal(result.stdout.trim(), beforeBranch, 'current branch should stay unchanged in main worktree');
341+
342+
result = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${createdBranch}`], repoDir);
343+
assert.equal(result.status, 0, 'created agent branch should exist');
344+
});
345+
346+
test('agent-branch-start blocks in-place mode unless explicitly allowed', () => {
347+
const repoDir = initRepo();
348+
seedCommit(repoDir);
349+
350+
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
351+
assert.equal(result.status, 0, result.stderr || result.stdout);
352+
353+
result = runCmd('git', ['add', '.'], repoDir);
354+
assert.equal(result.status, 0, result.stderr);
355+
result = runCmd('git', ['commit', '-m', 'post-setup'], repoDir, {
356+
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1',
357+
});
358+
assert.equal(result.status, 0, result.stderr || result.stdout);
359+
360+
result = runCmd(
361+
'bash',
362+
['scripts/agent-branch-start.sh', 'verify-in-place-guard', 'doctor', '--in-place'],
363+
repoDir,
364+
);
365+
assert.equal(result.status, 1, 'in-place should be blocked by default');
366+
assert.match(result.stderr, /--in-place is blocked by default/);
367+
assert.match(result.stderr, /--in-place --allow-in-place/);
368+
369+
result = runCmd(
370+
'bash',
371+
['scripts/agent-branch-start.sh', 'verify-in-place-override', 'doctor', '--in-place', '--allow-in-place'],
372+
repoDir,
373+
);
374+
assert.equal(result.status, 0, result.stderr || result.stdout);
375+
assert.match(result.stdout, /Created in-place branch: agent\/doctor\//);
376+
});
377+
313378
test('protect command manages configured protected branches', () => {
314379
const repoDir = initRepo();
315380
seedCommit(repoDir);

0 commit comments

Comments
 (0)