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
9 changes: 9 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
35 changes: 21 additions & 14 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
Expand Down Expand Up @@ -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 ..."
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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)));
Expand Down
1 change: 1 addition & 0 deletions scripts/agent-file-locks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions templates/githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions templates/githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
Expand Down Expand Up @@ -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[*]}"
Expand Down
1 change: 1 addition & 0 deletions templates/scripts/agent-file-locks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
75 changes: 67 additions & 8 deletions test/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
}

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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/);
Expand Down Expand Up @@ -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);
Expand All @@ -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/);
Expand Down Expand Up @@ -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);

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

Expand All @@ -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', () => {
Expand Down
Loading