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
35 changes: 24 additions & 11 deletions .claude/hooks/skill_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,24 @@ def emit_event(*_a: object, **_k: object) -> None:
SHELL_ENV_PREFIX_RE = re.compile(r"^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+")
SHELL_ALLOWED_SEGMENTS = (
re.compile(r"^(?:cd|pwd|true|false|echo|printf|export|unset|set(?:\s+-[A-Za-z-]+)?)\b"),
re.compile(r"^git\s+(?:status|rev-parse|symbolic-ref|branch|log|show|diff|fetch|remote|config\s+--get|worktree\s+list|ls-files|submodule\s+status)\b"),
re.compile(r"^git\s+(?:status|rev-parse|symbolic-ref|branch|log|show|diff|fetch|remote|config\s+--get|worktree\s+list|ls-files|submodule\s+status|stash\s+(?:list|show))\b"),
# Safe sync: fast-forward / rebase pulls cannot move primary onto a divergent state.
re.compile(r"^git\s+pull(?:\s+--ff-only|\s+--rebase|\s+origin\s+\S+)?\s*$"),
re.compile(r"^git\s+pull\s+--ff-only(?:\s+\S+){0,2}\s*$"),
re.compile(r"^git\s+pull\s+--rebase(?:\s+\S+){0,2}\s*$"),
# Pushing agent/* branches from any cwd is safe — guarded branch namespace.
re.compile(r"^git\s+push(?:\s+(?:-u|--set-upstream))?\s+\S+\s+agent/[^\s]+(?:\s|$)"),
re.compile(r"^git\s+push(?:\s+(?:-u|--set-upstream))?\s+\S+\s+HEAD:agent/[^\s]+(?:\s|$)"),
re.compile(
r"^gh\s+(?:auth\s+status|repo\s+view|pr\s+(?:list|view|checks|status)|issue\s+(?:list|view|status)|run\s+(?:list|view))\b"
r"^gh\s+(?:auth\s+status|repo\s+view|pr\s+(?:list|view|checks|status|create|edit|comment|review|ready|reopen|merge)|issue\s+(?:list|view|status|create|comment)|run\s+(?:list|view|watch)|workflow\s+(?:list|view|run))\b"
),
re.compile(r"^git\s+(?:checkout|switch)\s+agent/[^\s]+(?:\s|$)"),
re.compile(r"^(?:ls|cat|head|tail|wc|nl|sed\s+-n|rg|find|stat|du|df|ps|ss|which|command\s+-v)\b"),
re.compile(r"^(?:guardex|guardex)\s+(?:status|scan)\b"),
# All gitguardex CLI subcommands are themselves safety-aware; trust them on protected branches.
re.compile(r"^(?:gx|guardex|gitguardex|multiagent-safety)\s+\S+\b"),
re.compile(r"^python3?\s+scripts/(?:agent-file-locks\.py|main_rs_lock\.py)\s+(?:status|list|validate)\b"),
re.compile(
r"^(?:bash\s+)?(?:(?:\.{1,2}/)?scripts|(?:/|~)[^\s]*/scripts)/(?:agent-branch-start\.sh|codex-agent\.sh|install-agent-git-hooks\.sh)\b"
r"^(?:bash\s+)?(?:(?:\.{1,2}/)?scripts|(?:/|~)[^\s]*/scripts)/(?:agent-branch-start\.sh|agent-branch-finish\.sh|agent-pivot\.sh|codex-agent\.sh|install-agent-git-hooks\.sh)\b"
),
)

Expand Down Expand Up @@ -288,11 +296,13 @@ def ensure_protected_branch_edit_allowed(file_path: str) -> str | None:

return (
f"BLOCKED: Agent edit attempted on {blocked_scope}.\n"
"Agent edits must run from isolated agent/* branches.\n"
"Create a sandbox branch/worktree first:\n"
"Auto-pivot to an isolated agent worktree (single command, dirty tree migrates with you):\n"
' gx pivot "<task>" "<agent-name>"\n'
"Then `cd` into the printed WORKTREE_PATH and retry the edit.\n"
"Equivalent legacy form:\n"
' bash scripts/agent-branch-start.sh "<task>" "<agent-name>"\n'
"If you intentionally need a one-off protected-branch edit, set:\n"
f" {PROTECTED_BRANCH_EDIT_OVERRIDE_ENV}=1"
"Override (must be exported in the harness env, not as a command prefix):\n"
f" export {PROTECTED_BRANCH_EDIT_OVERRIDE_ENV}=1"
)


Expand Down Expand Up @@ -392,11 +402,14 @@ def ensure_non_agent_shell_command_allowed(repo_root: Path, command: str) -> str
preview = command.strip().splitlines()[0][:180]
return (
f"BLOCKED: Shell command may mutate files on {blocked_scope}.\n"
"Start isolated agent work first:\n"
"Auto-pivot to an isolated agent worktree (single command, dirty tree migrates with you):\n"
' gx pivot "<task>" "<agent-name>"\n'
"Then `cd` into the printed WORKTREE_PATH and retry from there.\n"
"Equivalent legacy form:\n"
' bash scripts/agent-branch-start.sh "<task>" "<agent-name>"\n'
f"Command preview: {preview}\n"
"Temporary bypass (not recommended):\n"
f" {SHELL_GUARD_OVERRIDE_ENV}=1"
"Override (must be exported in the harness env, not as a command prefix):\n"
f" export {SHELL_GUARD_OVERRIDE_ENV}=1"
)


Expand Down
35 changes: 24 additions & 11 deletions .codex/hooks/skill_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,24 @@ def emit_event(*_a: object, **_k: object) -> None:
SHELL_ENV_PREFIX_RE = re.compile(r"^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+")
SHELL_ALLOWED_SEGMENTS = (
re.compile(r"^(?:cd|pwd|true|false|echo|printf|export|unset|set(?:\s+-[A-Za-z-]+)?)\b"),
re.compile(r"^git\s+(?:status|rev-parse|symbolic-ref|branch|log|show|diff|fetch|remote|config\s+--get|worktree\s+list|ls-files|submodule\s+status)\b"),
re.compile(r"^git\s+(?:status|rev-parse|symbolic-ref|branch|log|show|diff|fetch|remote|config\s+--get|worktree\s+list|ls-files|submodule\s+status|stash\s+(?:list|show))\b"),
# Safe sync: fast-forward / rebase pulls cannot move primary onto a divergent state.
re.compile(r"^git\s+pull(?:\s+--ff-only|\s+--rebase|\s+origin\s+\S+)?\s*$"),
re.compile(r"^git\s+pull\s+--ff-only(?:\s+\S+){0,2}\s*$"),
re.compile(r"^git\s+pull\s+--rebase(?:\s+\S+){0,2}\s*$"),
# Pushing agent/* branches from any cwd is safe — guarded branch namespace.
re.compile(r"^git\s+push(?:\s+(?:-u|--set-upstream))?\s+\S+\s+agent/[^\s]+(?:\s|$)"),
re.compile(r"^git\s+push(?:\s+(?:-u|--set-upstream))?\s+\S+\s+HEAD:agent/[^\s]+(?:\s|$)"),
re.compile(
r"^gh\s+(?:auth\s+status|repo\s+view|pr\s+(?:list|view|checks|status)|issue\s+(?:list|view|status)|run\s+(?:list|view))\b"
r"^gh\s+(?:auth\s+status|repo\s+view|pr\s+(?:list|view|checks|status|create|edit|comment|review|ready|reopen|merge)|issue\s+(?:list|view|status|create|comment)|run\s+(?:list|view|watch)|workflow\s+(?:list|view|run))\b"
),
re.compile(r"^git\s+(?:checkout|switch)\s+agent/[^\s]+(?:\s|$)"),
re.compile(r"^(?:ls|cat|head|tail|wc|nl|sed\s+-n|rg|find|stat|du|df|ps|ss|which|command\s+-v)\b"),
re.compile(r"^(?:guardex|guardex)\s+(?:status|scan)\b"),
# All gitguardex CLI subcommands are themselves safety-aware; trust them on protected branches.
re.compile(r"^(?:gx|guardex|gitguardex|multiagent-safety)\s+\S+\b"),
re.compile(r"^python3?\s+scripts/(?:agent-file-locks\.py|main_rs_lock\.py)\s+(?:status|list|validate)\b"),
re.compile(
r"^(?:bash\s+)?(?:(?:\.{1,2}/)?scripts|(?:/|~)[^\s]*/scripts)/(?:agent-branch-start\.sh|codex-agent\.sh|install-agent-git-hooks\.sh)\b"
r"^(?:bash\s+)?(?:(?:\.{1,2}/)?scripts|(?:/|~)[^\s]*/scripts)/(?:agent-branch-start\.sh|agent-branch-finish\.sh|agent-pivot\.sh|codex-agent\.sh|install-agent-git-hooks\.sh)\b"
),
)

Expand Down Expand Up @@ -288,11 +296,13 @@ def ensure_protected_branch_edit_allowed(file_path: str) -> str | None:

return (
f"BLOCKED: Agent edit attempted on {blocked_scope}.\n"
"Agent edits must run from isolated agent/* branches.\n"
"Create a sandbox branch/worktree first:\n"
"Auto-pivot to an isolated agent worktree (single command, dirty tree migrates with you):\n"
' gx pivot "<task>" "<agent-name>"\n'
"Then `cd` into the printed WORKTREE_PATH and retry the edit.\n"
"Equivalent legacy form:\n"
' bash scripts/agent-branch-start.sh "<task>" "<agent-name>"\n'
"If you intentionally need a one-off protected-branch edit, set:\n"
f" {PROTECTED_BRANCH_EDIT_OVERRIDE_ENV}=1"
"Override (must be exported in the harness env, not as a command prefix):\n"
f" export {PROTECTED_BRANCH_EDIT_OVERRIDE_ENV}=1"
)


Expand Down Expand Up @@ -392,11 +402,14 @@ def ensure_non_agent_shell_command_allowed(repo_root: Path, command: str) -> str
preview = command.strip().splitlines()[0][:180]
return (
f"BLOCKED: Shell command may mutate files on {blocked_scope}.\n"
"Start isolated agent work first:\n"
"Auto-pivot to an isolated agent worktree (single command, dirty tree migrates with you):\n"
' gx pivot "<task>" "<agent-name>"\n'
"Then `cd` into the printed WORKTREE_PATH and retry from there.\n"
"Equivalent legacy form:\n"
' bash scripts/agent-branch-start.sh "<task>" "<agent-name>"\n'
f"Command preview: {preview}\n"
"Temporary bypass (not recommended):\n"
f" {SHELL_GUARD_OVERRIDE_ENV}=1"
"Override (must be exported in the harness env, not as a command prefix):\n"
f" export {SHELL_GUARD_OVERRIDE_ENV}=1"
)


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-27
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
## Why

Claude Code (and Codex) sessions could not recover from a protected-branch
mutation block on their own. The PreToolUse `skill_guard` hook blocks `Edit`,
`Write`, and most `Bash` mutations on `dev`/`main`/`master`, and the documented
escape was to set `ALLOW_BASH_ON_NON_AGENT_BRANCH=1` /
`ALLOW_CODE_EDIT_ON_PROTECTED_BRANCH=1` in the harness env. AI agents cannot
mutate harness env from inside a tool call, so they were forced to stop and ask
the user to run shell commands manually — every time the human pivoted between
`main`/`dev` and an agent branch, or wanted Claude to keep going across a
PR/merge cycle.

The whitelist also rejected safe sync ops (`git pull --ff-only`, `git stash
list`, agent-only `git push`, `gh pr create/merge`, and direct `gx`
subcommands), which made even pure-read recovery commands fail.

## What Changes

- Widen `SHELL_ALLOWED_SEGMENTS` in `.claude/hooks/skill_guard.py` and
`.codex/hooks/skill_guard.py` to allow:
- `git pull` / `git pull --ff-only [...]` / `git pull --rebase [...]` (safe
fast-forward sync of the protected branch the user is on).
- `git stash list` and `git stash show` (read-only stash inspection).
- `git push [origin] agent/...` and `git push HEAD:agent/...` (only the
`agent/*` ref namespace is permitted from primary).
- The full `gh pr` / `gh issue` / `gh workflow` action surface (PR ops are
safe — they affect remote, not local files).
- Any `gx` / `guardex` / `gitguardex` / `multiagent-safety` subcommand (the
CLI itself enforces guardrails internally).
- Direct invocation of `scripts/agent-branch-finish.sh` and
`scripts/agent-pivot.sh`.
- Update both `BLOCKED` messages (`ensure_protected_branch_edit_allowed`,
`ensure_non_agent_shell_command_allowed`) to point Claude at a single
copy-pastable command (`gx pivot "<task>" "<agent-name>"`) that does the
whole hop — branch + worktree creation, dirty-tree migration, and a clean
machine-parseable trailer (`WORKTREE_PATH=...`, `BRANCH=...`, `NEXT_STEP=cd
"..."`) the agent can parse to know exactly where to `cd`.
- Add `gx pivot "<task>" "<agent>" [--tier T0|T1|T2|T3]`. On a protected
branch, it forwards to `agent-branch-start.sh` (which already migrates dirty
changes), then echoes the trailer. On an existing `agent/*` branch it
short-circuits with the current worktree path — safe to call as a no-op.
- Add `gx ship` — alias for `gx finish --via-pr --wait-for-merge --cleanup`,
injecting any of those flags the caller forgot. Encodes the
"Default Claude finish (non-negotiable)" rule from `AGENTS.md` so AI agents
cannot accidentally strand commits or worktrees.

## Impact

- Affects: PreToolUse hook regex + block messages (Claude + Codex variants),
`gx` CLI dispatch (new `pivot` and `ship` subcommands), help output, and
command-suggestion list.
- Risk: hook regex change is additive — it only widens the allow list. No
previously-allowed command becomes blocked. New writable patterns
(`git pull --ff-only`, `git push origin agent/...`, `gh pr create/merge`,
`gx <subcommand>`) are scoped so they cannot mutate protected branches
directly.
- Rollout: ship as a normal `gx` patch release; downstream repos pick the
hook change up via `gx setup --repair` (hooks live under `.claude/hooks/`
and `.codex/hooks/`, not in templates yet — follow-up: copy-on-setup).
- Coverage: new `test/pivot.test.js` covers protected-branch -> agent
worktree pivot and the existing-worktree short-circuit. Whitelist regex is
exercised inline by a Python self-test.
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
## ADDED Requirements

### Requirement: `gx pivot` provides a single-tool-call escape from a protected branch

The system SHALL expose a `gx pivot "<task>" "<agent>" [--tier T0|T1|T2|T3]`
subcommand that AI agents can call from a protected (`dev`/`main`/`master`) or
non-`agent/*` branch to obtain an isolated agent worktree.

#### Scenario: Pivot from a protected branch

- **WHEN** `gx pivot "<task>" "<agent>"` is invoked on a non-`agent/*` branch
- **THEN** the command SHALL forward to the existing `agent-branch-start.sh`
flow (which migrates dirty primary-tree changes via auto-stash) and create a
new `agent/<role>/<slug>` branch + worktree
- **AND** stdout SHALL include three machine-parseable trailer lines:
`WORKTREE_PATH=<absolute-worktree-path>`, `BRANCH=<agent-branch-name>`, and
`NEXT_STEP=cd "<absolute-worktree-path>"`
- **AND** the exit code SHALL be `0`.

#### Scenario: Pivot is a no-op on an existing agent branch

- **WHEN** `gx pivot` is invoked from inside an `agent/*` worktree
- **THEN** the command SHALL print `Already on agent branch '<name>'.` plus
the same `WORKTREE_PATH=` / `BRANCH=` / `NEXT_STEP=cd "..."` trailer pointing
at the current worktree
- **AND** the command SHALL NOT create a new branch or worktree
- **AND** the exit code SHALL be `0`.

### Requirement: `gx ship` defaults to the canonical "I am done" finish flags

The system SHALL expose a `gx ship` subcommand that aliases `gx finish` while
ensuring `--via-pr`, `--wait-for-merge`, and `--cleanup` are always present.

#### Scenario: Missing flags are injected

- **WHEN** `gx ship --branch agent/claude/foo` is invoked
- **THEN** `gx finish` SHALL receive `--branch agent/claude/foo --via-pr
--wait-for-merge --cleanup`
- **AND** flags already supplied by the caller SHALL NOT be duplicated.

### Requirement: `skill_guard` allows safe sync ops on protected branches

The system SHALL allow the following commands to run from non-`agent/*`
branches without setting `ALLOW_BASH_ON_NON_AGENT_BRANCH=1`:

- `git pull`, `git pull --ff-only [...]`, `git pull --rebase [...]`
- `git stash list`, `git stash show`
- `git push [origin] agent/<name>` and `git push [origin] HEAD:agent/<name>`
(only the `agent/*` ref namespace)
- `gh pr {list,view,checks,status,create,edit,comment,review,ready,reopen,merge}`,
`gh issue {list,view,status,create,comment}`,
`gh run {list,view,watch}`, `gh workflow {list,view,run}`
- Any subcommand of `gx`, `guardex`, `gitguardex`, or `multiagent-safety`
- `bash scripts/agent-branch-finish.sh ...`,
`bash scripts/agent-pivot.sh ...`

#### Scenario: Pure sync command on protected branch is allowed

- **WHEN** the current branch is `main` and `git pull --ff-only origin main` is
invoked through Claude Code's `Bash` tool
- **THEN** the `skill_guard` PreToolUse hook SHALL exit `0` without printing a
`BLOCKED:` message.

#### Scenario: Destructive command on protected branch is still blocked

- **WHEN** the current branch is `main` and `git reset --hard HEAD` is invoked
through Claude Code's `Bash` tool
- **THEN** the `skill_guard` PreToolUse hook SHALL exit `2` with a `BLOCKED:`
message that points the agent at `gx pivot "<task>" "<agent-name>"`.

### Requirement: BLOCKED messages name the auto-pivot escape first

The system SHALL update both `ensure_protected_branch_edit_allowed` (Edit /
Write / patch tools) and `ensure_non_agent_shell_command_allowed` (Bash) to
mention `gx pivot "<task>" "<agent-name>"` as the recommended single-tool-call
recovery, and clarify that the override env (`ALLOW_BASH_ON_NON_AGENT_BRANCH`,
`ALLOW_CODE_EDIT_ON_PROTECTED_BRANCH`) must be exported in the harness env,
not as a command prefix inside a tool call.

#### Scenario: Block message instructs `gx pivot`

- **WHEN** Claude Code attempts an `Edit` on a protected branch
- **THEN** the `BLOCKED:` message SHALL contain the literal substring
`gx pivot "<task>" "<agent-name>"` and the literal substring `export `
prefixing the override env name.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
## Definition of Done

This change is complete only when **all** of the following are true:

- Every checkbox below is checked.
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.

## Handoff

- Handoff: change=`agent-claude-harden-claude-pivot-2026-04-27-09-28`; branch=`agent/claude/harden-claude-pivot-2026-04-27-09-28`; scope=`Hook whitelist + gx pivot / gx ship`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
- Copy prompt: Continue `agent-claude-harden-claude-pivot-2026-04-27-09-28` on branch `agent/claude/harden-claude-pivot-2026-04-27-09-28`. Work inside the existing sandbox, review `openspec/changes/agent-claude-harden-claude-pivot-2026-04-27-09-28/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/claude/harden-claude-pivot-2026-04-27-09-28 --base main --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-harden-claude-pivot-2026-04-27-09-28`.
- [x] 1.2 Define normative requirements in `specs/general-behavior/spec.md`.

## 2. Implementation

- [x] 2.1 Widen `SHELL_ALLOWED_SEGMENTS` in `.claude/hooks/skill_guard.py` and `.codex/hooks/skill_guard.py` to allow safe sync ops, agent-only push, full `gh pr` surface, `gx <subcommand>`, and `agent-branch-finish.sh` / `agent-pivot.sh`.
- [x] 2.2 Update both BLOCKED messages to point at `gx pivot "<task>" "<agent>"` as the single-tool-call escape and clarify the override env must be exported in the harness, not as a command prefix.
- [x] 2.3 Add `gx pivot` CLI command in `src/cli/main.js` (forwards to `branchStart`; emits `WORKTREE_PATH=` / `BRANCH=` / `NEXT_STEP=` trailer; short-circuits on existing `agent/*` branches).
- [x] 2.4 Add `gx ship` CLI command (alias for `gx finish --via-pr --wait-for-merge --cleanup`, injects missing flags).
- [x] 2.5 Register `pivot` + `ship` in `SUGGESTIBLE_COMMANDS` and the `Branch workflow` help group in `src/context.js`.

## 3. Verification

- [x] 3.1 Run `node --test test/pivot.test.js` (2 new tests: protected-branch pivot + agent-branch short-circuit).
- [x] 3.2 Run inline regex self-test (19/19 cases pass for whitelist allow/deny).
- [x] 3.3 Run `npm test` baseline (without `CLAUDECODE` env): 277 pass / 2 pre-existing failures unrelated to this change (`agent-branch-finish auto-commits parent gitlink after nested repo finish`, `setup refreshes initialized protected main through a sandbox and prunes it` — caused by submodule timing and system git's lack of `worktree --orphan`).
- [x] 3.4 Run `openspec validate agent-claude-harden-claude-pivot-2026-04-27-09-28 --type change --strict`.
- [x] 3.5 Run `openspec validate --specs`.

## 4. Cleanup (mandatory; run before claiming completion)

- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/claude/harden-claude-pivot-2026-04-27-09-28 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).
Loading
Loading