claude-code-arcane 2.12.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/agents/visualnovel/vn-producer.md +2 -2
- package/dist/cli.js +250 -275
- package/docs/USER-GUIDE.md +3 -3
- package/docs/collaborative-design.md +1 -1
- package/docs/context-management.md +5 -22
- package/docs/coordination-rules.md +1 -1
- package/docs/directory-structure.md +9 -4
- package/docs/gates/README.md +2 -2
- package/hooks/detect-division.sh +1 -1
- package/hooks/guard.py +445 -0
- package/hooks/guard.sh +18 -0
- package/hooks/log-agent-stop.sh +1 -1
- package/hooks/log-agent.sh +1 -1
- package/hooks/notify.sh +1 -1
- package/hooks/post-compact.sh +1 -1
- package/hooks/pre-compact.sh +1 -1
- package/hooks/session-start.sh +41 -5
- package/hooks/session-stop.sh +5 -1
- package/hooks/validate-commit.sh +11 -3
- package/hooks/worktree-stop.sh +48 -0
- package/package.json +1 -1
- package/profiles/audio.yaml +0 -1
- package/profiles/core.yaml +9 -4
- package/profiles/visual-novel.yaml +0 -1
- package/rules/implementation-workflow.md +24 -0
- package/skills/help/SKILL.md +2 -2
- package/skills/start/SKILL.md +2 -2
- package/skills/story-done/SKILL.md +2 -2
- package/skills/ux-design/SKILL.md +1 -1
- package/hooks/validate-push.sh +0 -21
- package/hooks/validate-secrets.sh +0 -23
package/docs/USER-GUIDE.md
CHANGED
|
@@ -238,10 +238,10 @@ Every installation automatically loads `core.yaml` first:
|
|
|
238
238
|
| Category | Contents |
|
|
239
239
|
|----------|----------|
|
|
240
240
|
| **Skills (21)** | commit, create-pr, changelog, check, code-review, context-prime, help, start, fix-issue, hotfix, brainstorm, scope-check, reverse-document, skill-improve, skill-test, tech-debt, arcane-status, arcane-list, arcane-add, arcane-remove, arcane-clean |
|
|
241
|
-
| **Rules (
|
|
242
|
-
| **Hooks (
|
|
241
|
+
| **Rules (8)** | data-files, prototype-code, test-standards, implementation-workflow, code-comments, verification, agent-delegation, git-worktrees |
|
|
242
|
+
| **Hooks (16)** | session-start, detect-division, detect-unreal, detect-gaps, check-update, guard, validate-commit, validate-assets, validate-skill-change, notify, pre-compact, post-compact, session-stop, worktree-stop, log-agent, log-agent-stop |
|
|
243
243
|
| **Agents (1 dir)** | quality |
|
|
244
|
-
| **Permissions** |
|
|
244
|
+
| **Permissions** | 15 allow (git, ls, gh pr/issue/run and read-only gh, curl) + 13 deny (force push, reset --hard, clean -f, sudo, .env, etc.) |
|
|
245
245
|
|
|
246
246
|
---
|
|
247
247
|
|
|
@@ -311,7 +311,7 @@ Para documentos largos (ADRs, PRDs, GDDs, RFCs) escribí sección por sección a
|
|
|
311
311
|
- Review con el usuario
|
|
312
312
|
- Iterar hasta aprobación
|
|
313
313
|
- Escribir ESA sección al archivo
|
|
314
|
-
- Update
|
|
314
|
+
- Update .claude/session-state/active.md
|
|
315
315
|
- La conversación de esa sección puede compactarse — la decisión ya está en disco
|
|
316
316
|
|
|
317
317
|
3. Si la sesión crashea mid-document:
|
|
@@ -16,7 +16,7 @@ Claude Code tiene una ventana de contexto limitada. Sesiones largas llegan al l
|
|
|
16
16
|
|
|
17
17
|
### Session State File
|
|
18
18
|
|
|
19
|
-
Mantené
|
|
19
|
+
Mantené `.claude/session-state/active.md` como checkpoint vivo. Update después de cada milestone significativo:
|
|
20
20
|
|
|
21
21
|
- Sección de diseño / ADR aprobada y escrita
|
|
22
22
|
- Decisión arquitectónica tomada
|
|
@@ -25,23 +25,6 @@ Mantené `production/session-state/active.md` como checkpoint vivo. Update despu
|
|
|
25
25
|
|
|
26
26
|
El state file debe contener: tarea actual, checklist de progreso, decisiones clave tomadas, archivos en trabajo, preguntas abiertas.
|
|
27
27
|
|
|
28
|
-
### Status Line Block (opcional, para proyectos activos)
|
|
29
|
-
|
|
30
|
-
Para proyectos en fase de ejecución, incluir en `active.md` un bloque estructurado que el status line script puede parsear:
|
|
31
|
-
|
|
32
|
-
```markdown
|
|
33
|
-
<!-- STATUS -->
|
|
34
|
-
Epic: Auth refactor
|
|
35
|
-
Feature: JWT rotation
|
|
36
|
-
Task: Implementar refresh endpoint
|
|
37
|
-
<!-- /STATUS -->
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
- Los tres campos (Epic, Feature, Task) son opcionales — incluí solo lo aplicable
|
|
41
|
-
- Update cuando cambiás de foco
|
|
42
|
-
- El status line lo muestra como breadcrumb: `Auth refactor > JWT rotation > Refresh endpoint`
|
|
43
|
-
- Remove o vaciá cuando no hay work focus activo
|
|
44
|
-
|
|
45
28
|
Después de cualquier disrupción (compaction, crash, `/clear`), leé el state file primero.
|
|
46
29
|
|
|
47
30
|
---
|
|
@@ -71,8 +54,8 @@ Esto mantiene el context window con ~3-5k tokens (solo la sección actual) en ve
|
|
|
71
54
|
|
|
72
55
|
### Compaction Hooks
|
|
73
56
|
|
|
74
|
-
- `pre-compact.sh
|
|
75
|
-
- `
|
|
57
|
+
- `pre-compact.sh` y `post-compact.sh` solo anotan la hora en `.claude/session-state/sessions.log`. No guardan ni cargan estado.
|
|
58
|
+
- Lo que sobrevive a la compactación es el archivo: `session-start.sh` corre también después de cada compactación (SessionStart con matcher vacío) y muestra el inicio de `active.md`. Por eso `active.md` tiene que estar al día **antes** de compactar.
|
|
76
59
|
|
|
77
60
|
---
|
|
78
61
|
|
|
@@ -111,7 +94,7 @@ Los subagents no heredan historia de conversación — dales contexto completo e
|
|
|
111
94
|
|
|
112
95
|
Cuando el contexto se compacta, preservá en el summary:
|
|
113
96
|
|
|
114
|
-
- Referencia a
|
|
97
|
+
- Referencia a `.claude/session-state/active.md` (leer para recover state)
|
|
115
98
|
- Lista de archivos modificados en la sesión y su propósito
|
|
116
99
|
- Decisiones arquitectónicas hechas y su rationale
|
|
117
100
|
- Tasks activas del sprint y estado actual
|
|
@@ -163,7 +146,7 @@ Cuando el status line muestra >= 70%:
|
|
|
163
146
|
```
|
|
164
147
|
Tarea: "Implementá auth JWT completo"
|
|
165
148
|
|
|
166
|
-
1. Crear
|
|
149
|
+
1. Crear .claude/session-state/active.md con:
|
|
167
150
|
- Feature: JWT auth
|
|
168
151
|
- Files to create: src/auth/{tokens.ts, middleware.ts, login.ts}
|
|
169
152
|
- Tests: tests/auth.test.ts
|
|
@@ -71,7 +71,7 @@ Ningún agente hace commits sin instrucción explícita del user. Ni siquiera el
|
|
|
71
71
|
|
|
72
72
|
## Regla #9 — Session State
|
|
73
73
|
|
|
74
|
-
Actualizar
|
|
74
|
+
Actualizar `.claude/session-state/active.md` cuando:
|
|
75
75
|
- Se completa un section/phase
|
|
76
76
|
- Se toma una decisión significativa
|
|
77
77
|
- Se cambia de división/agente
|
|
@@ -97,9 +97,9 @@ claude-code-arcane/
|
|
|
97
97
|
│ ├── statusline.sh # Solo con el profile statusline
|
|
98
98
|
│ ├── output-styles/ # Solo si algún profile declara output_styles
|
|
99
99
|
│ ├── docs/ # Copia de docs/; arcane update la mantiene al día
|
|
100
|
+
│ ├── session-state/ # active.md y logs de sesión; se ignora solo (.gitignore con *)
|
|
100
101
|
│ └── .arcane-trash/ # Lo que arcane update saca; nunca se borra con rm
|
|
101
|
-
|
|
102
|
-
└── production/session-state/ # Estado de sesión que escriben los hooks
|
|
102
|
+
└── .claude.bak/ # Backup de .claude/ antes de instalar
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
Fuera del proyecto, `~/.arcane/` guarda el registro de instalaciones (`installations.json`) y el contenido descargado de GitHub (`cache/<version>/`). `ARCANE_HOME` lo desvía.
|
|
@@ -122,11 +122,16 @@ Los eventos los define `src/installer.ts` al generar `settings.json`, no los pro
|
|
|
122
122
|
| Evento | Hooks |
|
|
123
123
|
|--------|-------|
|
|
124
124
|
| SessionStart | `session-start.sh` → `detect-division.sh` → `detect-unreal.sh` → `detect-gaps.sh` → `check-update.sh` |
|
|
125
|
-
| PreToolUse (`Bash`) | `
|
|
125
|
+
| PreToolUse (`Bash`) | `guard.sh` → `validate-commit.sh` |
|
|
126
|
+
| PreToolUse (`Write\|Edit\|MultiEdit\|NotebookEdit`) | `guard.sh` |
|
|
126
127
|
| PostToolUse (`Write\|Edit`) | `validate-assets.sh`, `validate-skill-change.sh` |
|
|
127
128
|
| Notification | `notify.sh` |
|
|
128
129
|
| PreCompact / PostCompact | `pre-compact.sh` / `post-compact.sh` |
|
|
129
|
-
| Stop | `session-stop.sh` |
|
|
130
|
+
| Stop | `session-stop.sh`, `worktree-stop.sh` |
|
|
130
131
|
| SubagentStart / SubagentStop | `log-agent.sh` / `log-agent-stop.sh` |
|
|
131
132
|
|
|
133
|
+
Cada comando es `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/<script>"`: dentro de un worktree los hooks corren parados en el worktree, que puede no tener `.claude/hooks/` commiteado, y la variable siempre apunta al proyecto donde arrancó la sesión. `arcane update` migra las instalaciones que tenían la ruta relativa.
|
|
134
|
+
|
|
135
|
+
`guard.sh` corre `guard.py` (Python stdlib): deniega lo que el usuario ejecuta a mano (force push, `--no-verify`, `sudo`, escribir `.env`), pregunta antes de lo destructivo (`rm -rf` fuera de carpetas regenerables, borrar datos, una dependencia nueva) y al editar CI, infra, migraciones existentes, manifests o configs de lint. Sin Python no hace nada y quedan solo los permisos estáticos.
|
|
136
|
+
|
|
132
137
|
La statusline (`statusline.sh`) se configura aparte, solo con el profile `statusline`.
|
package/docs/gates/README.md
CHANGED
|
@@ -123,8 +123,8 @@ Después de que un gate resuelve, registrar el verdict en el header de status de
|
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
Para phase gates, registrar en:
|
|
126
|
-
- Software: `docs/architecture/architecture.md` o
|
|
127
|
-
- Gamedev: `docs/architecture/architecture.md` o
|
|
126
|
+
- Software: `docs/architecture/architecture.md` o `.claude/session-state/active.md`
|
|
127
|
+
- Gamedev: `docs/architecture/architecture.md` o `.claude/session-state/active.md`
|
|
128
128
|
|
|
129
129
|
---
|
|
130
130
|
|
package/hooks/detect-division.sh
CHANGED
|
@@ -22,7 +22,7 @@ main() {
|
|
|
22
22
|
DIVISION="engineering (Python)"
|
|
23
23
|
fi
|
|
24
24
|
|
|
25
|
-
[[ -d "
|
|
25
|
+
[[ -d ".claude/session-state" ]] && echo "Division: $DIVISION" > .claude/session-state/division.txt
|
|
26
26
|
}
|
|
27
27
|
main 2>/dev/null
|
|
28
28
|
exit 0
|
package/hooks/guard.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse guard for Bash and file-writing tools.
|
|
3
|
+
|
|
4
|
+
Reads the hook payload on stdin and answers with a permission decision:
|
|
5
|
+
deny - actions the user runs by hand if they really want them (force push,
|
|
6
|
+
bypassing git hooks, sudo, writing .env files ...)
|
|
7
|
+
ask - destructive or supply-chain-sensitive actions (rm -rf outside build
|
|
8
|
+
output, dropping data, a new dependency, editing CI or lint configs ...)
|
|
9
|
+
none - everything else; the normal permission rules decide.
|
|
10
|
+
|
|
11
|
+
The command is parsed quote-aware and split on && || ; | so a chained or
|
|
12
|
+
quoted command cannot hide a match the old grep-based hooks missed. This is a
|
|
13
|
+
guard against mistakes, not a sandbox: a script or an interpreter one-liner
|
|
14
|
+
can still do anything.
|
|
15
|
+
|
|
16
|
+
It replaces validate-push.sh and validate-secrets.sh, whose substring checks
|
|
17
|
+
also fired on text inside arguments. `guard.py --command` only prints the Bash
|
|
18
|
+
command from the payload; validate-commit.sh uses it instead of a regex.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shlex
|
|
26
|
+
import sys
|
|
27
|
+
from fnmatch import fnmatch
|
|
28
|
+
|
|
29
|
+
DENY, ASK, NONE = "deny", "ask", None
|
|
30
|
+
RANK = {DENY: 2, ASK: 1, NONE: 0}
|
|
31
|
+
|
|
32
|
+
# Build output that a command regenerates; rm -rf on these alone never asks.
|
|
33
|
+
REGENERABLE = {
|
|
34
|
+
"node_modules", "dist", "build", ".next", "out", "coverage",
|
|
35
|
+
"__pycache__", ".pytest_cache", "Library", "Temp", "obj",
|
|
36
|
+
"Binaries", "Intermediate", "DerivedDataCache",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
SEPARATORS = {"&&", "||", ";", "|", "&", "(", ")", "|&", ";;"}
|
|
40
|
+
WRAPPERS = {"command", "exec", "time", "nohup", "env", "builtin", "nice"}
|
|
41
|
+
DB_CLIENTS = {
|
|
42
|
+
"psql", "mysql", "mariadb", "sqlite3", "sqlcmd", "mongo", "mongosh",
|
|
43
|
+
"redis-cli", "cockroach", "clickhouse-client", "duckdb",
|
|
44
|
+
}
|
|
45
|
+
DATA_LOSS_SQL = [
|
|
46
|
+
(r"\bDROP\s+(TABLE|DATABASE|SCHEMA)\b", "drops a table, database or schema"),
|
|
47
|
+
(r"\bTRUNCATE\b", "truncates a table"),
|
|
48
|
+
(r"\bFLUSH(ALL|DB)\b", "flushes Redis"),
|
|
49
|
+
(r"\.dropDatabase\s*\(", "drops a MongoDB database"),
|
|
50
|
+
]
|
|
51
|
+
# (programs that run it, pattern over the segment, reason). Scoped to the program so
|
|
52
|
+
# a commit message that merely mentions "terraform destroy" does not match.
|
|
53
|
+
TEXT_RULES = [
|
|
54
|
+
({"prisma"}, r"\bmigrate\s+reset\b", "resets the database (prisma migrate reset)"),
|
|
55
|
+
({"supabase"}, r"\bdb\s+reset\b", "resets the database (supabase db reset)"),
|
|
56
|
+
({"dotnet"}, r"\bef\s+database\s+drop\b", "drops the database (dotnet ef database drop)"),
|
|
57
|
+
({"php", "rails", "rake", "bundle", "knex", "sequelize", "sequelize-cli"},
|
|
58
|
+
r"\b(migrate|db):(rollback|reset|fresh|undo|drop)\b|\bdb:migrate:undo\b", "rolls back or drops the database"),
|
|
59
|
+
({"alembic"}, r"\bdowngrade\b", "rolls back migrations (alembic downgrade)"),
|
|
60
|
+
({"goose", "migrate"}, r"\sdown\b", "rolls back migrations"),
|
|
61
|
+
({"docker"}, r"\bsystem\s+prune\b", "deletes Docker data (system prune)"),
|
|
62
|
+
({"docker"}, r"\bvolume\s+(rm|prune)\b", "deletes Docker volumes"),
|
|
63
|
+
({"docker", "docker-compose"}, r"\bdown\b.*\s(-v|--volumes)\b", "deletes the compose volumes (database data)"),
|
|
64
|
+
({"terraform", "tofu", "pulumi"}, r"\bdestroy\b", "destroys infrastructure"),
|
|
65
|
+
({"kubectl"}, r"\bdelete\b", "deletes Kubernetes resources"),
|
|
66
|
+
({"helm"}, r"\b(uninstall|delete)\b", "uninstalls a Helm release"),
|
|
67
|
+
({"npm", "pnpm", "yarn"}, r"^\S+\s+(publish|unpublish)\b", "publishes or unpublishes a package"),
|
|
68
|
+
]
|
|
69
|
+
# Launchers whose first argument is the real program (npx prisma ..., pnpm exec knex ...).
|
|
70
|
+
LAUNCHERS = {"npx", "pnpx", "bunx", "pnpm", "yarn", "npm", "bun", "uv", "uvx", "poetry"}
|
|
71
|
+
|
|
72
|
+
ENV_FILE = re.compile(r"^\.env(\..+)?$")
|
|
73
|
+
READERS = {"cat", "type", "less", "more", "head", "tail", "bat", "nl", "strings", "grep", "rg"}
|
|
74
|
+
ENV_SAFE_SUFFIXES = (".example", ".sample", ".template", ".dist")
|
|
75
|
+
|
|
76
|
+
# Write/Edit targets, matched against the path relative to the project root.
|
|
77
|
+
ASK_ALWAYS = [
|
|
78
|
+
(".github/workflows/*", "edits a CI workflow, which runs with the repo's secrets"),
|
|
79
|
+
("infra/*", "edits infrastructure code"),
|
|
80
|
+
("terraform/*", "edits infrastructure code"),
|
|
81
|
+
("*.tf", "edits infrastructure code"),
|
|
82
|
+
("*.tfvars", "edits infrastructure variables"),
|
|
83
|
+
]
|
|
84
|
+
ASK_IF_EXISTS = [
|
|
85
|
+
("migrations/*", "edits an existing migration; shipped migrations are immutable"),
|
|
86
|
+
("*/migrations/*", "edits an existing migration; shipped migrations are immutable"),
|
|
87
|
+
("alembic/versions/*", "edits an existing migration; shipped migrations are immutable"),
|
|
88
|
+
("db/migrate/*", "edits an existing migration; shipped migrations are immutable"),
|
|
89
|
+
]
|
|
90
|
+
MANIFESTS = {
|
|
91
|
+
"package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb",
|
|
92
|
+
"requirements.txt", "pyproject.toml", "poetry.lock", "uv.lock", "Pipfile", "Pipfile.lock",
|
|
93
|
+
"go.mod", "go.sum", "Cargo.toml", "Cargo.lock", "Gemfile", "Gemfile.lock",
|
|
94
|
+
"composer.json", "composer.lock", "packages.lock.json",
|
|
95
|
+
}
|
|
96
|
+
MANIFEST_GLOBS = ["requirements*.txt", "*.csproj", "Directory.Packages.props", "Packages/manifest.json"]
|
|
97
|
+
CHECK_CONFIGS = [
|
|
98
|
+
".eslintrc*", "eslint.config.*", ".prettierrc*", "prettier.config.*", "tsconfig*.json",
|
|
99
|
+
"biome.json", "biome.jsonc", ".golangci.y*ml", ".golangci.toml", "ruff.toml", ".ruff.toml",
|
|
100
|
+
".flake8", ".stylelintrc*", "stylelint.config.*", ".editorconfig",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- command parsing -------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def strip_heredoc_bodies(command: str) -> str:
|
|
107
|
+
"""Drop heredoc bodies: their text is data (a commit message), not commands."""
|
|
108
|
+
return re.sub(
|
|
109
|
+
r"<<-?\s*(['\"]?)(\w+)\1([^\n]*)\n.*?\n\s*\2\s*(?=\n|$|\))",
|
|
110
|
+
r"<<\2\3",
|
|
111
|
+
command,
|
|
112
|
+
flags=re.S,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def newlines_to_separators(command: str) -> str:
|
|
117
|
+
out, quote = [], None
|
|
118
|
+
for ch in command:
|
|
119
|
+
if quote:
|
|
120
|
+
if ch == quote:
|
|
121
|
+
quote = None
|
|
122
|
+
elif ch in "'\"":
|
|
123
|
+
quote = ch
|
|
124
|
+
elif ch == "\n":
|
|
125
|
+
ch = ";"
|
|
126
|
+
out.append(ch)
|
|
127
|
+
return "".join(out)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def tokenize(command: str) -> list[str]:
|
|
131
|
+
try:
|
|
132
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
133
|
+
lexer.whitespace_split = True
|
|
134
|
+
return list(lexer)
|
|
135
|
+
except ValueError: # unbalanced quotes: fall back to a plain split
|
|
136
|
+
return re.findall(r"&&|\|\||[;|&()]|[^\s;|&()]+", command)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def segments(command: str) -> list[list[str]]:
|
|
140
|
+
command = newlines_to_separators(strip_heredoc_bodies(command))
|
|
141
|
+
result, current = [], []
|
|
142
|
+
for token in tokenize(command):
|
|
143
|
+
if token in SEPARATORS:
|
|
144
|
+
if current:
|
|
145
|
+
result.append(current)
|
|
146
|
+
current = []
|
|
147
|
+
else:
|
|
148
|
+
current.append(token)
|
|
149
|
+
if current:
|
|
150
|
+
result.append(current)
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def nested_commands(tokens: list[str]) -> list[str]:
|
|
155
|
+
"""Command strings run by this segment: bash -c "...", eval "...", $(...)."""
|
|
156
|
+
found = []
|
|
157
|
+
for i, token in enumerate(tokens):
|
|
158
|
+
if os.path.basename(token) in ("bash", "sh", "zsh") and i + 2 < len(tokens) and tokens[i + 1] == "-c":
|
|
159
|
+
found.append(tokens[i + 2])
|
|
160
|
+
if token == "eval" and i + 1 < len(tokens):
|
|
161
|
+
found.append(" ".join(tokens[i + 1:]))
|
|
162
|
+
found.extend(re.findall(r"\$\(([^()]*)\)", token))
|
|
163
|
+
found.extend(re.findall(r"`([^`]*)`", token))
|
|
164
|
+
return found
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def program(tokens: list[str]) -> tuple[str, list[str]]:
|
|
168
|
+
"""Strip env assignments and wrappers; return (basename, args)."""
|
|
169
|
+
i = 0
|
|
170
|
+
while i < len(tokens):
|
|
171
|
+
token = tokens[i]
|
|
172
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", token) or os.path.basename(token) in WRAPPERS:
|
|
173
|
+
i += 1
|
|
174
|
+
continue
|
|
175
|
+
break
|
|
176
|
+
if i >= len(tokens):
|
|
177
|
+
return "", []
|
|
178
|
+
name = os.path.basename(tokens[i]).lower()
|
|
179
|
+
if name.endswith(".exe"):
|
|
180
|
+
name = name[:-4]
|
|
181
|
+
return name, tokens[i + 1:]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def effective_program(name: str, args: list[str]) -> str:
|
|
185
|
+
"""`npx prisma migrate reset` runs prisma; `php artisan migrate:fresh` runs php."""
|
|
186
|
+
if name not in LAUNCHERS:
|
|
187
|
+
return name
|
|
188
|
+
rest = [a for a in args if not a.startswith("-")]
|
|
189
|
+
if rest[:1] and rest[0] in ("exec", "dlx", "run", "x"):
|
|
190
|
+
rest = rest[1:]
|
|
191
|
+
return os.path.basename(rest[0]).lower() if rest else name
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def short_flags(args: list[str]) -> set[str]:
|
|
195
|
+
letters = set()
|
|
196
|
+
for arg in args:
|
|
197
|
+
if arg.startswith("-") and not arg.startswith("--") and len(arg) > 1:
|
|
198
|
+
letters.update(arg[1:])
|
|
199
|
+
return letters
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def positional(args: list[str], takes_value: set[str] = frozenset()) -> list[str]:
|
|
203
|
+
out, skip = [], False
|
|
204
|
+
for arg in args:
|
|
205
|
+
if skip:
|
|
206
|
+
skip = False
|
|
207
|
+
continue
|
|
208
|
+
if arg in takes_value:
|
|
209
|
+
skip = True
|
|
210
|
+
continue
|
|
211
|
+
if not arg.startswith("-"):
|
|
212
|
+
out.append(arg)
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --- rules -----------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def check_git(args: list[str]) -> tuple[str | None, str]:
|
|
219
|
+
i = 0
|
|
220
|
+
while i < len(args) and args[i].startswith("-"):
|
|
221
|
+
if args[i] == "-c" and i + 1 < len(args):
|
|
222
|
+
if args[i + 1].lower().startswith("core.hookspath"):
|
|
223
|
+
return DENY, "git -c core.hooksPath= bypasses the repository's git hooks"
|
|
224
|
+
i += 2
|
|
225
|
+
elif args[i] in ("-C", "--git-dir", "--work-tree") and i + 1 < len(args):
|
|
226
|
+
i += 2
|
|
227
|
+
else:
|
|
228
|
+
i += 1
|
|
229
|
+
if i >= len(args):
|
|
230
|
+
return NONE, ""
|
|
231
|
+
sub, rest = args[i], args[i + 1:]
|
|
232
|
+
flags = short_flags(rest)
|
|
233
|
+
|
|
234
|
+
if "--no-verify" in rest and sub in ("commit", "push", "merge", "rebase", "cherry-pick", "am"):
|
|
235
|
+
return DENY, "--no-verify skips the git hooks; fix what they report instead"
|
|
236
|
+
if sub == "commit" and "n" in flags:
|
|
237
|
+
return DENY, "git commit -n skips the git hooks; fix what they report instead"
|
|
238
|
+
if sub == "push":
|
|
239
|
+
forced = any(a == "--force" or a.startswith("--force-") for a in rest) or "f" in flags
|
|
240
|
+
if forced or any(a.startswith("+") for a in rest):
|
|
241
|
+
return DENY, ("Force push is blocked. If it is really needed, ask the user to run it "
|
|
242
|
+
"themselves: `! git push --force-with-lease`")
|
|
243
|
+
if "--delete" in rest or "d" in flags or any(a.startswith(":") and len(a) > 1 for a in rest):
|
|
244
|
+
return ASK, "deletes a remote branch or tag"
|
|
245
|
+
if sub == "reset" and "--hard" in rest:
|
|
246
|
+
return DENY, "git reset --hard throws away uncommitted work; the user runs it by hand"
|
|
247
|
+
if sub == "clean" and ("f" in flags or "--force" in rest):
|
|
248
|
+
return DENY, "git clean -f deletes untracked files; the user runs it by hand"
|
|
249
|
+
whole_tree = any(a in (".", ":/", "*", ":/*") for a in rest)
|
|
250
|
+
if sub == "checkout" and (whole_tree or "f" in flags or "--force" in rest):
|
|
251
|
+
return ASK, "discards uncommitted changes (git checkout)"
|
|
252
|
+
if sub == "restore" and whole_tree and not ("--staged" in rest and "--worktree" not in rest):
|
|
253
|
+
return ASK, "discards uncommitted changes (git restore)"
|
|
254
|
+
if sub == "stash" and rest[:1] and rest[0] in ("drop", "clear"):
|
|
255
|
+
return ASK, "deletes stashed work"
|
|
256
|
+
if sub == "branch" and ("D" in flags or ("--delete" in rest and "--force" in rest)):
|
|
257
|
+
return ASK, "force-deletes a branch without checking it is merged"
|
|
258
|
+
if sub == "tag" and ("d" in flags or "--delete" in rest):
|
|
259
|
+
return ASK, "deletes a tag"
|
|
260
|
+
if sub in ("filter-repo", "filter-branch"):
|
|
261
|
+
return ASK, "rewrites the repository history"
|
|
262
|
+
return NONE, ""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def check_rm(args: list[str]) -> tuple[str | None, str]:
|
|
266
|
+
flags = short_flags(args)
|
|
267
|
+
recursive = bool(flags & {"r", "R"}) or "--recursive" in args
|
|
268
|
+
force = "f" in flags or "--force" in args
|
|
269
|
+
if not (recursive and force):
|
|
270
|
+
return NONE, ""
|
|
271
|
+
targets = positional(args)
|
|
272
|
+
safe = targets and all(
|
|
273
|
+
not t.startswith(("/", "~")) and not re.match(r"^[A-Za-z]:", t)
|
|
274
|
+
and ".." not in re.split(r"[/\\]", t) and "*" not in t
|
|
275
|
+
and os.path.basename(t.rstrip("/\\")) in REGENERABLE
|
|
276
|
+
for t in targets
|
|
277
|
+
)
|
|
278
|
+
if safe:
|
|
279
|
+
return NONE, ""
|
|
280
|
+
return ASK, f"recursively force-deletes {', '.join(targets) or 'files'}"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def check_new_dependency(name: str, args: list[str]) -> tuple[str | None, str]:
|
|
284
|
+
packages: list[str] = []
|
|
285
|
+
if name in ("npm", "pnpm", "yarn", "bun") and args:
|
|
286
|
+
sub, rest = args[0], args[1:]
|
|
287
|
+
if sub in ("add",) or (name in ("npm", "pnpm") and sub in ("install", "i", "in", "ins")):
|
|
288
|
+
packages = positional(rest)
|
|
289
|
+
elif name in ("pip", "pip3") and args[:1] == ["install"]:
|
|
290
|
+
packages = pip_packages(args[1:])
|
|
291
|
+
elif name in ("python", "python3", "py") and args[:3] == ["-m", "pip", "install"]:
|
|
292
|
+
packages = pip_packages(args[3:])
|
|
293
|
+
elif name == "uv" and args[:2] == ["pip", "install"]:
|
|
294
|
+
packages = pip_packages(args[2:])
|
|
295
|
+
elif name in ("uv", "poetry", "cargo") and args[:1] == ["add"]:
|
|
296
|
+
packages = positional(args[1:])
|
|
297
|
+
elif name == "go" and args[:1] == ["get"]:
|
|
298
|
+
packages = [p for p in positional(args[1:]) if not p.startswith(".")]
|
|
299
|
+
elif name == "dotnet" and args[:2] == ["add", "package"]:
|
|
300
|
+
packages = positional(args[2:])
|
|
301
|
+
if not packages:
|
|
302
|
+
return NONE, ""
|
|
303
|
+
return ASK, (f"adds a new dependency: {', '.join(packages)}. Check the exact name before "
|
|
304
|
+
"installing: a one-letter typo can install a malicious package")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def pip_packages(args: list[str]) -> list[str]:
|
|
308
|
+
takes_value = {"-r", "--requirement", "-c", "--constraint", "-e", "--editable", "-i",
|
|
309
|
+
"--index-url", "--extra-index-url", "-t", "--target", "--prefix", "-f", "--find-links"}
|
|
310
|
+
return [p for p in positional(args, takes_value)
|
|
311
|
+
if not (p.startswith((".", "/")) or p.endswith((".whl", ".tar.gz", ".zip")))]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def writes_env_file(tokens: list[str]) -> bool:
|
|
315
|
+
targets = []
|
|
316
|
+
for i, token in enumerate(tokens):
|
|
317
|
+
if token in (">", ">>") and i + 1 < len(tokens):
|
|
318
|
+
targets.append(tokens[i + 1])
|
|
319
|
+
name, args = program(tokens)
|
|
320
|
+
if name == "tee":
|
|
321
|
+
targets.extend(positional(args))
|
|
322
|
+
return any(is_env_file(t) for t in targets)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def is_env_file(path: str) -> bool:
|
|
326
|
+
base = os.path.basename(path.replace("\\", "/"))
|
|
327
|
+
return bool(ENV_FILE.match(base)) and not base.endswith(ENV_SAFE_SUFFIXES)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def check_segment(tokens: list[str], depth: int = 0) -> tuple[str | None, str]:
|
|
331
|
+
name, args = program(tokens)
|
|
332
|
+
text = " ".join(tokens)
|
|
333
|
+
decisions = []
|
|
334
|
+
|
|
335
|
+
if name == "sudo":
|
|
336
|
+
decisions.append((DENY, "sudo is blocked; the user runs privileged commands by hand"))
|
|
337
|
+
if name == "chmod" and any(a in ("777", "0777") for a in args):
|
|
338
|
+
decisions.append((DENY, "chmod 777 makes files writable by everyone"))
|
|
339
|
+
if writes_env_file(tokens):
|
|
340
|
+
decisions.append((DENY, "writing a .env file is blocked; the user edits secrets by hand"))
|
|
341
|
+
if name in READERS and any(is_env_file(a) for a in positional(args)):
|
|
342
|
+
decisions.append((DENY, "reading a .env file would put its secrets in the conversation"))
|
|
343
|
+
if name == "git":
|
|
344
|
+
decisions.append(check_git(args))
|
|
345
|
+
if name == "rm":
|
|
346
|
+
decisions.append(check_rm(args))
|
|
347
|
+
if name == "gh":
|
|
348
|
+
if args[:2] in (["repo", "delete"], ["release", "delete"], ["secret", "delete"], ["secret", "remove"]):
|
|
349
|
+
decisions.append((ASK, f"gh {' '.join(args[:2])} deletes data on GitHub"))
|
|
350
|
+
if args[:1] == ["api"] and re.search(r"(-X|--method)[ =]?\s*DELETE\b", text, re.I):
|
|
351
|
+
decisions.append((ASK, "gh api DELETE request"))
|
|
352
|
+
decisions.append(check_new_dependency(name, args))
|
|
353
|
+
if DB_CLIENTS & {os.path.basename(t).lower() for t in tokens}:
|
|
354
|
+
for pattern, reason in DATA_LOSS_SQL:
|
|
355
|
+
if re.search(pattern, text, re.I):
|
|
356
|
+
decisions.append((ASK, reason))
|
|
357
|
+
if re.search(r"\bDELETE\s+FROM\b", text, re.I) and not re.search(r"\bWHERE\b", text, re.I):
|
|
358
|
+
decisions.append((ASK, "DELETE without WHERE removes every row"))
|
|
359
|
+
runs = {name, effective_program(name, args)}
|
|
360
|
+
for programs, pattern, reason in TEXT_RULES:
|
|
361
|
+
if runs & programs and re.search(pattern, text, re.I):
|
|
362
|
+
decisions.append((ASK, reason))
|
|
363
|
+
if depth < 3:
|
|
364
|
+
for inner in nested_commands(tokens):
|
|
365
|
+
decisions.append(check_command(inner, depth + 1))
|
|
366
|
+
return worst(decisions)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def check_command(command: str, depth: int = 0) -> tuple[str | None, str]:
|
|
370
|
+
return worst([check_segment(seg, depth) for seg in segments(command)])
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def worst(decisions: list[tuple[str | None, str]]) -> tuple[str | None, str]:
|
|
374
|
+
best = (NONE, "")
|
|
375
|
+
for decision in decisions:
|
|
376
|
+
if RANK[decision[0]] > RANK[best[0]]:
|
|
377
|
+
best = decision
|
|
378
|
+
return best
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def check_file(path: str, root: str) -> tuple[str | None, str]:
|
|
382
|
+
if not path:
|
|
383
|
+
return NONE, ""
|
|
384
|
+
absolute = os.path.abspath(os.path.join(root, path))
|
|
385
|
+
rel = os.path.relpath(absolute, root).replace("\\", "/")
|
|
386
|
+
if rel.startswith("../"):
|
|
387
|
+
return NONE, ""
|
|
388
|
+
rel = re.sub(r"^\.claude/worktrees/[^/]+/", "", rel)
|
|
389
|
+
base = os.path.basename(rel)
|
|
390
|
+
|
|
391
|
+
if is_env_file(base):
|
|
392
|
+
return DENY, "writing a .env file is blocked; the user edits secrets by hand"
|
|
393
|
+
for pattern, reason in ASK_ALWAYS:
|
|
394
|
+
if fnmatch(rel, pattern):
|
|
395
|
+
return ASK, reason
|
|
396
|
+
if not os.path.exists(absolute):
|
|
397
|
+
return NONE, ""
|
|
398
|
+
for pattern, reason in ASK_IF_EXISTS:
|
|
399
|
+
if fnmatch(rel, pattern):
|
|
400
|
+
return ASK, reason
|
|
401
|
+
if base in MANIFESTS or any(fnmatch(rel, g) or fnmatch(base, g) for g in MANIFEST_GLOBS):
|
|
402
|
+
return ASK, "edits a dependency manifest or lockfile"
|
|
403
|
+
if any(fnmatch(base, g) for g in CHECK_CONFIGS):
|
|
404
|
+
return ASK, "edits a lint, format or type-check config; fix the code instead of loosening the check"
|
|
405
|
+
return NONE, ""
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# --- entry point -----------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
def decide(payload: dict) -> tuple[str | None, str]:
|
|
411
|
+
tool = payload.get("tool_name", "")
|
|
412
|
+
tool_input = payload.get("tool_input") or {}
|
|
413
|
+
if tool == "Bash":
|
|
414
|
+
return check_command(tool_input.get("command", ""))
|
|
415
|
+
if tool in ("Write", "Edit", "MultiEdit", "NotebookEdit"):
|
|
416
|
+
root = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
|
|
417
|
+
return check_file(tool_input.get("file_path") or tool_input.get("notebook_path", ""), root)
|
|
418
|
+
return NONE, ""
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def main() -> int:
|
|
422
|
+
try:
|
|
423
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
424
|
+
except ValueError:
|
|
425
|
+
return 0
|
|
426
|
+
if "--command" in sys.argv[1:]:
|
|
427
|
+
print((payload.get("tool_input") or {}).get("command", ""))
|
|
428
|
+
return 0
|
|
429
|
+
decision, reason = decide(payload)
|
|
430
|
+
if decision:
|
|
431
|
+
print(json.dumps({
|
|
432
|
+
"hookSpecificOutput": {
|
|
433
|
+
"hookEventName": "PreToolUse",
|
|
434
|
+
"permissionDecision": decision,
|
|
435
|
+
"permissionDecisionReason": f"Arcane guard: {reason}.",
|
|
436
|
+
}
|
|
437
|
+
}))
|
|
438
|
+
return 0
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
if __name__ == "__main__":
|
|
442
|
+
try:
|
|
443
|
+
sys.exit(main())
|
|
444
|
+
except Exception: # a guard bug must never break the user's tool call
|
|
445
|
+
sys.exit(0)
|
package/hooks/guard.sh
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse guard (Bash, Write, Edit): see guard.py for the rules.
|
|
3
|
+
#
|
|
4
|
+
# Runs guard.py with the first Python that actually starts. On Windows `python3`
|
|
5
|
+
# can be the Microsoft Store stub, which exits non-zero instead of running, so
|
|
6
|
+
# each candidate is tried in turn. With no working Python the guard does nothing
|
|
7
|
+
# and the static permission rules in settings.json remain the only check.
|
|
8
|
+
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
INPUT=$(cat)
|
|
10
|
+
|
|
11
|
+
for PY in python3 python py; do
|
|
12
|
+
command -v "$PY" >/dev/null 2>&1 || continue
|
|
13
|
+
if OUT=$(printf '%s' "$INPUT" | "$PY" "$DIR/guard.py" "$@" 2>/dev/null); then
|
|
14
|
+
printf '%s' "$OUT"
|
|
15
|
+
exit 0
|
|
16
|
+
fi
|
|
17
|
+
done
|
|
18
|
+
exit 0
|
package/hooks/log-agent-stop.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set +e
|
|
3
3
|
main() {
|
|
4
|
-
[[ -d
|
|
4
|
+
[[ -d .claude/session-state ]] && echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] subagent_stop" >> .claude/session-state/agents.log
|
|
5
5
|
}
|
|
6
6
|
main 2>/dev/null
|
|
7
7
|
exit 0
|
package/hooks/log-agent.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set +e
|
|
3
3
|
main() {
|
|
4
|
-
[[ -d
|
|
4
|
+
[[ -d .claude/session-state ]] && echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] subagent_start" >> .claude/session-state/agents.log
|
|
5
5
|
}
|
|
6
6
|
main 2>/dev/null
|
|
7
7
|
exit 0
|
package/hooks/notify.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set +e
|
|
3
3
|
main() {
|
|
4
|
-
[[ -d
|
|
4
|
+
[[ -d .claude/session-state ]] && echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] notification" >> .claude/session-state/notifications.log
|
|
5
5
|
}
|
|
6
6
|
main 2>/dev/null
|
|
7
7
|
exit 0
|
package/hooks/post-compact.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set +e
|
|
3
3
|
main() {
|
|
4
|
-
[[ -d
|
|
4
|
+
[[ -d .claude/session-state ]] && echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] post-compact" >> .claude/session-state/sessions.log
|
|
5
5
|
}
|
|
6
6
|
main 2>/dev/null
|
|
7
7
|
exit 0
|