Skip to content

Commit f95b83f

Browse files
NagyViktNagyVikt
andauthored
Allow guarded Codex-only AGENTS/.gitignore commits on protected branches (#38)
Guardrail maintenance on protected branches needs a narrow exception so Codex can land managed AGENTS.md and .gitignore updates while still blocking other protected-branch commits. Setup/doctor also self-repair critical guardrail files from templates. Constraint: Protected branches must continue blocking general Codex commits outside agent/* branches Rejected: Disable protected-branch Codex guard entirely | weakens branch safety policy Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep template and live pre-commit hook logic in lockstep Tested: node --check bin/multiagent-safety.js Not-tested: fully passing suite in this environment Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 95b8fdf commit f95b83f

5 files changed

Lines changed: 93 additions & 2 deletions

File tree

.githooks/pre-commit

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,31 @@ case "$codex_require_agent_branch" in
5050
*) should_require_codex_agent_branch=1 ;;
5151
esac
5252

53+
is_codex_managed_only_commit_on_protected=0
54+
if [[ "$is_codex_session" == "1" && "$is_protected_branch" == "1" ]]; then
55+
deleted_paths="$(git diff --cached --name-only --diff-filter=D)"
56+
staged_paths="$(git diff --cached --name-only --diff-filter=ACMRTUXB)"
57+
if [[ -z "$deleted_paths" && -n "$staged_paths" ]]; then
58+
managed_only=1
59+
while IFS= read -r staged_path; do
60+
case "$staged_path" in
61+
AGENTS.md|.gitignore) ;;
62+
*) managed_only=0; break ;;
63+
esac
64+
done <<< "$staged_paths"
65+
if [[ "$managed_only" == "1" ]]; then
66+
is_codex_managed_only_commit_on_protected=1
67+
fi
68+
fi
69+
fi
70+
5371
if [[ "$should_require_codex_agent_branch" == "1" && "${MUSAFETY_ALLOW_CODEX_ON_NON_AGENT:-0}" != "1" ]]; then
5472
if [[ "$is_codex_session" == "1" && "$branch" != agent/* ]]; then
5573
if [[ "$is_protected_branch" == "1" ]]; then
74+
if [[ "$is_codex_managed_only_commit_on_protected" == "1" ]]; then
75+
exit 0
76+
fi
77+
5678
cat >&2 <<'MSG'
5779
[guardex-preedit-guard] Codex edit/commit detected on a protected branch.
5880
GuardeX requires Codex work to run from an isolated agent/* branch.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ and asks `[y/N]` whether to update immediately (default is `N`).
289289
- Non-interactive setup: skips global installs by default; use `--yes-global-install` to force.
290290
- In already-initialized repos, `setup` / `install` / `fix` block writes on protected `main` by default; start an agent branch first. Use `--allow-protected-base-write` only for emergency in-place maintenance.
291291
- `gx doctor` on protected `main` auto-starts an isolated `agent/gx/...-gx-doctor` worktree branch and applies repairs there.
292+
- `gx setup` and `gx doctor` always refresh `.githooks/pre-commit` from templates, so Codex sub-branch enforcement stays repaired.
292293
- `scripts/codex-agent.sh` now auto-runs finish automation after a Codex session when `origin` exists:
293294
auto-commit changed files, run PR/merge cleanup, and prune merged worktrees.
294295
If conflicts remain, it keeps the sandbox and prompts for a conflict-resolution review pass.
@@ -368,7 +369,7 @@ multiagent.protectedBranches
368369
## What is protected
369370

370371
- direct commits to protected branches (defaults: `dev`, `main`, `master`; configurable via `gx protect ...`)
371-
- protected-branch commits are blocked regardless of commit client (including VS Code Source Control)
372+
- protected-branch commits are blocked by default for all clients; Codex sessions only may commit protected branches when staged files are strictly `AGENTS.md` and/or `.gitignore`
372373
- Codex-session commits on non-`agent/*` branches are blocked by default (`multiagent.codexRequireAgentBranch=true`)
373374
- Codex commits attempted on protected branches trigger `guardex-preedit-guard` and require starting work via `scripts/codex-agent.sh`
374375
- overlapping file ownership between agents

bin/multiagent-safety.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,10 @@ function ensureExecutable(destinationPath, relativePath, dryRun) {
363363
}
364364
}
365365

366+
function isCriticalGuardrailPath(relativePath) {
367+
return CRITICAL_GUARDRAIL_PATHS.has(relativePath);
368+
}
369+
366370
function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
367371
const sourcePath = path.join(TEMPLATE_ROOT, relativeTemplatePath);
368372
const destinationRelativePath = toDestinationPath(relativeTemplatePath);
@@ -377,7 +381,7 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
377381
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
378382
return { status: 'unchanged', file: destinationRelativePath };
379383
}
380-
if (!force) {
384+
if (!force && !isCriticalGuardrailPath(destinationRelativePath)) {
381385
throw new Error(
382386
`Refusing to overwrite existing file without --force: ${destinationRelativePath}`,
383387
);
@@ -390,6 +394,10 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
390394
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
391395
}
392396

397+
if (destinationExists && !force && isCriticalGuardrailPath(destinationRelativePath)) {
398+
return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
399+
}
400+
393401
return { status: destinationExists ? 'overwritten' : 'created', file: destinationRelativePath };
394402
}
395403

@@ -406,6 +414,14 @@ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
406414
return { status: 'unchanged', file: destinationRelativePath };
407415
}
408416

417+
if (isCriticalGuardrailPath(destinationRelativePath)) {
418+
if (!dryRun) {
419+
fs.writeFileSync(destinationPath, sourceContent, 'utf8');
420+
ensureExecutable(destinationPath, destinationRelativePath, dryRun);
421+
}
422+
return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
423+
}
424+
409425
// In fix mode, avoid silently replacing local customizations.
410426
return { status: 'skipped-conflict', file: destinationRelativePath };
411427
}

templates/githooks/pre-commit

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,31 @@ case "$codex_require_agent_branch" in
5050
*) should_require_codex_agent_branch=1 ;;
5151
esac
5252

53+
is_codex_managed_only_commit_on_protected=0
54+
if [[ "$is_codex_session" == "1" && "$is_protected_branch" == "1" ]]; then
55+
deleted_paths="$(git diff --cached --name-only --diff-filter=D)"
56+
staged_paths="$(git diff --cached --name-only --diff-filter=ACMRTUXB)"
57+
if [[ -z "$deleted_paths" && -n "$staged_paths" ]]; then
58+
managed_only=1
59+
while IFS= read -r staged_path; do
60+
case "$staged_path" in
61+
AGENTS.md|.gitignore) ;;
62+
*) managed_only=0; break ;;
63+
esac
64+
done <<< "$staged_paths"
65+
if [[ "$managed_only" == "1" ]]; then
66+
is_codex_managed_only_commit_on_protected=1
67+
fi
68+
fi
69+
fi
70+
5371
if [[ "$should_require_codex_agent_branch" == "1" && "${MUSAFETY_ALLOW_CODEX_ON_NON_AGENT:-0}" != "1" ]]; then
5472
if [[ "$is_codex_session" == "1" && "$branch" != agent/* ]]; then
5573
if [[ "$is_protected_branch" == "1" ]]; then
74+
if [[ "$is_codex_managed_only_commit_on_protected" == "1" ]]; then
75+
exit 0
76+
fi
77+
5678
cat >&2 <<'MSG'
5779
[guardex-preedit-guard] Codex edit/commit detected on a protected branch.
5880
GuardeX requires Codex work to run from an isolated agent/* branch.

test/install.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,32 @@ test('setup pre-commit detects codex commit attempts on protected main and requi
359359
assert.match(result.stderr, /bash scripts\/codex-agent\.sh/);
360360
});
361361

362+
test('setup pre-commit allows codex managed guardrail commits on protected main only for AGENTS.md/.gitignore', () => {
363+
const repoDir = initRepoOnBranch('main');
364+
365+
let result = runNode(['setup', '--target', repoDir], repoDir);
366+
assert.equal(result.status, 0, result.stderr || result.stdout);
367+
368+
fs.appendFileSync(path.join(repoDir, 'AGENTS.md'), '\n<!-- codex-managed test -->\n', 'utf8');
369+
result = runCmd('git', ['add', 'AGENTS.md'], repoDir);
370+
assert.equal(result.status, 0, result.stderr || result.stdout);
371+
result = runCmd('git', ['commit', '-m', 'codex protected AGENTS commit'], repoDir, { CODEX_THREAD_ID: 'test-thread' });
372+
assert.equal(result.status, 0, result.stderr || result.stdout);
373+
374+
fs.appendFileSync(path.join(repoDir, '.gitignore'), '\n# codex-managed test\n', 'utf8');
375+
result = runCmd('git', ['add', '.gitignore'], repoDir);
376+
assert.equal(result.status, 0, result.stderr || result.stdout);
377+
result = runCmd('git', ['commit', '-m', 'codex protected gitignore commit'], repoDir, { CODEX_THREAD_ID: 'test-thread' });
378+
assert.equal(result.status, 0, result.stderr || result.stdout);
379+
380+
fs.writeFileSync(path.join(repoDir, 'notes-main.txt'), 'hello from main\n', 'utf8');
381+
result = runCmd('git', ['add', 'notes-main.txt'], repoDir);
382+
assert.equal(result.status, 0, result.stderr || result.stdout);
383+
result = runCmd('git', ['commit', '-m', 'codex protected non-managed commit'], repoDir, { CODEX_THREAD_ID: 'test-thread' });
384+
assert.notEqual(result.status, 0, result.stdout);
385+
assert.match(result.stderr, /\[guardex-preedit-guard\] Codex edit\/commit detected on a protected branch\./);
386+
});
387+
362388
test('setup agent-branch-start requires --allow-in-place when using --in-place', () => {
363389
const repoDir = initRepo();
364390

@@ -1335,6 +1361,7 @@ test('doctor repairs setup drift and confirms repo is musafe', () => {
13351361

13361362
// Simulate broken setup + stale lock.
13371363
fs.rmSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh'));
1364+
fs.writeFileSync(path.join(repoDir, '.githooks', 'pre-commit'), '#!/usr/bin/env bash\necho broken hook >&2\nexit 1\n', 'utf8');
13381365
result = runCmd('git', ['config', 'core.hooksPath', '.git/hooks'], repoDir);
13391366
assert.equal(result.status, 0, result.stderr);
13401367

@@ -1361,6 +1388,9 @@ test('doctor repairs setup drift and confirms repo is musafe', () => {
13611388
assert.match(result.stdout, /Doctor\/fix/);
13621389
assert.match(result.stdout, /Repo is correctly musafe/);
13631390

1391+
const repairedHook = fs.readFileSync(path.join(repoDir, '.githooks', 'pre-commit'), 'utf8');
1392+
assert.match(repairedHook, /AGENTS\.md\|\.gitignore/);
1393+
13641394
const scanAfter = runNode(['scan', '--target', repoDir], repoDir);
13651395
assert.equal(scanAfter.status, 0, scanAfter.stderr || scanAfter.stdout);
13661396
});

0 commit comments

Comments
 (0)