codeforge-dev 1.5.7 → 1.7.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.
Files changed (80) hide show
  1. package/.devcontainer/.env +2 -1
  2. package/.devcontainer/CHANGELOG.md +55 -9
  3. package/.devcontainer/CLAUDE.md +65 -15
  4. package/.devcontainer/README.md +67 -6
  5. package/.devcontainer/config/keybindings.json +5 -0
  6. package/.devcontainer/config/main-system-prompt.md +63 -2
  7. package/.devcontainer/config/settings.json +25 -6
  8. package/.devcontainer/devcontainer.json +23 -7
  9. package/.devcontainer/features/README.md +21 -7
  10. package/.devcontainer/features/ccburn/README.md +60 -0
  11. package/.devcontainer/features/ccburn/devcontainer-feature.json +38 -0
  12. package/.devcontainer/features/ccburn/install.sh +174 -0
  13. package/.devcontainer/features/ccstatusline/README.md +22 -21
  14. package/.devcontainer/features/ccstatusline/devcontainer-feature.json +1 -1
  15. package/.devcontainer/features/ccstatusline/install.sh +48 -16
  16. package/.devcontainer/features/claude-code/config/settings.json +60 -24
  17. package/.devcontainer/features/mcp-qdrant/devcontainer-feature.json +1 -1
  18. package/.devcontainer/features/mcp-reasoner/devcontainer-feature.json +1 -1
  19. package/.devcontainer/plugins/devs-marketplace/plugins/auto-formatter/scripts/__pycache__/format-on-stop.cpython-314.pyc +0 -0
  20. package/.devcontainer/plugins/devs-marketplace/plugins/auto-formatter/scripts/format-on-stop.py +21 -6
  21. package/.devcontainer/plugins/devs-marketplace/plugins/auto-linter/scripts/__pycache__/lint-file.cpython-314.pyc +0 -0
  22. package/.devcontainer/plugins/devs-marketplace/plugins/auto-linter/scripts/lint-file.py +7 -10
  23. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/REVIEW-RUBRIC.md +440 -0
  24. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/architect.md +190 -0
  25. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/bash-exec.md +173 -0
  26. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/claude-guide.md +155 -0
  27. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/dependency-analyst.md +248 -0
  28. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/doc-writer.md +233 -0
  29. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/explorer.md +235 -0
  30. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/generalist.md +125 -0
  31. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/git-archaeologist.md +242 -0
  32. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/migrator.md +195 -0
  33. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/perf-profiler.md +265 -0
  34. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/refactorer.md +209 -0
  35. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/researcher.md +195 -0
  36. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/security-auditor.md +289 -0
  37. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/spec-writer.md +284 -0
  38. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/statusline-config.md +188 -0
  39. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/test-writer.md +245 -0
  40. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/hooks/hooks.json +12 -0
  41. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/guard-readonly-bash.cpython-314.pyc +0 -0
  42. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/redirect-builtin-agents.cpython-314.pyc +0 -0
  43. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/skill-suggester.cpython-314.pyc +0 -0
  44. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/syntax-validator.cpython-314.pyc +0 -0
  45. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/verify-no-regression.cpython-314.pyc +0 -0
  46. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/verify-tests-pass.cpython-314.pyc +0 -0
  47. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/guard-readonly-bash.py +611 -0
  48. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/redirect-builtin-agents.py +83 -0
  49. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/skill-suggester.py +85 -2
  50. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/syntax-validator.py +9 -4
  51. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/verify-no-regression.py +221 -0
  52. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/verify-tests-pass.py +176 -0
  53. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/claude-agent-sdk/SKILL.md +599 -0
  54. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/claude-agent-sdk/references/sdk-typescript-reference.md +954 -0
  55. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/git-forensics/SKILL.md +276 -0
  56. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/git-forensics/references/advanced-commands.md +332 -0
  57. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/git-forensics/references/investigation-playbooks.md +319 -0
  58. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/performance-profiling/SKILL.md +341 -0
  59. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/performance-profiling/references/interpreting-results.md +235 -0
  60. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/performance-profiling/references/tool-commands.md +395 -0
  61. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/refactoring-patterns/SKILL.md +344 -0
  62. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/refactoring-patterns/references/safe-transformations.md +247 -0
  63. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/refactoring-patterns/references/smell-catalog.md +332 -0
  64. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/security-checklist/SKILL.md +277 -0
  65. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/security-checklist/references/owasp-patterns.md +269 -0
  66. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/security-checklist/references/secrets-patterns.md +253 -0
  67. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/specification-writing/SKILL.md +288 -0
  68. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/specification-writing/references/criteria-patterns.md +245 -0
  69. package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/specification-writing/references/ears-templates.md +239 -0
  70. package/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/__pycache__/guard-protected.cpython-314.pyc +0 -0
  71. package/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected.py +40 -39
  72. package/.devcontainer/scripts/setup-aliases.sh +10 -20
  73. package/.devcontainer/scripts/setup-config.sh +2 -0
  74. package/.devcontainer/scripts/setup-plugins.sh +38 -46
  75. package/.devcontainer/scripts/setup-projects.sh +175 -0
  76. package/.devcontainer/scripts/setup-symlink-claude.sh +36 -0
  77. package/.devcontainer/scripts/setup-update-claude.sh +11 -8
  78. package/.devcontainer/scripts/setup.sh +4 -2
  79. package/package.json +1 -1
  80. package/.devcontainer/scripts/setup-irie-claude.sh +0 -32
@@ -0,0 +1,611 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Guard readonly bash - PreToolUse hook for read-only agents.
4
+
5
+ Ensures Bash commands are read-only by blocking write operations.
6
+ Supports two modes:
7
+ --mode general-readonly: Blocks common write/modification commands
8
+ --mode git-readonly: Only allows specific git read commands + safe utilities
9
+
10
+ Handles bypass vectors: command chaining (;, &&, ||), pipes (|),
11
+ command substitution ($(), backticks), backgrounding (&), redirections
12
+ (>, >>), eval/exec, inline scripting (python -c, node -e), and
13
+ path/backslash prefix bypasses (/usr/bin/rm, \\rm).
14
+
15
+ Reads tool input from stdin (JSON). Returns JSON on stdout.
16
+ Exit 0: Command is safe (allowed)
17
+ Exit 2: Command would modify state (blocked)
18
+ """
19
+
20
+ import json
21
+ import re
22
+ import sys
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # General-readonly blocklist
26
+ # ---------------------------------------------------------------------------
27
+
28
+ # Single-word commands that modify files or system state
29
+ WRITE_COMMANDS = frozenset(
30
+ {
31
+ # File system modification
32
+ "rm",
33
+ "mv",
34
+ "cp",
35
+ "mkdir",
36
+ "rmdir",
37
+ "touch",
38
+ "chmod",
39
+ "chown",
40
+ "chgrp",
41
+ "ln",
42
+ "install",
43
+ "mkfifo",
44
+ "mknod",
45
+ "truncate",
46
+ "shred",
47
+ "unlink",
48
+ # Interactive editors
49
+ "nano",
50
+ "vi",
51
+ "vim",
52
+ "nvim",
53
+ # Process management
54
+ "kill",
55
+ "pkill",
56
+ "killall",
57
+ # Dangerous utilities
58
+ "dd",
59
+ "sudo",
60
+ "su",
61
+ "tee",
62
+ # Shell builtins that execute arbitrary code
63
+ "eval",
64
+ "exec",
65
+ "source",
66
+ # Can execute arbitrary commands as arguments
67
+ "xargs",
68
+ }
69
+ )
70
+
71
+ # Two-word command prefixes that are blocked (matched on word boundaries)
72
+ WRITE_PREFIXES = (
73
+ # Docker writes
74
+ "docker stop",
75
+ "docker rm",
76
+ "docker kill",
77
+ "docker rmi",
78
+ "docker exec",
79
+ "docker-compose down",
80
+ "docker compose down",
81
+ # Git writes
82
+ "git push",
83
+ "git reset",
84
+ "git clean",
85
+ "git merge",
86
+ "git rebase",
87
+ "git commit",
88
+ "git cherry-pick",
89
+ "git revert",
90
+ "git pull",
91
+ "git checkout --",
92
+ "git restore",
93
+ "git stash drop",
94
+ "git stash clear",
95
+ "git stash pop",
96
+ "git config",
97
+ "git remote add",
98
+ "git remote remove",
99
+ "git remote rename",
100
+ "git branch -d",
101
+ "git branch -D",
102
+ "git branch --delete",
103
+ "git branch -m",
104
+ "git branch -M",
105
+ "git branch --move",
106
+ "git tag -d",
107
+ "git tag --delete",
108
+ # Package managers (write operations)
109
+ "pip install",
110
+ "pip uninstall",
111
+ "pip3 install",
112
+ "pip3 uninstall",
113
+ "uv pip",
114
+ "npm install",
115
+ "npm uninstall",
116
+ "npm ci",
117
+ "npm update",
118
+ "npm link",
119
+ "yarn add",
120
+ "yarn remove",
121
+ "yarn install",
122
+ "pnpm add",
123
+ "pnpm remove",
124
+ "pnpm install",
125
+ "apt install",
126
+ "apt-get install",
127
+ "apt remove",
128
+ "apt-get remove",
129
+ "cargo install",
130
+ # sed in-place editing
131
+ "sed -i",
132
+ "sed --in-place",
133
+ )
134
+
135
+ # Interpreters that can execute arbitrary code
136
+ INTERPRETERS = frozenset(
137
+ {
138
+ "bash",
139
+ "sh",
140
+ "zsh",
141
+ "dash",
142
+ "ksh",
143
+ "fish",
144
+ "python",
145
+ "python3",
146
+ "node",
147
+ "perl",
148
+ "ruby",
149
+ }
150
+ )
151
+
152
+ # Flags that trigger inline script execution per interpreter
153
+ INLINE_FLAGS = {
154
+ "python": "-c",
155
+ "python3": "-c",
156
+ "node": "-e",
157
+ "perl": "-e",
158
+ "ruby": "-e",
159
+ "bash": "-c",
160
+ "sh": "-c",
161
+ "zsh": "-c",
162
+ }
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Git-readonly allowlist
167
+ # ---------------------------------------------------------------------------
168
+
169
+ # Git subcommands that are safe (read-only)
170
+ GIT_SAFE_SUBCOMMANDS = frozenset(
171
+ {
172
+ "log",
173
+ "blame",
174
+ "show",
175
+ "diff",
176
+ "bisect",
177
+ "reflog",
178
+ "shortlog",
179
+ "rev-parse",
180
+ "rev-list",
181
+ "branch",
182
+ "tag",
183
+ "remote",
184
+ "status",
185
+ "ls-files",
186
+ "ls-tree",
187
+ "cat-file",
188
+ "describe",
189
+ "name-rev",
190
+ "grep",
191
+ "for-each-ref",
192
+ "count-objects",
193
+ "fsck",
194
+ "verify-commit",
195
+ "verify-tag",
196
+ "fetch",
197
+ "stash",
198
+ "notes",
199
+ "worktree",
200
+ "config",
201
+ "help",
202
+ "version",
203
+ }
204
+ )
205
+
206
+ # Flags/subcommands that make an otherwise-safe git command destructive
207
+ GIT_RESTRICTED_ARGS = {
208
+ "branch": {"-d", "-D", "-m", "-M", "--delete", "--move", "--copy", "-c", "-C"},
209
+ "tag": {"-d", "--delete", "-f", "--force"},
210
+ "remote": {"add", "remove", "rename", "set-url", "set-head", "prune"},
211
+ "stash": {"drop", "clear", "pop", "apply", "push", "save", "create", "store"},
212
+ "worktree": {"add", "remove", "prune", "repair", "move", "lock", "unlock"},
213
+ "notes": {"add", "append", "copy", "edit", "merge", "prune", "remove"},
214
+ "config": set(), # blocked by default — only --get/--list allowed
215
+ }
216
+
217
+ # Non-git commands allowed in git-readonly mode
218
+ READONLY_UTILITIES = frozenset(
219
+ {
220
+ # File reading
221
+ "cat",
222
+ "head",
223
+ "tail",
224
+ "less",
225
+ "more",
226
+ "bat",
227
+ # Text processing (non-destructive — sed without -i, awk)
228
+ "wc",
229
+ "sort",
230
+ "uniq",
231
+ "cut",
232
+ "tr",
233
+ "paste",
234
+ "column",
235
+ "fold",
236
+ "sed",
237
+ "awk",
238
+ "gawk",
239
+ # Search
240
+ "grep",
241
+ "egrep",
242
+ "fgrep",
243
+ "rg",
244
+ "ag",
245
+ "ack",
246
+ # File/directory listing
247
+ "find",
248
+ "ls",
249
+ "tree",
250
+ "file",
251
+ "stat",
252
+ "du",
253
+ "df",
254
+ # Output
255
+ "echo",
256
+ "printf",
257
+ # Comparison
258
+ "diff",
259
+ "comm",
260
+ "cmp",
261
+ # JSON/YAML processing
262
+ "jq",
263
+ "yq",
264
+ # Path utilities
265
+ "basename",
266
+ "dirname",
267
+ "realpath",
268
+ "readlink",
269
+ # System information
270
+ "date",
271
+ "cal",
272
+ "env",
273
+ "printenv",
274
+ "id",
275
+ "whoami",
276
+ "uname",
277
+ "hostname",
278
+ "pwd",
279
+ "uptime",
280
+ "nproc",
281
+ "arch",
282
+ # Conditionals and builtins
283
+ "true",
284
+ "false",
285
+ "test",
286
+ "[",
287
+ # Lookup
288
+ "which",
289
+ "type",
290
+ "command",
291
+ # Numeric/sequencing
292
+ "seq",
293
+ "expr",
294
+ "bc",
295
+ # Terminal
296
+ "tput",
297
+ "clear",
298
+ # Checksums
299
+ "md5sum",
300
+ "sha256sum",
301
+ "sha1sum",
302
+ # Binary inspection
303
+ "xxd",
304
+ "od",
305
+ "hexdump",
306
+ "strings",
307
+ # Network (stdout by default)
308
+ "curl",
309
+ # Remote access
310
+ "ssh",
311
+ # Code search
312
+ "ast-grep",
313
+ "sg",
314
+ }
315
+ )
316
+
317
+
318
+ # ---------------------------------------------------------------------------
319
+ # Command parsing helpers
320
+ # ---------------------------------------------------------------------------
321
+
322
+
323
+ def _split_segments(command: str) -> list[str]:
324
+ """Split command on ; && || & (background) into segments.
325
+
326
+ Handles line continuations (backslash-newline). Does not attempt
327
+ to parse quoted strings — intentionally over-splits for safety.
328
+ """
329
+ command = command.replace("\\\n", " ")
330
+ # Split on ; && || and lone & (not &&)
331
+ segments = re.split(r"\s*(?:;|&&|\|\||(?<![&])&(?![&]))\s*", command)
332
+ return [s.strip() for s in segments if s.strip()]
333
+
334
+
335
+ def _split_pipes(segment: str) -> list[str]:
336
+ """Split a segment on | (single pipe, not ||)."""
337
+ parts = re.split(r"(?<!\|)\|(?!\|)", segment)
338
+ return [p.strip() for p in parts if p.strip()]
339
+
340
+
341
+ def _get_cmd_words(stage: str) -> list[str]:
342
+ """Extract command words from a pipe stage, skipping env-var assignments."""
343
+ words = stage.split()
344
+ result = []
345
+ for word in words:
346
+ # Skip leading VAR=value assignments (but not flags like --foo=bar)
347
+ if "=" in word and not word.startswith("-") and not result:
348
+ continue
349
+ result.append(word)
350
+ return result
351
+
352
+
353
+ def _base_name(cmd: str) -> str:
354
+ """Get base command name, stripping path prefix and leading backslash.
355
+
356
+ Examples: /usr/bin/rm -> rm, \\rm -> rm, ./script.sh -> script.sh
357
+ """
358
+ cmd = cmd.lstrip("\\")
359
+ return cmd.rsplit("/", 1)[-1] if "/" in cmd else cmd
360
+
361
+
362
+ def _resolve_prefix(words: list[str]) -> tuple[str, list[str]]:
363
+ """Resolve through 'command' and 'builtin' prefixes.
364
+
365
+ E.g. ``command rm file`` -> base='rm', words=['rm', 'file'].
366
+ """
367
+ if not words:
368
+ return ("", [])
369
+ base = _base_name(words[0])
370
+ if base in ("command", "builtin"):
371
+ rest = words[1:]
372
+ # Skip flags belonging to command/builtin (e.g. command -v)
373
+ while rest and rest[0].startswith("-"):
374
+ rest = rest[1:]
375
+ if rest:
376
+ return (_base_name(rest[0]), rest)
377
+ return ("", [])
378
+ return (base, words)
379
+
380
+
381
+ def _has_redirect(command: str) -> bool:
382
+ """Detect output redirections (> or >>) excluding >/dev/null.
383
+
384
+ Returns True if the command writes to a file via shell redirection.
385
+ May produce false positives for '>' inside quoted strings — this is
386
+ intentional (safe-side).
387
+ """
388
+ # Strip harmless /dev/null redirections first
389
+ cleaned = re.sub(r"[12]?>{1,2}\s*/dev/null", "", command)
390
+ return bool(re.search(r"(?:^|[\s)])(?:[12])?>{1,2}\s*[^\s&|;]", cleaned))
391
+
392
+
393
+ def _has_command_substitution(command: str) -> bool:
394
+ """Check if command contains $() or backtick command substitution."""
395
+ return "$(" in command or "`" in command
396
+
397
+
398
+ def _extract_substitution_commands(command: str) -> list[str]:
399
+ """Extract inner commands from $() and backtick substitutions."""
400
+ inner: list[str] = []
401
+ for m in re.finditer(r"\$\(([^)]+)\)", command):
402
+ inner.append(m.group(1))
403
+ for m in re.finditer(r"`([^`]+)`", command):
404
+ inner.append(m.group(1))
405
+ return inner
406
+
407
+
408
+ def _has_sed_inplace(words: list[str]) -> bool:
409
+ """Check if a sed invocation uses in-place editing (-i)."""
410
+ for w in words[1:]:
411
+ if w == "-i" or w == "--in-place" or w.startswith("-i"):
412
+ return True
413
+ # Combined short flags like -ni
414
+ if w.startswith("-") and not w.startswith("--") and "i" in w:
415
+ return True
416
+ return False
417
+
418
+
419
+ def _matches_prefix(cmd_words: list[str], prefix: str) -> bool:
420
+ """Check if command words match a blocked prefix on word boundaries."""
421
+ pwords = prefix.split()
422
+ if len(cmd_words) < len(pwords):
423
+ return False
424
+ return cmd_words[: len(pwords)] == pwords
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # Mode checkers
429
+ # ---------------------------------------------------------------------------
430
+
431
+
432
+ def check_general_readonly(command: str) -> str | None:
433
+ """Block write commands in general-readonly mode.
434
+
435
+ Returns:
436
+ Error message if blocked, None if allowed.
437
+ """
438
+ # Global checks on the raw command string
439
+ if _has_redirect(command):
440
+ return "Blocked: output redirection (> or >>) is not allowed in read-only mode"
441
+
442
+ # Recursively check command substitutions
443
+ if _has_command_substitution(command):
444
+ for inner in _extract_substitution_commands(command):
445
+ result = check_general_readonly(inner)
446
+ if result:
447
+ return "Blocked: command substitution contains a write operation"
448
+
449
+ # Check each segment and pipe stage
450
+ for segment in _split_segments(command):
451
+ for i, stage in enumerate(_split_pipes(segment)):
452
+ words = _get_cmd_words(stage)
453
+ if not words:
454
+ continue
455
+
456
+ base, words = _resolve_prefix(words)
457
+ if not base:
458
+ continue
459
+
460
+ # Single-word blocked commands
461
+ if base in WRITE_COMMANDS:
462
+ return f"Blocked: '{base}' is not allowed in read-only mode"
463
+
464
+ # Two-word blocked prefixes
465
+ cmd_words = [base] + [w for w in words[1:]]
466
+ for wp in WRITE_PREFIXES:
467
+ if _matches_prefix(cmd_words, wp):
468
+ return f"Blocked: '{wp}' is not allowed in read-only mode"
469
+
470
+ # Block piping into interpreters (e.g. curl ... | bash)
471
+ if i > 0 and base in INTERPRETERS:
472
+ return f"Blocked: piping into '{base}' is not allowed in read-only mode"
473
+
474
+ # Block inline script execution (e.g. python3 -c "os.remove(...)")
475
+ if base in INLINE_FLAGS:
476
+ flag = INLINE_FLAGS[base]
477
+ if flag in words[1:]:
478
+ return f"Blocked: '{base} {flag}' inline execution is not allowed in read-only mode"
479
+
480
+ return None
481
+
482
+
483
+ def check_git_readonly(command: str) -> str | None:
484
+ """Only allow git read commands and safe utilities (strict allowlist).
485
+
486
+ Returns:
487
+ Error message if blocked, None if allowed.
488
+ """
489
+ if _has_redirect(command):
490
+ return "Blocked: output redirection is not allowed in read-only mode"
491
+
492
+ if _has_command_substitution(command):
493
+ for inner in _extract_substitution_commands(command):
494
+ result = check_git_readonly(inner)
495
+ if result:
496
+ return "Blocked: command substitution contains a blocked operation"
497
+
498
+ for segment in _split_segments(command):
499
+ for i, stage in enumerate(_split_pipes(segment)):
500
+ words = _get_cmd_words(stage)
501
+ if not words:
502
+ continue
503
+
504
+ base, words = _resolve_prefix(words)
505
+ if not base:
506
+ continue
507
+
508
+ # --- Git commands ---
509
+ if base == "git":
510
+ if len(words) < 2:
511
+ continue # bare "git" is harmless
512
+
513
+ # Resolve git global flags to find the real subcommand
514
+ # e.g. git -C /path --no-pager log -> subcommand is "log"
515
+ sub = None
516
+ skip_next = False
517
+ for w in words[1:]:
518
+ if skip_next:
519
+ skip_next = False
520
+ continue
521
+ if w in ("-C", "-c", "--git-dir", "--work-tree"):
522
+ skip_next = True
523
+ continue
524
+ if w.startswith("-"):
525
+ continue
526
+ sub = w
527
+ break
528
+
529
+ if sub is None:
530
+ continue # all flags, no subcommand — harmless
531
+
532
+ if sub not in GIT_SAFE_SUBCOMMANDS:
533
+ return f"Blocked: 'git {sub}' is not allowed in read-only mode"
534
+
535
+ # Check restricted arguments for certain subcommands
536
+ if sub in GIT_RESTRICTED_ARGS:
537
+ restricted = GIT_RESTRICTED_ARGS[sub]
538
+
539
+ if sub == "config":
540
+ # Only allow --get, --get-all, --list, --get-regexp
541
+ safe_flags = {
542
+ "--get",
543
+ "--get-all",
544
+ "--list",
545
+ "-l",
546
+ "--get-regexp",
547
+ }
548
+ if not (set(words[2:]) & safe_flags):
549
+ return "Blocked: 'git config' is only allowed with --get or --list"
550
+
551
+ elif sub == "stash":
552
+ # Only allow "stash list" and "stash show"
553
+ if len(words) > 2 and words[2] not in ("list", "show"):
554
+ return f"Blocked: 'git stash {words[2]}' is not allowed in read-only mode"
555
+
556
+ else:
557
+ for w in words[2:]:
558
+ if w in restricted:
559
+ return f"Blocked: 'git {sub} {w}' is not allowed in read-only mode"
560
+
561
+ # --- Allowed utilities ---
562
+ elif base in READONLY_UTILITIES:
563
+ # Special case: sed -i is destructive even though sed is allowed
564
+ if base == "sed" and _has_sed_inplace(words):
565
+ return "Blocked: 'sed -i' (in-place edit) is not allowed in read-only mode"
566
+ continue
567
+
568
+ # --- Everything else is blocked ---
569
+ else:
570
+ return f"Blocked: '{base}' is not in the read-only allowlist"
571
+
572
+ return None
573
+
574
+
575
+ # ---------------------------------------------------------------------------
576
+ # Main
577
+ # ---------------------------------------------------------------------------
578
+
579
+
580
+ def main():
581
+ mode = "general-readonly"
582
+ for i, arg in enumerate(sys.argv):
583
+ if arg == "--mode" and i + 1 < len(sys.argv):
584
+ mode = sys.argv[i + 1]
585
+ break
586
+
587
+ try:
588
+ input_data = json.load(sys.stdin)
589
+ except (json.JSONDecodeError, ValueError):
590
+ sys.exit(0)
591
+
592
+ tool_input = input_data.get("tool_input", {})
593
+ command = tool_input.get("command", "")
594
+
595
+ if not command or not command.strip():
596
+ sys.exit(0)
597
+
598
+ if mode == "git-readonly":
599
+ error = check_git_readonly(command)
600
+ else:
601
+ error = check_general_readonly(command)
602
+
603
+ if error:
604
+ json.dump({"error": error}, sys.stdout)
605
+ sys.exit(2)
606
+
607
+ sys.exit(0)
608
+
609
+
610
+ if __name__ == "__main__":
611
+ main()
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Redirect built-in agents - PreToolUse hook for Task tool.
4
+
5
+ Intercepts Task tool calls and transparently redirects built-in agent
6
+ types to enhanced custom agents defined in the code-directive plugin.
7
+
8
+ The redirect preserves the original prompt — only the subagent_type
9
+ is changed. Model selection is left to the custom agent's YAML config.
10
+
11
+ Reads tool input from stdin (JSON). Returns JSON on stdout.
12
+ Exit 0: No redirect needed (passthrough) or redirect applied (with JSON output)
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ from datetime import datetime, timezone
19
+
20
+ # Built-in agent type → custom agent name mapping
21
+ REDIRECT_MAP = {
22
+ "Explore": "explorer",
23
+ "Plan": "architect",
24
+ "general-purpose": "generalist",
25
+ "Bash": "bash-exec",
26
+ "claude-code-guide": "claude-guide",
27
+ "statusline-setup": "statusline-config",
28
+ }
29
+
30
+ # Plugin name prefix for fully-qualified agent references
31
+ PLUGIN_PREFIX = "code-directive"
32
+
33
+ LOG_FILE = os.environ.get("AGENT_REDIRECT_LOG", "/tmp/agent-redirect.log")
34
+
35
+
36
+ def log(message: str) -> None:
37
+ """Append a timestamped log entry if logging is enabled."""
38
+ if not LOG_FILE:
39
+ return
40
+ try:
41
+ with open(LOG_FILE, "a") as f:
42
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
43
+ f.write(f"[{ts}] {message}\n")
44
+ except OSError:
45
+ pass
46
+
47
+
48
+ def main() -> None:
49
+ try:
50
+ input_data = json.load(sys.stdin)
51
+ except (json.JSONDecodeError, ValueError):
52
+ sys.exit(0)
53
+
54
+ tool_input = input_data.get("tool_input", {})
55
+ subagent_type = tool_input.get("subagent_type", "")
56
+
57
+ if subagent_type not in REDIRECT_MAP:
58
+ sys.exit(0)
59
+
60
+ target = REDIRECT_MAP[subagent_type]
61
+ qualified_name = f"{PLUGIN_PREFIX}:{target}"
62
+
63
+ log(f"{subagent_type} → {qualified_name}")
64
+
65
+ # Include all original fields in updatedInput — Claude Code may replace
66
+ # rather than merge, so we must preserve prompt, description, etc.
67
+ updated = {**tool_input, "subagent_type": qualified_name}
68
+
69
+ response = {
70
+ "hookSpecificOutput": {
71
+ "hookEventName": "PreToolUse",
72
+ "permissionDecision": "allow",
73
+ "permissionDecisionReason": f"Redirected {subagent_type} to {qualified_name}",
74
+ "updatedInput": updated,
75
+ }
76
+ }
77
+
78
+ json.dump(response, sys.stdout)
79
+ sys.exit(0)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()