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.
- package/.devcontainer/.env +2 -1
- package/.devcontainer/CHANGELOG.md +55 -9
- package/.devcontainer/CLAUDE.md +65 -15
- package/.devcontainer/README.md +67 -6
- package/.devcontainer/config/keybindings.json +5 -0
- package/.devcontainer/config/main-system-prompt.md +63 -2
- package/.devcontainer/config/settings.json +25 -6
- package/.devcontainer/devcontainer.json +23 -7
- package/.devcontainer/features/README.md +21 -7
- package/.devcontainer/features/ccburn/README.md +60 -0
- package/.devcontainer/features/ccburn/devcontainer-feature.json +38 -0
- package/.devcontainer/features/ccburn/install.sh +174 -0
- package/.devcontainer/features/ccstatusline/README.md +22 -21
- package/.devcontainer/features/ccstatusline/devcontainer-feature.json +1 -1
- package/.devcontainer/features/ccstatusline/install.sh +48 -16
- package/.devcontainer/features/claude-code/config/settings.json +60 -24
- package/.devcontainer/features/mcp-qdrant/devcontainer-feature.json +1 -1
- package/.devcontainer/features/mcp-reasoner/devcontainer-feature.json +1 -1
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-formatter/scripts/__pycache__/format-on-stop.cpython-314.pyc +0 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-formatter/scripts/format-on-stop.py +21 -6
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-linter/scripts/__pycache__/lint-file.cpython-314.pyc +0 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/auto-linter/scripts/lint-file.py +7 -10
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/REVIEW-RUBRIC.md +440 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/architect.md +190 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/bash-exec.md +173 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/claude-guide.md +155 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/dependency-analyst.md +248 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/doc-writer.md +233 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/explorer.md +235 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/generalist.md +125 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/git-archaeologist.md +242 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/migrator.md +195 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/perf-profiler.md +265 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/refactorer.md +209 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/researcher.md +195 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/security-auditor.md +289 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/spec-writer.md +284 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/statusline-config.md +188 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/agents/test-writer.md +245 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/hooks/hooks.json +12 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/guard-readonly-bash.cpython-314.pyc +0 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/redirect-builtin-agents.cpython-314.pyc +0 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/skill-suggester.cpython-314.pyc +0 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/syntax-validator.cpython-314.pyc +0 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/verify-no-regression.cpython-314.pyc +0 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/__pycache__/verify-tests-pass.cpython-314.pyc +0 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/guard-readonly-bash.py +611 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/redirect-builtin-agents.py +83 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/skill-suggester.py +85 -2
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/syntax-validator.py +9 -4
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/verify-no-regression.py +221 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/verify-tests-pass.py +176 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/claude-agent-sdk/SKILL.md +599 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/claude-agent-sdk/references/sdk-typescript-reference.md +954 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/git-forensics/SKILL.md +276 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/git-forensics/references/advanced-commands.md +332 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/git-forensics/references/investigation-playbooks.md +319 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/performance-profiling/SKILL.md +341 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/performance-profiling/references/interpreting-results.md +235 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/performance-profiling/references/tool-commands.md +395 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/refactoring-patterns/SKILL.md +344 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/refactoring-patterns/references/safe-transformations.md +247 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/refactoring-patterns/references/smell-catalog.md +332 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/security-checklist/SKILL.md +277 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/security-checklist/references/owasp-patterns.md +269 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/security-checklist/references/secrets-patterns.md +253 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/specification-writing/SKILL.md +288 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/specification-writing/references/criteria-patterns.md +245 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/skills/specification-writing/references/ears-templates.md +239 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/__pycache__/guard-protected.cpython-314.pyc +0 -0
- package/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected.py +40 -39
- package/.devcontainer/scripts/setup-aliases.sh +10 -20
- package/.devcontainer/scripts/setup-config.sh +2 -0
- package/.devcontainer/scripts/setup-plugins.sh +38 -46
- package/.devcontainer/scripts/setup-projects.sh +175 -0
- package/.devcontainer/scripts/setup-symlink-claude.sh +36 -0
- package/.devcontainer/scripts/setup-update-claude.sh +11 -8
- package/.devcontainer/scripts/setup.sh +4 -2
- package/package.json +1 -1
- package/.devcontainer/scripts/setup-irie-claude.sh +0 -32
package/.devcontainer/plugins/devs-marketplace/plugins/code-directive/scripts/guard-readonly-bash.py
ADDED
|
@@ -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()
|