Skip to content

Commit b62fc89

Browse files
author
NagyVikt
committed
Auto-enforce Codex agent branches during setup and doctor
Pre-commit now blocks Codex/OMX session commits on non-agent branches by default, while allowing human branch workflows to continue.\n\nSetup and doctor now auto-refresh managed safety files ( and ) when templates drift, so rerunning musafety applies the latest branch-safety logic without requiring manual force flags. Added regression coverage for the Codex guard plus auto-refresh behavior. Constraint: Keep existing human VS Code commits on non-protected feature branches working Rejected: Block all non-agent branch commits for everyone | would break normal human trunk/feature workflows Confidence: high Scope-risk: moderate Reversibility: clean Directive: Treat Codex branch guard and template auto-refresh as safety-critical defaults; do not weaken without replacement controls Tested: npm test; node --check bin/multiagent-safety.js; npm pack --dry-run Not-tested: GitHub Actions run for this new commit
1 parent a34669e commit b62fc89

4 files changed

Lines changed: 141 additions & 6 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,17 @@ Configuration is stored in local git config key:
269269
multiagent.protectedBranches
270270
```
271271

272+
Codex/OMX agent branch guard is enabled by default in pre-commit and can be configured with:
273+
274+
```text
275+
multiagent.codexRequireAgentBranch
276+
```
277+
272278
## What is protected
273279

274280
- direct commits to protected branches (defaults: `dev`, `main`, `master`; configurable via `musafety protect ...`)
275281
- protected-branch commits are blocked regardless of commit client (including VS Code Source Control)
282+
- Codex/OMX session commits are blocked on non-`agent/*` branches by default (keeps human branch untouched while agents use isolated branches)
276283
- overlapping file ownership between agents
277284
- unapproved deletions of claimed files
278285
- risky stale/missing lock state

bin/multiagent-safety.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ const TEMPLATE_FILES = [
3636
'claude/commands/musafety.md',
3737
];
3838

39+
const AUTO_SYNC_TEMPLATE_FILES = new Set([
40+
'scripts/agent-branch-start.sh',
41+
'githooks/pre-commit',
42+
]);
43+
3944
const EXECUTABLE_RELATIVE_PATHS = new Set([
4045
'scripts/agent-branch-start.sh',
4146
'scripts/agent-branch-finish.sh',
@@ -352,7 +357,7 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
352357
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
353358
return { status: 'unchanged', file: destinationRelativePath };
354359
}
355-
if (!force) {
360+
if (!force && !AUTO_SYNC_TEMPLATE_FILES.has(relativeTemplatePath)) {
356361
throw new Error(
357362
`Refusing to overwrite existing file without --force: ${destinationRelativePath}`,
358363
);
@@ -381,8 +386,16 @@ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
381386
return { status: 'unchanged', file: destinationRelativePath };
382387
}
383388

384-
// In fix mode, avoid silently replacing local customizations.
385-
return { status: 'skipped-conflict', file: destinationRelativePath };
389+
if (!AUTO_SYNC_TEMPLATE_FILES.has(relativeTemplatePath)) {
390+
// In fix mode, avoid silently replacing local customizations.
391+
return { status: 'skipped-conflict', file: destinationRelativePath };
392+
}
393+
394+
if (!dryRun) {
395+
fs.writeFileSync(destinationPath, sourceContent, 'utf8');
396+
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
397+
}
398+
return { status: dryRun ? 'would-update' : 'updated', file: destinationRelativePath };
386399
}
387400

388401
ensureParentDir(destinationPath, dryRun);

templates/githooks/pre-commit

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,41 @@ MSG
4343
exit 1
4444
fi
4545

46+
codex_require_agent_branch_raw="${MUSAFETY_CODEX_REQUIRE_AGENT_BRANCH:-$(git config --get multiagent.codexRequireAgentBranch || true)}"
47+
if [[ -z "$codex_require_agent_branch_raw" ]]; then
48+
codex_require_agent_branch_raw="true"
49+
fi
50+
codex_require_agent_branch="$(printf '%s' "$codex_require_agent_branch_raw" | tr '[:upper:]' '[:lower:]')"
51+
52+
should_require_codex_agent_branch=0
53+
case "$codex_require_agent_branch" in
54+
1|true|yes|on) should_require_codex_agent_branch=1 ;;
55+
0|false|no|off) should_require_codex_agent_branch=0 ;;
56+
*) should_require_codex_agent_branch=1 ;;
57+
esac
58+
59+
if [[ "$should_require_codex_agent_branch" == "1" && "${MUSAFETY_ALLOW_CODEX_ON_NON_AGENT:-0}" != "1" ]]; then
60+
is_codex_session=0
61+
if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" == "1" ]]; then
62+
is_codex_session=1
63+
fi
64+
65+
if [[ "$is_codex_session" == "1" && "$branch" != agent/* ]]; then
66+
cat >&2 <<'MSG'
67+
[codex-branch-guard] Codex agent commit blocked on non-agent branch.
68+
Use isolated branch/worktree first:
69+
bash scripts/agent-branch-start.sh "<task-or-plan>" "<agent-name>"
70+
Then commit from the created agent/* branch.
71+
72+
Temporary bypass (not recommended):
73+
MUSAFETY_ALLOW_CODEX_ON_NON_AGENT=1 git commit ...
74+
Disable this rule for a repo (not recommended):
75+
git config multiagent.codexRequireAgentBranch false
76+
MSG
77+
exit 1
78+
fi
79+
fi
80+
4681
if [[ "$branch" == agent/* ]]; then
4782
if ! python3 scripts/agent-file-locks.py validate --branch "$branch" --staged; then
4883
cat >&2 <<'MSG'

test/install.test.js

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ function initRepoOnBranch(branchName) {
8484
function seedCommit(repoDir) {
8585
let result = runCmd('git', ['add', '.'], repoDir);
8686
assert.equal(result.status, 0, result.stderr);
87-
result = runCmd('git', ['commit', '-m', 'seed'], repoDir);
87+
result = runCmd('git', ['commit', '-m', 'seed'], repoDir, {
88+
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1',
89+
MUSAFETY_ALLOW_CODEX_ON_NON_AGENT: '1',
90+
});
8891
assert.equal(result.status, 0, result.stderr);
8992
}
9093

@@ -126,7 +129,7 @@ function commitFile(repoDir, relativePath, contents, message) {
126129
assert.equal(result.status, 0, result.stderr);
127130
const commitEnv = ['dev', 'main', 'master'].includes(branchName)
128131
? { ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1' }
129-
: {};
132+
: { MUSAFETY_ALLOW_CODEX_ON_NON_AGENT: '1' };
130133
result = runCmd('git', ['commit', '-m', message], repoDir, commitEnv);
131134
assert.equal(result.status, 0, result.stderr);
132135
}
@@ -310,6 +313,36 @@ test('setup --no-gitignore skips creating managed gitignore block', () => {
310313
assert.equal(fs.existsSync(path.join(repoDir, '.gitignore')), false);
311314
});
312315

316+
test('setup auto-refreshes managed pre-commit guard when template changed', () => {
317+
const repoDir = initRepo();
318+
319+
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
320+
assert.equal(result.status, 0, result.stderr || result.stdout);
321+
322+
fs.writeFileSync(path.join(repoDir, '.githooks', 'pre-commit'), '#!/usr/bin/env bash\nexit 0\n', 'utf8');
323+
324+
result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
325+
assert.equal(result.status, 0, result.stderr || result.stdout);
326+
327+
const repaired = fs.readFileSync(path.join(repoDir, '.githooks', 'pre-commit'), 'utf8');
328+
assert.match(repaired, /\[codex-branch-guard\] Codex agent commit blocked on non-agent branch/);
329+
});
330+
331+
test('doctor auto-refreshes managed pre-commit guard when template changed', () => {
332+
const repoDir = initRepo();
333+
334+
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
335+
assert.equal(result.status, 0, result.stderr || result.stdout);
336+
337+
fs.writeFileSync(path.join(repoDir, '.githooks', 'pre-commit'), '#!/usr/bin/env bash\nexit 0\n', 'utf8');
338+
339+
result = runNode(['doctor', '--target', repoDir], repoDir);
340+
assert.equal(result.status, 0, result.stderr || result.stdout);
341+
342+
const repaired = fs.readFileSync(path.join(repoDir, '.githooks', 'pre-commit'), 'utf8');
343+
assert.match(repaired, /\[codex-branch-guard\] Codex agent commit blocked on non-agent branch/);
344+
});
345+
313346
test('agent-branch-start keeps main worktree branch unchanged by default', () => {
314347
const repoDir = initRepo();
315348
seedCommit(repoDir);
@@ -443,6 +476,50 @@ test('pre-commit blocks protected branch commits even from VS Code Source Contro
443476
assert.match(hookResult.stderr, /Direct commits on protected branches are blocked/);
444477
});
445478

479+
test('pre-commit blocks Codex session commits on non-agent branches by default', () => {
480+
const repoDir = initRepoOnBranch('feature/codex-guard');
481+
seedCommit(repoDir);
482+
483+
const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
484+
assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout);
485+
486+
const hookResult = runCmd(
487+
'bash',
488+
['.githooks/pre-commit'],
489+
repoDir,
490+
{
491+
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0',
492+
CODEX_THREAD_ID: 'codex-thread-test',
493+
},
494+
);
495+
496+
assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout);
497+
assert.match(hookResult.stderr, /Codex agent commit blocked on non-agent branch/);
498+
});
499+
500+
test('pre-commit allows non-agent branch commits for Codex when repo guard is disabled', () => {
501+
const repoDir = initRepoOnBranch('feature/codex-guard-optout');
502+
seedCommit(repoDir);
503+
504+
const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
505+
assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout);
506+
507+
let result = runCmd('git', ['config', 'multiagent.codexRequireAgentBranch', 'false'], repoDir);
508+
assert.equal(result.status, 0, result.stderr);
509+
510+
const hookResult = runCmd(
511+
'bash',
512+
['.githooks/pre-commit'],
513+
repoDir,
514+
{
515+
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0',
516+
CODEX_THREAD_ID: 'codex-thread-test',
517+
},
518+
);
519+
520+
assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout);
521+
});
522+
446523
test('sync command rebases current agent branch onto latest origin/dev', () => {
447524
const repoDir = initRepo();
448525
seedCommit(repoDir);
@@ -671,7 +748,10 @@ test('validate blocks unapproved deletions until allow-delete is set', () => {
671748

672749
result = runCmd('git', ['add', '.'], repoDir);
673750
assert.equal(result.status, 0, result.stderr);
674-
result = runCmd('git', ['commit', '-m', 'seed'], repoDir);
751+
result = runCmd('git', ['commit', '-m', 'seed'], repoDir, {
752+
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1',
753+
MUSAFETY_ALLOW_CODEX_ON_NON_AGENT: '1',
754+
});
675755
assert.equal(result.status, 0, result.stderr);
676756

677757
result = runCmd(

0 commit comments

Comments
 (0)