diff --git a/.githooks/pre-commit b/.githooks/pre-commit index b7b01e2..8eb3b0d 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -23,6 +23,11 @@ if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" is_codex_session=1 fi +is_vscode_git_context=0 +if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" || "${TERM_PROGRAM:-}" == "vscode" ]]; then + is_vscode_git_context=1 +fi + protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}" if [[ -z "$protected_branches_raw" ]]; then protected_branches_raw="dev main master" @@ -106,6 +111,10 @@ MSG fi if [[ "$is_protected_branch" == "1" ]]; then + if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then + exit 0 + fi + if [[ "$is_unborn_branch" == "1" && "$is_codex_session" != "1" ]]; then exit 0 fi diff --git a/.githooks/pre-push b/.githooks/pre-push index 88a5d7f..f46f64d 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -10,16 +10,9 @@ if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n is_vscode_git_context=1 fi -allow_vscode_protected_branch_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH:-$(git config --get multiagent.protectedBranches.allowVSCode || true)}" -allow_vscode_protected_branch_raw="$(printf '%s' "${allow_vscode_protected_branch_raw:-}" | tr '[:upper:]' '[:lower:]')" -allow_vscode_protected_branch=0 -case "$allow_vscode_protected_branch_raw" in - 1|true|yes|on) allow_vscode_protected_branch=1 ;; - *) allow_vscode_protected_branch=0 ;; -esac - -if [[ "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch" == "1" ]]; then - exit 0 +is_codex_session=0 +if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" == "1" ]]; then + is_codex_session=1 fi protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}" @@ -51,12 +44,26 @@ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do done if [[ "${#blocked_refs[@]}" -gt 0 ]]; then + if [[ "$is_codex_session" == "1" ]]; then + { + echo "[guardex-preedit-guard] Codex push detected toward protected branch." + echo "[guardex-preedit-guard] Protected target(s): ${blocked_refs[*]}" + echo "[guardex-preedit-guard] Run Codex from an agent/* branch and merge via PR." + echo + echo "Temporary bypass (not recommended):" + echo " ALLOW_PUSH_ON_PROTECTED_BRANCH=1 git push ..." + } >&2 + exit 1 + fi + + if [[ "$is_vscode_git_context" == "1" ]]; then + exit 0 + fi + { - echo "[agent-branch-guard] Push to protected branch blocked." + echo "[agent-branch-guard] Push to protected branch blocked outside VS Code Git context." echo "[agent-branch-guard] Protected target(s): ${blocked_refs[*]}" - echo "[agent-branch-guard] Push from an agent branch and merge via PR." - echo "[agent-branch-guard] Optional repo override for VS Code Source Control:" - echo " git config multiagent.protectedBranches.allowVSCode true" + echo "[agent-branch-guard] Use VS Code Source Control for protected-branch push, or push from an agent branch and merge via PR." echo echo "Temporary bypass (not recommended):" echo " ALLOW_PUSH_ON_PROTECTED_BRANCH=1 git push ..." diff --git a/README.md b/README.md index 917c781..6eb65f8 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ gx report scorecard --repo github.com/recodeecom/multiagent-safety - `gx setup` checks GitHub CLI (`gh`) and prints install guidance if missing. - Interactive self-update prompt defaults to **No** (`[y/N]`). - In initialized repos, `setup`/`install`/`fix` block protected-base writes unless explicitly overridden. +- In VS Code Source Control, manual (non-Codex) commits/pushes to protected branches are allowed by default. +- Codex/agent sessions stay blocked on protected branches and must use `agent/*` branch + PR workflow. - On protected `main`, `gx doctor` auto-runs in a sandbox agent branch/worktree. - `scripts/agent-branch-start.sh` hydrates `scripts/codex-agent.sh` into new sandbox worktrees when missing, so auto-finish launcher flow stays available. @@ -190,6 +192,7 @@ scripts/agent-file-locks.py scripts/install-agent-git-hooks.sh scripts/openspec/init-plan-workspace.sh .githooks/pre-commit +.githooks/pre-push .codex/skills/guardex/SKILL.md .claude/commands/guardex.md .omx/state/agent-file-locks.json diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 50d27fa..4bdc762 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -46,6 +46,7 @@ const TEMPLATE_FILES = [ 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', 'githooks/pre-commit', + 'githooks/pre-push', 'codex/skills/guardex/SKILL.md', 'claude/commands/guardex.md', ]; @@ -59,11 +60,13 @@ const EXECUTABLE_RELATIVE_PATHS = new Set([ 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', + '.githooks/pre-push', ]); const CRITICAL_GUARDRAIL_PATHS = new Set([ 'AGENTS.md', '.githooks/pre-commit', + '.githooks/pre-push', 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/agent-worktree-prune.sh', @@ -84,6 +87,7 @@ const MANAGED_GITIGNORE_PATHS = [ 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', + '.githooks/pre-push', 'oh-my-codex/', '.codex/skills/guardex/SKILL.md', '.claude/commands/guardex.md', @@ -706,6 +710,7 @@ function hasGuardexBootstrapFiles(repoRoot) { 'AGENTS.md', 'scripts/agent-branch-start.sh', '.githooks/pre-commit', + '.githooks/pre-push', LOCK_FILE_RELATIVE, ]; return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath))); diff --git a/scripts/agent-file-locks.py b/scripts/agent-file-locks.py index 0c52c88..06cdd7a 100755 --- a/scripts/agent-file-locks.py +++ b/scripts/agent-file-locks.py @@ -26,6 +26,7 @@ CRITICAL_GUARDRAIL_PATHS = { 'AGENTS.md', '.githooks/pre-commit', + '.githooks/pre-push', 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/agent-file-locks.py', diff --git a/templates/githooks/pre-commit b/templates/githooks/pre-commit index b7b01e2..8eb3b0d 100755 --- a/templates/githooks/pre-commit +++ b/templates/githooks/pre-commit @@ -23,6 +23,11 @@ if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" is_codex_session=1 fi +is_vscode_git_context=0 +if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" || "${TERM_PROGRAM:-}" == "vscode" ]]; then + is_vscode_git_context=1 +fi + protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}" if [[ -z "$protected_branches_raw" ]]; then protected_branches_raw="dev main master" @@ -106,6 +111,10 @@ MSG fi if [[ "$is_protected_branch" == "1" ]]; then + if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then + exit 0 + fi + if [[ "$is_unborn_branch" == "1" && "$is_codex_session" != "1" ]]; then exit 0 fi diff --git a/templates/githooks/pre-push b/templates/githooks/pre-push index 88b7704..f46f64d 100644 --- a/templates/githooks/pre-push +++ b/templates/githooks/pre-push @@ -10,8 +10,9 @@ if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n is_vscode_git_context=1 fi -if [[ "$is_vscode_git_context" == "1" ]]; then - exit 0 +is_codex_session=0 +if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" == "1" ]]; then + is_codex_session=1 fi protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}" @@ -43,6 +44,22 @@ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do done if [[ "${#blocked_refs[@]}" -gt 0 ]]; then + if [[ "$is_codex_session" == "1" ]]; then + { + echo "[guardex-preedit-guard] Codex push detected toward protected branch." + echo "[guardex-preedit-guard] Protected target(s): ${blocked_refs[*]}" + echo "[guardex-preedit-guard] Run Codex from an agent/* branch and merge via PR." + echo + echo "Temporary bypass (not recommended):" + echo " ALLOW_PUSH_ON_PROTECTED_BRANCH=1 git push ..." + } >&2 + exit 1 + fi + + if [[ "$is_vscode_git_context" == "1" ]]; then + exit 0 + fi + { echo "[agent-branch-guard] Push to protected branch blocked outside VS Code Git context." echo "[agent-branch-guard] Protected target(s): ${blocked_refs[*]}" diff --git a/templates/scripts/agent-file-locks.py b/templates/scripts/agent-file-locks.py index 0c52c88..06cdd7a 100755 --- a/templates/scripts/agent-file-locks.py +++ b/templates/scripts/agent-file-locks.py @@ -26,6 +26,7 @@ CRITICAL_GUARDRAIL_PATHS = { 'AGENTS.md', '.githooks/pre-commit', + '.githooks/pre-push', 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/agent-file-locks.py', diff --git a/test/install.test.js b/test/install.test.js index bcd65c1..0bbc7c1 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -32,10 +32,16 @@ function runCmd(cmd, args, cwd, options = {}) { delete sanitizedEnv.OMX_SESSION_ID; delete sanitizedEnv.CODEX_CI; + const overrideEnv = options.env || options; + const pushBypassEnv = + cmd === 'git' && Array.isArray(args) && args[0] === 'push' + ? { ALLOW_PUSH_ON_PROTECTED_BRANCH: '1' } + : {}; + return cp.spawnSync(cmd, args, { cwd, encoding: 'utf8', - env: { ...sanitizedEnv, ...(options.env || options) }, + env: { ...sanitizedEnv, ...pushBypassEnv, ...overrideEnv }, }); } @@ -215,6 +221,7 @@ test('setup provisions workflow files and repo config', () => { 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', + '.githooks/pre-push', '.codex/skills/guardex/SKILL.md', '.claude/commands/guardex.md', '.omx/state/agent-file-locks.json', @@ -246,6 +253,7 @@ test('setup provisions workflow files and repo config', () => { assert.match(gitignoreContent, /scripts\/codex-agent\.sh/); assert.match(gitignoreContent, /scripts\/agent-file-locks\.py/); assert.match(gitignoreContent, /\.githooks\/pre-commit/); + assert.match(gitignoreContent, /\.githooks\/pre-push/); assert.match(gitignoreContent, /oh-my-codex\//); assert.match(gitignoreContent, /\.codex\/skills\/guardex\/SKILL\.md/); assert.match(gitignoreContent, /\.claude\/commands\/guardex\.md/); @@ -500,7 +508,7 @@ test('setup pre-commit blocks codex session commits on non-agent branches by def assert.match(result.stderr, /\[codex-branch-guard\] Codex agent commit blocked on non-agent branch\./); }); -test('setup pre-commit detects codex commit attempts on protected main and requires GuardeX sub-branch', () => { +test('setup pre-commit detects codex commit attempts on protected main (including VS Code env) and requires GuardeX sub-branch', () => { const repoDir = initRepoOnBranch('main'); let result = runNode(['setup', '--target', repoDir], repoDir); @@ -510,7 +518,12 @@ test('setup pre-commit detects codex commit attempts on protected main and requi result = runCmd('git', ['add', 'notes-main.txt'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - result = runCmd('git', ['commit', '-m', 'codex protected commit'], repoDir, { CODEX_THREAD_ID: 'test-thread' }); + result = runCmd('git', ['commit', '-m', 'codex protected commit'], repoDir, { + CODEX_THREAD_ID: 'test-thread', + VSCODE_GIT_IPC_HANDLE: '1', + VSCODE_GIT_ASKPASS_NODE: '1', + VSCODE_IPC_HOOK_CLI: '1', + }); assert.notEqual(result.status, 0, result.stdout); assert.match(result.stderr, /\[guardex-preedit-guard\] Codex edit\/commit detected on a protected branch\./); assert.match(result.stderr, /bash scripts\/codex-agent\.sh/); @@ -888,7 +901,7 @@ test('protect command manages configured protected branches', () => { assert.match(result.stdout, /reset to defaults/); }); -test('pre-commit blocks custom protected branches configured via musafety protect', () => { +test('pre-commit allows non-codex VS Code commits on custom protected branches configured via musafety protect', () => { const repoDir = initRepoOnBranch('release'); seedCommit(repoDir); @@ -902,11 +915,10 @@ test('pre-commit blocks custom protected branches configured via musafety protec ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0', VSCODE_GIT_IPC_HANDLE: '1', }); - assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); - assert.match(hookResult.stderr, /Direct commits on protected branches are blocked/); + assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); }); -test('pre-commit blocks protected branch commits even from VS Code Source Control env', () => { +test('pre-commit allows non-codex protected branch commits from VS Code Source Control env', () => { const repoDir = initRepo(); seedCommit(repoDir); @@ -924,8 +936,55 @@ test('pre-commit blocks protected branch commits even from VS Code Source Contro VSCODE_IPC_HOOK_CLI: '1', }, ); + assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); +}); + +test('pre-push allows non-codex protected branch pushes from VS Code Source Control env', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + + const hookResult = runCmd( + 'bash', + [ + '-lc', + `printf '%s\\n' 'refs/heads/main 1111111111111111111111111111111111111111 refs/heads/main 0000000000000000000000000000000000000000' | .githooks/pre-push origin origin`, + ], + repoDir, + { + VSCODE_GIT_IPC_HANDLE: '1', + VSCODE_GIT_ASKPASS_NODE: '1', + VSCODE_IPC_HOOK_CLI: '1', + }, + ); + assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); +}); + +test('pre-push blocks codex protected branch pushes even from VS Code Source Control env', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + + const hookResult = runCmd( + 'bash', + [ + '-lc', + `printf '%s\\n' 'refs/heads/main 1111111111111111111111111111111111111111 refs/heads/main 0000000000000000000000000000000000000000' | .githooks/pre-push origin origin`, + ], + repoDir, + { + CODEX_THREAD_ID: 'test-thread', + VSCODE_GIT_IPC_HANDLE: '1', + VSCODE_GIT_ASKPASS_NODE: '1', + VSCODE_IPC_HOOK_CLI: '1', + }, + ); assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); - assert.match(hookResult.stderr, /Direct commits on protected branches are blocked/); + assert.match(hookResult.stderr, /\[guardex-preedit-guard\] Codex push detected toward protected branch\./); }); test('codex-agent launches codex inside a fresh sandbox worktree and keeps branch/worktree by default', () => {