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
20 changes: 17 additions & 3 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}"
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
if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" ]]; then
is_vscode_git_context=1
fi

Expand Down Expand Up @@ -55,6 +55,15 @@ for protected_branch in $protected_branches_raw; do
fi
done

is_local_only_branch=0
if [[ "$is_protected_branch" == "1" ]]; then
upstream_ref="$(git for-each-ref --format='%(upstream:short)' "refs/heads/${branch}" | head -n 1)"
remote_branch_ref="$(git for-each-ref --format='%(refname:short)' "refs/remotes/*/${branch}" | head -n 1)"
if [[ -z "$upstream_ref" && -z "$remote_branch_ref" ]]; then
is_local_only_branch=1
fi
fi

codex_require_agent_branch_raw="${MUSAFETY_CODEX_REQUIRE_AGENT_BRANCH:-$(git config --get multiagent.codexRequireAgentBranch || true)}"
if [[ -z "$codex_require_agent_branch_raw" ]]; then
codex_require_agent_branch_raw="true"
Expand Down Expand Up @@ -124,8 +133,10 @@ MSG
fi

if [[ "$is_protected_branch" == "1" ]]; then
if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then
exit 0
if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then
if [[ "$allow_vscode_protected_branch_writes" == "1" || "$is_local_only_branch" == "1" ]]; then
exit 0
fi
fi

if [[ "$is_unborn_branch" == "1" && "$is_codex_session" != "1" ]]; then
Expand All @@ -147,6 +158,9 @@ After finishing work:
Optional repo override for manual VS Code protected-branch commits:
git config multiagent.allowVscodeProtectedBranchWrites true

VS Code Source Control commits on protected local-only branches
(no upstream and no remote branch) are allowed automatically.

Temporary bypass (not recommended):
ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ...
MSG
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,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.
- Direct commits/pushes to protected branches are blocked by default (including VS Code Source Control).
- Direct commits/pushes to protected branches are blocked by default.
- Exception: VS Code Source Control commits are allowed on protected branches that exist only locally (no upstream and no remote branch).
- 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.
Expand Down
20 changes: 17 additions & 3 deletions templates/githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}"
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
if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" ]]; then
is_vscode_git_context=1
fi

Expand Down Expand Up @@ -55,6 +55,15 @@ for protected_branch in $protected_branches_raw; do
fi
done

is_local_only_branch=0
if [[ "$is_protected_branch" == "1" ]]; then
upstream_ref="$(git for-each-ref --format='%(upstream:short)' "refs/heads/${branch}" | head -n 1)"
remote_branch_ref="$(git for-each-ref --format='%(refname:short)' "refs/remotes/*/${branch}" | head -n 1)"
if [[ -z "$upstream_ref" && -z "$remote_branch_ref" ]]; then
is_local_only_branch=1
fi
fi

codex_require_agent_branch_raw="${MUSAFETY_CODEX_REQUIRE_AGENT_BRANCH:-$(git config --get multiagent.codexRequireAgentBranch || true)}"
if [[ -z "$codex_require_agent_branch_raw" ]]; then
codex_require_agent_branch_raw="true"
Expand Down Expand Up @@ -124,8 +133,10 @@ MSG
fi

if [[ "$is_protected_branch" == "1" ]]; then
if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then
exit 0
if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then
if [[ "$allow_vscode_protected_branch_writes" == "1" || "$is_local_only_branch" == "1" ]]; then
exit 0
fi
fi

if [[ "$is_unborn_branch" == "1" && "$is_codex_session" != "1" ]]; then
Expand All @@ -147,6 +158,9 @@ After finishing work:
Optional repo override for manual VS Code protected-branch commits:
git config multiagent.allowVscodeProtectedBranchWrites true

VS Code Source Control commits on protected local-only branches
(no upstream and no remote branch) are allowed automatically.

Temporary bypass (not recommended):
ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ...
MSG
Expand Down
77 changes: 76 additions & 1 deletion test/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1153,9 +1153,10 @@ test('protect command manages configured protected branches', () => {
assert.match(result.stdout, /reset to defaults/);
});

test('pre-commit blocks non-codex VS Code commits on custom protected branches by default', () => {
test('pre-commit blocks non-codex VS Code commits on custom protected branches by default when branch has remote counterpart', () => {
const repoDir = initRepoOnBranch('release');
seedCommit(repoDir);
attachOriginRemoteForBranch(repoDir, 'release');

let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
Expand All @@ -1174,6 +1175,7 @@ test('pre-commit blocks non-codex VS Code commits on custom protected branches b
test('pre-commit blocks non-codex protected branch commits from VS Code Source Control env by default', () => {
const repoDir = initRepo();
seedCommit(repoDir);
attachOriginRemote(repoDir);

const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout);
Expand All @@ -1193,6 +1195,50 @@ test('pre-commit blocks non-codex protected branch commits from VS Code Source C
assert.match(hookResult.stderr, /\[agent-branch-guard\] Direct commits on protected branches are blocked\./);
});

test('pre-commit allows non-codex VS Code commits on protected local-only branches', () => {
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, 0, hookResult.stderr || hookResult.stdout);
});

test('pre-commit blocks codex commits on protected local-only branches even from VS Code Source Control env', () => {
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',
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, /\[guardex-preedit-guard\] Codex edit\/commit detected on a protected branch\./);
});

test('pre-push blocks non-codex protected branch pushes from VS Code Source Control env by default', () => {
const repoDir = initRepoOnBranch('main');
seedCommit(repoDir);
Expand Down Expand Up @@ -1220,6 +1266,7 @@ test('pre-push blocks non-codex protected branch pushes from VS Code Source Cont
test('pre-commit allows non-codex protected branch commits from VS Code Source Control env when explicitly enabled', () => {
const repoDir = initRepo();
seedCommit(repoDir);
attachOriginRemote(repoDir);

const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout);
Expand All @@ -1245,6 +1292,34 @@ 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-commit does not treat TERM_PROGRAM=vscode as VS Code Source Control context', () => {
const repoDir = initRepo();
seedCommit(repoDir);
attachOriginRemote(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'],
repoDir,
{
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0',
TERM_PROGRAM: 'vscode',
},
);
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 allows non-codex protected branch pushes from VS Code Source Control env when explicitly enabled', () => {
const repoDir = initRepoOnBranch('main');
seedCommit(repoDir);
Expand Down
Loading