diff --git a/.githooks/pre-commit b/.githooks/pre-commit index ce25c77..b3227d6 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -2,6 +2,9 @@ set -euo pipefail branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ -z "$branch" || "$branch" == "HEAD" ]]; then + branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" +fi if [[ -z "$branch" ]]; then exit 0 fi @@ -10,18 +13,15 @@ if [[ "${ALLOW_COMMIT_ON_PROTECTED_BRANCH:-0}" == "1" ]]; then exit 0 fi -is_vscode_git_context=0 -if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" ]]; then - is_vscode_git_context=1 +is_unborn_branch=0 +if ! git rev-parse --verify HEAD >/dev/null 2>&1; then + is_unborn_branch=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 +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)}" if [[ -z "$protected_branches_raw" ]]; then @@ -37,8 +37,54 @@ for protected_branch in $protected_branches_raw; do fi done +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" +fi +codex_require_agent_branch="$(printf '%s' "$codex_require_agent_branch_raw" | tr '[:upper:]' '[:lower:]')" + +should_require_codex_agent_branch=0 +case "$codex_require_agent_branch" in + 1|true|yes|on) should_require_codex_agent_branch=1 ;; + 0|false|no|off) should_require_codex_agent_branch=0 ;; + *) should_require_codex_agent_branch=1 ;; +esac + +if [[ "$should_require_codex_agent_branch" == "1" && "${MUSAFETY_ALLOW_CODEX_ON_NON_AGENT:-0}" != "1" ]]; then + if [[ "$is_codex_session" == "1" && "$branch" != agent/* ]]; then + if [[ "$is_protected_branch" == "1" ]]; then + cat >&2 <<'MSG' +[guardex-preedit-guard] Codex edit/commit detected on a protected branch. +GuardeX requires Codex work to run from an isolated agent/* branch. +Start the sub-branch/worktree with: + bash scripts/codex-agent.sh "" "" +Or manually: + bash scripts/agent-branch-start.sh "" "" +Then commit from the created agent/* branch. + +Temporary bypass (not recommended): + MUSAFETY_ALLOW_CODEX_ON_NON_AGENT=1 git commit ... +MSG + exit 1 + fi + + cat >&2 <<'MSG' +[codex-branch-guard] Codex agent commit blocked on non-agent branch. +Use isolated branch/worktree first: + bash scripts/agent-branch-start.sh "" "" +Then commit from the created agent/* branch. + +Temporary bypass (not recommended): + MUSAFETY_ALLOW_CODEX_ON_NON_AGENT=1 git commit ... +Disable this rule for a repo (not recommended): + git config multiagent.codexRequireAgentBranch false +MSG + exit 1 + fi +fi + if [[ "$is_protected_branch" == "1" ]]; then - if [[ "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch" == "1" ]]; then + if [[ "$is_unborn_branch" == "1" && "$is_codex_session" != "1" ]]; then exit 0 fi @@ -54,9 +100,6 @@ Use an agent branch first: After finishing work: bash scripts/agent-branch-finish.sh -Optional repo override to allow VS Code Source Control commits: - git config multiagent.protectedBranches.allowVSCode true - Temporary bypass (not recommended): ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... MSG diff --git a/README.md b/README.md index 4ddde96..978c488 100644 --- a/README.md +++ b/README.md @@ -111,11 +111,13 @@ By default this writes: #### Real VS Code Source Control example (after `gx setup`) +![GuardeX real VS Code Source Control layout](./docs/images/workflow-vscode-guardex-real.png) + This is the exact layout you should expect in VS Code Source Control after setup and a few `agent-branch-start` runs: ```text -multiagent-safety (main) +GuardeX (your preferred local branch: main/dev) agent_codex_-- agent_bot_-- agent_bot_-- @@ -347,6 +349,7 @@ multiagent.protectedBranches - direct commits to protected branches (defaults: `dev`, `main`, `master`; configurable via `gx protect ...`) - protected-branch commits are blocked regardless of commit client (including VS Code Source Control) - Codex-session commits on non-`agent/*` branches are blocked by default (`multiagent.codexRequireAgentBranch=true`) +- Codex commits attempted on protected branches trigger `guardex-preedit-guard` and require starting work via `scripts/codex-agent.sh` - overlapping file ownership between agents - unapproved deletions of claimed files - risky stale/missing lock state diff --git a/docs/images/workflow-vscode-guardex-real.png b/docs/images/workflow-vscode-guardex-real.png new file mode 100644 index 0000000..da04bd0 Binary files /dev/null and b/docs/images/workflow-vscode-guardex-real.png differ diff --git a/templates/githooks/pre-commit b/templates/githooks/pre-commit index 3ee6228..b3227d6 100755 --- a/templates/githooks/pre-commit +++ b/templates/githooks/pre-commit @@ -2,6 +2,9 @@ set -euo pipefail branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ -z "$branch" || "$branch" == "HEAD" ]]; then + branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" +fi if [[ -z "$branch" ]]; then exit 0 fi @@ -10,6 +13,16 @@ if [[ "${ALLOW_COMMIT_ON_PROTECTED_BRANCH:-0}" == "1" ]]; then exit 0 fi +is_unborn_branch=0 +if ! git rev-parse --verify HEAD >/dev/null 2>&1; then + is_unborn_branch=1 +fi + +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)}" if [[ -z "$protected_branches_raw" ]]; then protected_branches_raw="dev main master" @@ -24,25 +37,6 @@ for protected_branch in $protected_branches_raw; do fi done -if [[ "$is_protected_branch" == "1" ]]; then - git_dir="$(git rev-parse --git-dir)" - if [[ -f "$git_dir/MERGE_HEAD" ]]; then - exit 0 - fi - - cat >&2 <<'MSG' -[agent-branch-guard] Direct commits on protected branches are blocked. -Use an agent branch first: - bash scripts/agent-branch-start.sh "" "" -After finishing work: - bash scripts/agent-branch-finish.sh - -Temporary bypass (not recommended): - ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... -MSG - exit 1 -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" @@ -57,12 +51,23 @@ case "$codex_require_agent_branch" in esac if [[ "$should_require_codex_agent_branch" == "1" && "${MUSAFETY_ALLOW_CODEX_ON_NON_AGENT:-0}" != "1" ]]; then - is_codex_session=0 - if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" == "1" ]]; then - is_codex_session=1 - fi - if [[ "$is_codex_session" == "1" && "$branch" != agent/* ]]; then + if [[ "$is_protected_branch" == "1" ]]; then + cat >&2 <<'MSG' +[guardex-preedit-guard] Codex edit/commit detected on a protected branch. +GuardeX requires Codex work to run from an isolated agent/* branch. +Start the sub-branch/worktree with: + bash scripts/codex-agent.sh "" "" +Or manually: + bash scripts/agent-branch-start.sh "" "" +Then commit from the created agent/* branch. + +Temporary bypass (not recommended): + MUSAFETY_ALLOW_CODEX_ON_NON_AGENT=1 git commit ... +MSG + exit 1 + fi + cat >&2 <<'MSG' [codex-branch-guard] Codex agent commit blocked on non-agent branch. Use isolated branch/worktree first: @@ -78,6 +83,29 @@ MSG fi fi +if [[ "$is_protected_branch" == "1" ]]; then + if [[ "$is_unborn_branch" == "1" && "$is_codex_session" != "1" ]]; then + exit 0 + fi + + git_dir="$(git rev-parse --git-dir)" + if [[ -f "$git_dir/MERGE_HEAD" ]]; then + exit 0 + fi + + cat >&2 <<'MSG' +[agent-branch-guard] Direct commits on protected branches are blocked. +Use an agent branch first: + bash scripts/agent-branch-start.sh "" "" +After finishing work: + bash scripts/agent-branch-finish.sh + +Temporary bypass (not recommended): + ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... +MSG + exit 1 +fi + if [[ "$branch" == agent/* ]]; then if ! python3 scripts/agent-file-locks.py validate --branch "$branch" --staged; then cat >&2 <<'MSG' diff --git a/test/install.test.js b/test/install.test.js index fc9afa7..9898b60 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -259,6 +259,22 @@ 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', () => { + const repoDir = initRepoOnBranch('main'); + + let result = runNode(['setup', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + fs.writeFileSync(path.join(repoDir, 'notes-main.txt'), 'hello from main\n', 'utf8'); + 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' }); + 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/); +}); + test('setup agent-branch-start requires --allow-in-place when using --in-place', () => { const repoDir = initRepo();