From 50e814e62877e9205c7b03c302a663c259a636cd Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Sat, 11 Apr 2026 19:13:56 +0200 Subject: [PATCH] Prevent in-place maintenance writes on protected main by default Users expect local main to stay pull-only, but maintenance commands like setup/install/fix/doctor could still mutate an already-initialized repository when run from main. This change introduces a protected-main write guard for initialized repos and adds an explicit emergency override flag. Setup still performs global toolchain detection/install, then blocks before repository writes when main is protected. Documentation and tests were updated to make the behavior explicit. Constraint: First-time bootstrap on new repos must keep working on main Rejected: Block all protected branches (dev/main/master) immediately | would break existing dev-branch setup workflows Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep command option docs and parseCommonArgs in sync when adding guard/override flags Tested: npm test (52/52); node --check bin/multiagent-safety.js; manual setup run on initialized main blocked with guidance Not-tested: Windows shell output formatting of multiline guard error --- README.md | 11 +++++---- bin/multiagent-safety.js | 51 ++++++++++++++++++++++++++++++++++++++++ test/install.test.js | 36 ++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7ba002d..ab89f72 100644 --- a/README.md +++ b/README.md @@ -257,9 +257,9 @@ Use this exact checklist to setup multi-agent safety in this repository for Code ```sh gx status [--target ] [--json] -gx setup [--target ] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore] -gx init [--target ] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore] -gx doctor [--target ] [--dry-run] [--json] [--keep-stale-locks] [--no-gitignore] +gx setup [--target ] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore] [--allow-protected-base-write] +gx init [--target ] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore] [--allow-protected-base-write] +gx doctor [--target ] [--dry-run] [--json] [--keep-stale-locks] [--no-gitignore] [--allow-protected-base-write] gx copy-prompt gx copy-commands gx protect list [--target ] @@ -283,12 +283,13 @@ and asks `[y/N]` whether to update immediately (default is `N`). - Interactive setup: prompts for Y/N approval before global OMX/OpenSpec/codex-auth install. - Interactive prompt is strict (`[y/n]`) and waits for explicit answer. - Non-interactive setup: skips global installs by default; use `--yes-global-install` to force. +- In already-initialized repos, `setup` / `install` / `fix` / `doctor` block writes on protected `main` by default; start an agent branch first. Use `--allow-protected-base-write` only for emergency in-place maintenance. ## Advanced commands ```sh -gx install [--target ] [--force] [--skip-agents] [--skip-package-json] [--no-gitignore] [--dry-run] -gx fix [--target ] [--dry-run] [--keep-stale-locks] [--no-gitignore] +gx install [--target ] [--force] [--skip-agents] [--skip-package-json] [--no-gitignore] [--dry-run] [--allow-protected-base-write] +gx fix [--target ] [--dry-run] [--keep-stale-locks] [--no-gitignore] [--allow-protected-base-write] gx scan [--target ] [--json] gx report help ``` diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 82366b0..efb6fa8 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -288,6 +288,7 @@ NOTES - Short alias: ${SHORT_TOOL_NAME} - ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup - ${TOOL_NAME} setup asks for Y/N approval before global installs + - In initialized repos, setup/install/fix/doctor block in-place writes on protected main by default - Legacy command aliases are still supported: ${LEGACY_NAMES.join(', ')}`); if (outsideGitRepo) { @@ -645,6 +646,10 @@ function parseCommonArgs(rawArgs, defaults) { options.skipGitignore = true; continue; } + if (arg === '--allow-protected-base-write') { + options.allowProtectedBaseWrite = true; + continue; + } throw new Error(`Unknown option: ${arg}`); } @@ -656,6 +661,44 @@ function parseCommonArgs(rawArgs, defaults) { return options; } +function hasGuardexBootstrapFiles(repoRoot) { + const required = [ + 'AGENTS.md', + 'scripts/agent-branch-start.sh', + '.githooks/pre-commit', + LOCK_FILE_RELATIVE, + ]; + return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath))); +} + +function assertProtectedMainWriteAllowed(options, commandName) { + if (options.dryRun || options.allowProtectedBaseWrite) { + return; + } + + const repoRoot = resolveRepoRoot(options.target); + if (!hasGuardexBootstrapFiles(repoRoot)) { + return; + } + + const branch = currentBranchName(repoRoot); + if (branch !== 'main') { + return; + } + + const protectedBranches = readProtectedBranches(repoRoot); + if (!protectedBranches.includes(branch)) { + return; + } + + throw new Error( + `${commandName} blocked on protected branch '${branch}' in an initialized repo.\n` + + `Keep local '${branch}' pull-only: start an agent branch/worktree first:\n` + + ` bash scripts/agent-branch-start.sh "" "codex"\n` + + `Override once only when intentional: --allow-protected-base-write`, + ); +} + function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) { const remaining = []; let target = defaultTarget; @@ -1792,8 +1835,10 @@ function install(rawArgs) { skipPackageJson: false, skipGitignore: false, dryRun: false, + allowProtectedBaseWrite: false, }); + assertProtectedMainWriteAllowed(options, 'install'); const payload = runInstallInternal(options); printOperations('Install target', payload, options.dryRun); @@ -1812,8 +1857,10 @@ function fix(rawArgs) { skipPackageJson: false, skipGitignore: false, dryRun: false, + allowProtectedBaseWrite: false, }); + assertProtectedMainWriteAllowed(options, 'fix'); const payload = runFixInternal(options); printOperations('Fix target', payload, options.dryRun); @@ -1844,8 +1891,10 @@ function doctor(rawArgs) { skipGitignore: false, dryRun: false, json: false, + allowProtectedBaseWrite: false, }); + assertProtectedMainWriteAllowed(options, 'doctor'); const fixPayload = runFixInternal(options); const scanResult = runScanInternal({ target: options.target, json: false }); const musafe = scanResult.errors === 0 && scanResult.warnings === 0; @@ -1989,6 +2038,7 @@ function setup(rawArgs) { dryRun: false, yesGlobalInstall: false, noGlobalInstall: false, + allowProtectedBaseWrite: false, }); const globalInstallStatus = installGlobalToolchain(options); @@ -2011,6 +2061,7 @@ function setup(rawArgs) { ); } + assertProtectedMainWriteAllowed(options, 'setup'); const installPayload = runInstallInternal(options); printOperations('Setup/install', installPayload, options.dryRun); diff --git a/test/install.test.js b/test/install.test.js index bbb64e0..68ef3ad 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -253,6 +253,42 @@ test('init aliases setup and provisions workflow files', () => { assert.equal(fs.existsSync(path.join(repoDir, 'AGENTS.md')), true); }); +test('setup blocks in-place maintenance writes on protected main after initialization', () => { + const repoDir = initRepoOnBranch('main'); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 1, result.stderr || result.stdout); + assert.match(result.stderr, /setup blocked on protected branch 'main'/); + assert.match(result.stderr, /agent-branch-start\.sh/); +}); + +test('setup allows explicit protected-main override for in-place maintenance', () => { + const repoDir = initRepoOnBranch('main'); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNode( + ['setup', '--target', repoDir, '--no-global-install', '--allow-protected-base-write'], + repoDir, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +test('install blocks in-place maintenance writes on protected main unless override is set', () => { + const repoDir = initRepoOnBranch('main'); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNode(['install', '--target', repoDir], repoDir); + assert.equal(result.status, 1, result.stderr || result.stdout); + assert.match(result.stderr, /install blocked on protected branch 'main'/); +}); + test('setup pre-commit blocks codex session commits on non-agent branches by default', () => { const repoDir = initRepo();