From 8dbf2badb640aa8d7b0eda08d82a593d742781c4 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 13 Apr 2026 21:04:04 +0200 Subject: [PATCH] Enforce agent-branch workflow on protected branches by default Codex edits in VS Code could bypass protected-branch guards when Codex session env markers were missing. This flips the default to block protected-branch commits/pushes in all contexts and keeps VS Code writes as an explicit per-repo opt-in.\n\nConstraint: Guardrails must still allow intentional local overrides for teams that want manual VS Code protected-branch operations\nRejected: Keep VS Code bypass default-on and rely on Codex env detection | Codex session markers are not always present in every integration surface\nConfidence: high\nScope-risk: moderate\nReversibility: clean\nDirective: Keep template and hydrated .githooks behavior aligned when editing guardrail hook logic\nTested: node --test test/install.test.js\nNot-tested: Live VS Code GUI commit/push flow against a real remote --- .githooks/pre-commit | 18 ++++++++- .githooks/pre-push | 21 +++++++++-- README.md | 3 +- templates/githooks/pre-commit | 18 ++++++++- templates/githooks/pre-push | 21 +++++++++-- test/install.test.js | 69 +++++++++++++++++++++++++++++++++-- 6 files changed, 137 insertions(+), 13 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8eb3b0d..8ae9fe1 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -28,6 +28,19 @@ if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n is_vscode_git_context=1 fi +allow_vscode_protected_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH_WRITES:-$(git config --get multiagent.allowVscodeProtectedBranchWrites || true)}" +if [[ -z "$allow_vscode_protected_raw" ]]; then + allow_vscode_protected_raw="false" +fi +allow_vscode_protected="$(printf '%s' "$allow_vscode_protected_raw" | tr '[:upper:]' '[:lower:]')" + +allow_vscode_protected_branch_writes=0 +case "$allow_vscode_protected" in + 1|true|yes|on) allow_vscode_protected_branch_writes=1 ;; + 0|false|no|off) allow_vscode_protected_branch_writes=0 ;; + *) allow_vscode_protected_branch_writes=0 ;; +esac + protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}" if [[ -z "$protected_branches_raw" ]]; then protected_branches_raw="dev main master" @@ -111,7 +124,7 @@ MSG fi if [[ "$is_protected_branch" == "1" ]]; then - if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then + if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then exit 0 fi @@ -131,6 +144,9 @@ Use an agent branch first: After finishing work: bash scripts/agent-branch-finish.sh +Optional repo override for manual VS Code protected-branch commits: + git config multiagent.allowVscodeProtectedBranchWrites true + Temporary bypass (not recommended): ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... MSG diff --git a/.githooks/pre-push b/.githooks/pre-push index f46f64d..a0f066a 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -10,6 +10,19 @@ if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n is_vscode_git_context=1 fi +allow_vscode_protected_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH_WRITES:-$(git config --get multiagent.allowVscodeProtectedBranchWrites || true)}" +if [[ -z "$allow_vscode_protected_raw" ]]; then + allow_vscode_protected_raw="false" +fi +allow_vscode_protected="$(printf '%s' "$allow_vscode_protected_raw" | tr '[:upper:]' '[:lower:]')" + +allow_vscode_protected_branch_writes=0 +case "$allow_vscode_protected" in + 1|true|yes|on) allow_vscode_protected_branch_writes=1 ;; + 0|false|no|off) allow_vscode_protected_branch_writes=0 ;; + *) allow_vscode_protected_branch_writes=0 ;; +esac + is_codex_session=0 if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" == "1" ]]; then is_codex_session=1 @@ -56,14 +69,16 @@ if [[ "${#blocked_refs[@]}" -gt 0 ]]; then exit 1 fi - if [[ "$is_vscode_git_context" == "1" ]]; then + if [[ "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then exit 0 fi { - echo "[agent-branch-guard] Push to protected branch blocked outside VS Code Git context." + echo "[agent-branch-guard] Push to protected branch blocked." echo "[agent-branch-guard] Protected target(s): ${blocked_refs[*]}" - echo "[agent-branch-guard] Use VS Code Source Control for protected-branch push, or push from an agent branch and merge via PR." + echo "[agent-branch-guard] Use an agent branch and merge via PR." + echo "[agent-branch-guard] Optional VS Code override:" + echo " git config multiagent.allowVscodeProtectedBranchWrites true" echo echo "Temporary bypass (not recommended):" echo " ALLOW_PUSH_ON_PROTECTED_BRANCH=1 git push ..." diff --git a/README.md b/README.md index de3c0f6..dc4faef 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,8 @@ Note: the monitor dispatches Codex through explicit `--task/--agent/--base` flag - `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. +- Direct commits/pushes to protected branches are blocked by default (including VS Code Source Control). +- Optional repo override for manual VS Code protected-branch writes: `git config multiagent.allowVscodeProtectedBranchWrites true`. - 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. diff --git a/templates/githooks/pre-commit b/templates/githooks/pre-commit index 8eb3b0d..8ae9fe1 100755 --- a/templates/githooks/pre-commit +++ b/templates/githooks/pre-commit @@ -28,6 +28,19 @@ if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n is_vscode_git_context=1 fi +allow_vscode_protected_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH_WRITES:-$(git config --get multiagent.allowVscodeProtectedBranchWrites || true)}" +if [[ -z "$allow_vscode_protected_raw" ]]; then + allow_vscode_protected_raw="false" +fi +allow_vscode_protected="$(printf '%s' "$allow_vscode_protected_raw" | tr '[:upper:]' '[:lower:]')" + +allow_vscode_protected_branch_writes=0 +case "$allow_vscode_protected" in + 1|true|yes|on) allow_vscode_protected_branch_writes=1 ;; + 0|false|no|off) allow_vscode_protected_branch_writes=0 ;; + *) allow_vscode_protected_branch_writes=0 ;; +esac + protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}" if [[ -z "$protected_branches_raw" ]]; then protected_branches_raw="dev main master" @@ -111,7 +124,7 @@ MSG fi if [[ "$is_protected_branch" == "1" ]]; then - if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then + if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then exit 0 fi @@ -131,6 +144,9 @@ Use an agent branch first: After finishing work: bash scripts/agent-branch-finish.sh +Optional repo override for manual VS Code protected-branch commits: + git config multiagent.allowVscodeProtectedBranchWrites true + Temporary bypass (not recommended): ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... MSG diff --git a/templates/githooks/pre-push b/templates/githooks/pre-push index f46f64d..a0f066a 100644 --- a/templates/githooks/pre-push +++ b/templates/githooks/pre-push @@ -10,6 +10,19 @@ if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n is_vscode_git_context=1 fi +allow_vscode_protected_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH_WRITES:-$(git config --get multiagent.allowVscodeProtectedBranchWrites || true)}" +if [[ -z "$allow_vscode_protected_raw" ]]; then + allow_vscode_protected_raw="false" +fi +allow_vscode_protected="$(printf '%s' "$allow_vscode_protected_raw" | tr '[:upper:]' '[:lower:]')" + +allow_vscode_protected_branch_writes=0 +case "$allow_vscode_protected" in + 1|true|yes|on) allow_vscode_protected_branch_writes=1 ;; + 0|false|no|off) allow_vscode_protected_branch_writes=0 ;; + *) allow_vscode_protected_branch_writes=0 ;; +esac + is_codex_session=0 if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" == "1" ]]; then is_codex_session=1 @@ -56,14 +69,16 @@ if [[ "${#blocked_refs[@]}" -gt 0 ]]; then exit 1 fi - if [[ "$is_vscode_git_context" == "1" ]]; then + if [[ "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then exit 0 fi { - echo "[agent-branch-guard] Push to protected branch blocked outside VS Code Git context." + echo "[agent-branch-guard] Push to protected branch blocked." echo "[agent-branch-guard] Protected target(s): ${blocked_refs[*]}" - echo "[agent-branch-guard] Use VS Code Source Control for protected-branch push, or push from an agent branch and merge via PR." + echo "[agent-branch-guard] Use an agent branch and merge via PR." + echo "[agent-branch-guard] Optional VS Code override:" + echo " git config multiagent.allowVscodeProtectedBranchWrites true" echo echo "Temporary bypass (not recommended):" echo " ALLOW_PUSH_ON_PROTECTED_BRANCH=1 git push ..." diff --git a/test/install.test.js b/test/install.test.js index 0ae0631..d8cf89d 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -1054,7 +1054,7 @@ test('protect command manages configured protected branches', () => { assert.match(result.stdout, /reset to defaults/); }); -test('pre-commit allows non-codex VS Code commits on custom protected branches configured via musafety protect', () => { +test('pre-commit blocks non-codex VS Code commits on custom protected branches by default', () => { const repoDir = initRepoOnBranch('release'); seedCommit(repoDir); @@ -1068,16 +1068,70 @@ test('pre-commit allows non-codex VS Code commits on custom protected branches c ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0', VSCODE_GIT_IPC_HANDLE: '1', }); - assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); + assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); + assert.match(hookResult.stderr, /\[agent-branch-guard\] Direct commits on protected branches are blocked\./); +}); + +test('pre-commit blocks non-codex protected branch commits from VS Code Source Control env by default', () => { + const repoDir = initRepo(); + 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', + ['.githooks/pre-commit'], + repoDir, + { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0', + 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, /\[agent-branch-guard\] Direct commits on protected branches are blocked\./); +}); + +test('pre-push blocks non-codex protected branch pushes from VS Code Source Control env by default', () => { + 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, 1, hookResult.stderr || hookResult.stdout); + assert.match(hookResult.stderr, /\[agent-branch-guard\] Push to protected branch blocked\./); }); -test('pre-commit allows non-codex protected branch commits from VS Code Source Control env', () => { +test('pre-commit allows non-codex protected branch commits from VS Code Source Control env when explicitly enabled', () => { const repoDir = initRepo(); seedCommit(repoDir); const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + let configResult = runCmd( + 'git', + ['config', 'multiagent.allowVscodeProtectedBranchWrites', 'true'], + repoDir, + ); + assert.equal(configResult.status, 0, configResult.stderr || configResult.stdout); + const hookResult = runCmd( 'bash', ['.githooks/pre-commit'], @@ -1092,13 +1146,20 @@ test('pre-commit allows non-codex protected branch commits from VS Code Source C assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); }); -test('pre-push allows non-codex protected branch pushes from VS Code Source Control env', () => { +test('pre-push allows non-codex protected branch pushes from VS Code Source Control env when explicitly enabled', () => { 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); + let configResult = runCmd( + 'git', + ['config', 'multiagent.allowVscodeProtectedBranchWrites', 'true'], + repoDir, + ); + assert.equal(configResult.status, 0, configResult.stderr || configResult.stdout); + const hookResult = runCmd( 'bash', [