Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,9 @@ Use this exact checklist to setup multi-agent safety in this repository for Code

```sh
gx status [--target <path>] [--json]
gx setup [--target <path>] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore]
gx init [--target <path>] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore]
gx doctor [--target <path>] [--dry-run] [--json] [--keep-stale-locks] [--no-gitignore]
gx setup [--target <path>] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore] [--allow-protected-base-write]
gx init [--target <path>] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore] [--allow-protected-base-write]
gx doctor [--target <path>] [--dry-run] [--json] [--keep-stale-locks] [--no-gitignore] [--allow-protected-base-write]
gx copy-prompt
gx copy-commands
gx protect list [--target <path>]
Expand All @@ -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 <path>] [--force] [--skip-agents] [--skip-package-json] [--no-gitignore] [--dry-run]
gx fix [--target <path>] [--dry-run] [--keep-stale-locks] [--no-gitignore]
gx install [--target <path>] [--force] [--skip-agents] [--skip-package-json] [--no-gitignore] [--dry-run] [--allow-protected-base-write]
gx fix [--target <path>] [--dry-run] [--keep-stale-locks] [--no-gitignore] [--allow-protected-base-write]
gx scan [--target <path>] [--json]
gx report help
```
Expand Down
51 changes: 51 additions & 0 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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 "<task>" "codex"\n` +
`Override once only when intentional: --allow-protected-base-write`,
);
}

function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
const remaining = [];
let target = defaultTarget;
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1989,6 +2038,7 @@ function setup(rawArgs) {
dryRun: false,
yesGlobalInstall: false,
noGlobalInstall: false,
allowProtectedBaseWrite: false,
});

const globalInstallStatus = installGlobalToolchain(options);
Expand All @@ -2011,6 +2061,7 @@ function setup(rawArgs) {
);
}

assertProtectedMainWriteAllowed(options, 'setup');
const installPayload = runInstallInternal(options);
printOperations('Setup/install', installPayload, options.dryRun);

Expand Down
36 changes: 36 additions & 0 deletions test/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading