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.
@@ -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 (3)** | data-files, prototype-code, test-standards |
242
- | **Hooks (15)** | session-start, detect-division, detect-gaps, validate-commit, validate-push, validate-secrets, validate-assets, validate-skill-change, notify, pre-compact, post-compact, session-stop, log-agent, log-agent-stop |
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** | 9 allow (git, ls, gh, curl) + 14 deny (rm -rf, force push, sudo, .env, etc.) |
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 production/session-state/active.md
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é `production/session-state/active.md` como checkpoint vivo. Update después de cada milestone significativo:
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`: Guarda summary del estado antes de compaction
75
- - `post-compact.sh`: Carga summary después de compaction
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 `production/session-state/active.md` (leer para recover state)
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 production/session-state/active.md con:
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 `production/session-state/active.md` cuando:
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
- ├── .claude.bak/ # Backup de .claude/ antes de instalar
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`) | `validate-commit.sh`, `validate-push.sh`, `validate-secrets.sh` |
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`.
@@ -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 `production/session-state/active.md`
127
- - Gamedev: `docs/architecture/architecture.md` o `production/session-state/active.md`
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
 
@@ -22,7 +22,7 @@ main() {
22
22
  DIVISION="engineering (Python)"
23
23
  fi
24
24
 
25
- [[ -d "production/session-state" ]] && echo "Division: $DIVISION" > production/session-state/division.txt
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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  set +e
3
3
  main() {
4
- [[ -d production/session-state ]] && echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] subagent_stop" >> production/session-state/agents.log
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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  set +e
3
3
  main() {
4
- [[ -d production/session-state ]] && echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] subagent_start" >> production/session-state/agents.log
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 production/session-state ]] && echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] notification" >> production/session-state/notifications.log
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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  set +e
3
3
  main() {
4
- [[ -d production/session-state ]] && echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] post-compact" >> production/session-state/sessions.log
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