Skip to content

Commit a559568

Browse files
NagyViktNagyVikt
andauthored
chore(hooks): dedupe .claude/.codex hooks and soften skill_guard (#594)
- .claude/hooks/* is now canonical; .codex/hooks/* are relative symlinks pointing at the canonical files (HOOKS.md documents the layout). - skill_guard.py: add GUARDEX_AGENT_BRANCH_PREFIXES env var so users can recognize session-managed branches (claude/*, codex/*, ...) without forking the hook. agent/ stays in the always-on default set. - skill_guard.py: extend the read-only allowlist with version probes (node/python/etc --version) so simple inspection no longer requires ALLOW_BASH_ON_NON_AGENT_BRANCH=1. - Add test/skill-guard-hook.test.js covering the allow/block matrix on main and agent/* and the env-override behavior. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent e403486 commit a559568

11 files changed

Lines changed: 378 additions & 1082 deletions

File tree

.claude/hooks/HOOKS.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Hooks (Claude + Codex)
2+
3+
`.claude/hooks/` is the canonical source for the Python hook scripts:
4+
5+
- `post_edit_tracker.py`
6+
- `skill_activation.py`
7+
- `skill_guard.py`
8+
- `skill_tracker.py`
9+
10+
`.codex/hooks/` contains relative symlinks back to the canonical files so
11+
both harnesses execute the same code. Edit only the files under
12+
`.claude/hooks/`; never edit through the `.codex/hooks/` path.
13+
14+
If a future repo target (e.g. Windows) cannot follow symlinks, replace the
15+
symlinks with real copies and add a CI step that asserts their SHA1 matches
16+
the canonical files.
17+
18+
## Configuration
19+
20+
`skill_guard.py` enforces the multi-agent contract on PreToolUse events.
21+
22+
### Env vars
23+
24+
| Var | Purpose |
25+
| --- | --- |
26+
| `ALLOW_BASH_ON_NON_AGENT_BRANCH=1` | Bypass the shell-command guard. |
27+
| `ALLOW_CODE_EDIT_ON_PROTECTED_BRANCH=1` | Bypass the protected-branch edit guard. |
28+
| `ALLOW_CODE_EDIT_ON_PRIMARY_WORKTREE=1` | Allow agent edits in the primary worktree. |
29+
| `GUARDEX_AGENT_BRANCH_PREFIXES` | Extra branch prefixes (comma- or space-separated) that count as agent-managed. `agent/` is always recognized. Example: `GUARDEX_AGENT_BRANCH_PREFIXES="claude/,codex/"`. |
30+
| `GUARDEX_ON` | Repo toggle. Falsy values disable every guard. |
31+
| `GUARDEX_PROTECTED_BRANCHES` | Additional protected branch names (comma- or space-separated). |
32+
33+
### Read-only allowlist
34+
35+
Even on non-agent / protected branches, `skill_guard.py` lets through
36+
read-only shell commands so simple inspection (`git status`, `ls`, `cat`,
37+
`gh pr view`, etc.) does not require setting an override. The full pattern
38+
list lives in `SHELL_ALLOWED_SEGMENTS` inside `skill_guard.py`.
39+
40+
Mutating commands (`rm`, `git checkout main`, `git reset --hard`,
41+
`git push origin main`, redirections that overwrite files, etc.) remain
42+
blocked. To loosen the deny-list, change `skill_guard.py` directly and add
43+
a regression test under `test/skill-guard-hook.test.js`.

.claude/hooks/skill_guard.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ def emit_event(*_a: object, **_k: object) -> None:
2626
PROTECTED_BRANCH_EDIT_OVERRIDE_ENV = "ALLOW_CODE_EDIT_ON_PROTECTED_BRANCH"
2727
SHELL_GUARD_OVERRIDE_ENV = "ALLOW_BASH_ON_NON_AGENT_BRANCH"
2828
PRIMARY_WORKTREE_AGENT_EDIT_OVERRIDE_ENV = "ALLOW_CODE_EDIT_ON_PRIMARY_WORKTREE"
29+
# Extra agent-branch prefixes (comma- or space-separated). The hardcoded
30+
# "agent/" prefix is always recognized; this env var adds session-managed
31+
# prefixes like "claude/" without forcing repos to fork the hook.
32+
AGENT_BRANCH_PREFIXES_ENV = "GUARDEX_AGENT_BRANCH_PREFIXES"
33+
DEFAULT_AGENT_BRANCH_PREFIXES = ("agent/",)
2934
PATCH_FILE_HEADER_RE = re.compile(
3035
r"^\*\*\* (?:Update|Add|Delete) File:\s+(.+?)\s*$",
3136
re.MULTILINE,
@@ -38,6 +43,9 @@ def emit_event(*_a: object, **_k: object) -> None:
3843
)
3944
SHELL_ALLOWED_SEGMENTS = (
4045
re.compile(r"^(?:cd|pwd|true|false|echo|printf|export|unset|set(?:\s+-[A-Za-z-]+)?)\b"),
46+
# Read-only version probes: harmless, frequent on session start.
47+
re.compile(r"^(?:node|npm|pnpm|yarn|python|python3|ruby|go|java|cargo|rustc|deno|bun)\s+--version\b"),
48+
re.compile(r"^(?:node|npm|pnpm|yarn|python|python3|ruby|go|java|cargo|rustc|deno|bun)\s+-v\b"),
4149
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"),
4250
# Safe sync: fast-forward / rebase pulls cannot move primary onto a divergent state.
4351
re.compile(r"^git\s+pull(?:\s+--ff-only|\s+--rebase|\s+origin\s+\S+)?\s*$"),
@@ -273,6 +281,40 @@ def branch_agent_name(branch: str) -> str:
273281
return ""
274282

275283

284+
def _parse_branch_prefixes(raw: str) -> tuple[str, ...]:
285+
"""Split GUARDEX_AGENT_BRANCH_PREFIXES into normalized prefixes."""
286+
if not raw:
287+
return ()
288+
tokens = [token.strip() for token in re.split(r"[\s,]+", raw) if token.strip()]
289+
normalized: list[str] = []
290+
for token in tokens:
291+
# Ensure trailing slash so "claude" matches "claude/foo" but not
292+
# an unrelated branch named "claudette/foo".
293+
if not token.endswith("/"):
294+
token = token + "/"
295+
normalized.append(token)
296+
return tuple(normalized)
297+
298+
299+
def agent_branch_prefixes() -> tuple[str, ...]:
300+
"""Active agent-branch prefixes: defaults plus GUARDEX_AGENT_BRANCH_PREFIXES."""
301+
extras = _parse_branch_prefixes(os.environ.get(AGENT_BRANCH_PREFIXES_ENV, ""))
302+
seen: set[str] = set()
303+
out: list[str] = []
304+
for prefix in (*DEFAULT_AGENT_BRANCH_PREFIXES, *extras):
305+
if prefix not in seen:
306+
seen.add(prefix)
307+
out.append(prefix)
308+
return tuple(out)
309+
310+
311+
def is_agent_branch(branch: str) -> bool:
312+
"""Treat branch as agent-managed if it matches any active prefix."""
313+
if not branch:
314+
return False
315+
return any(branch.startswith(prefix) for prefix in agent_branch_prefixes())
316+
317+
276318
def is_codex_session() -> bool:
277319
"""Best-effort detection for Codex/OMX automated sessions."""
278320
return bool(
@@ -288,7 +330,7 @@ def ensure_protected_branch_edit_allowed(file_path: str) -> str | None:
288330
return None
289331
repo_root = find_repo_root(file_path)
290332
branch = current_branch(repo_root)
291-
if branch.startswith("agent/"):
333+
if is_agent_branch(branch):
292334
return None
293335

294336
if branch in PROTECTED_BRANCHES:
@@ -423,7 +465,7 @@ def ensure_non_agent_shell_command_allowed(repo_root: Path, command: str) -> str
423465
return None
424466

425467
branch = current_branch(repo_root)
426-
if branch.startswith("agent/"):
468+
if is_agent_branch(branch):
427469
return None
428470
if is_allowed_non_agent_shell_command(command):
429471
return None

.codex/hooks/post_edit_tracker.py

Lines changed: 0 additions & 103 deletions
This file was deleted.

.codex/hooks/post_edit_tracker.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../.claude/hooks/post_edit_tracker.py

.codex/hooks/skill_activation.py

Lines changed: 0 additions & 137 deletions
This file was deleted.

.codex/hooks/skill_activation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../.claude/hooks/skill_activation.py

0 commit comments

Comments
 (0)