@reddb-io/red-skills 4.4.1 → 4.5.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 (77) hide show
  1. package/bin/build-info.mjs +87 -0
  2. package/bin/cli-frontdoor.mjs +14 -0
  3. package/bin/red-skills-brain.mjs +17 -22
  4. package/bin/red-skills-content.mjs +17 -0
  5. package/bin/red-skills-hook.mjs +45 -0
  6. package/bin/red-skills-memory.mjs +17 -22
  7. package/bin/red-skills-redskilled-mcp.mjs +2 -0
  8. package/bin/red-skills-resource.mjs +21 -0
  9. package/bin/runtime-compatibility.mjs +17 -0
  10. package/bin/runtime-paths.mjs +13 -0
  11. package/dist/code-nav.bundle.min.mjs +1 -1
  12. package/dist/opencode-host.bundle.min.mjs +1 -1
  13. package/dist/redskilled-mcp.bundle.min.mjs +36 -36
  14. package/dist/redskilled.bundle.min.mjs +1 -1
  15. package/dist/release.bundle.min.mjs +1 -1
  16. package/dist/rsp-core.bundle.min.mjs +1 -1
  17. package/dist/rsp.bundle.min.mjs +1 -1
  18. package/dist/statusline.bundle.min.mjs +1 -1
  19. package/package.json +9 -4
  20. package/runtime/hook-routes.json +125 -0
  21. package/runtime/plugins/brain/scripts/bootstrap.mjs +656 -0
  22. package/runtime/plugins/brain/scripts/structural-smoke.sh +7 -0
  23. package/runtime/plugins/dev/hooks/branch-lock-codex.sh +105 -0
  24. package/runtime/plugins/dev/hooks/code-nav-mcp.sh +63 -0
  25. package/runtime/plugins/dev/hooks/command-guard.sh +893 -0
  26. package/runtime/plugins/dev/hooks/ensure-codex-statusline.mjs +144 -0
  27. package/runtime/plugins/dev/hooks/red-fetch.mjs +38 -0
  28. package/runtime/plugins/dev/hooks/redskilled-mcp.sh +64 -0
  29. package/runtime/plugins/dev/hooks/rsp-hook.sh +343 -0
  30. package/runtime/plugins/dev/hooks/tests/command-guard.test.sh +378 -0
  31. package/runtime/plugins/dev/hooks/tests/rsp-hook.test.sh +229 -0
  32. package/runtime/plugins/dev/scripts/memory-bridge.sh +230 -0
  33. package/runtime/plugins/dev/scripts/structural-smoke.sh +7 -0
  34. package/runtime/plugins/dev/scripts/tests/memory-bridge.test.sh +555 -0
  35. package/runtime/plugins/dev/skills/engineering/afk/defaults/cargo-pre-worktree.sh +37 -0
  36. package/runtime/plugins/dev/skills/engineering/afk/defaults/envelope-post-attempt.sh +47 -0
  37. package/runtime/plugins/dev/skills/engineering/afk/defaults/gradle-pre-worktree.sh +45 -0
  38. package/runtime/plugins/dev/skills/engineering/afk/defaults/heartbeat-post-attempt.sh +43 -0
  39. package/runtime/plugins/dev/skills/engineering/afk/defaults/validation-post-merge.sh +77 -0
  40. package/runtime/plugins/dev/skills/engineering/afk/detectors/cargo.sh +27 -0
  41. package/runtime/plugins/dev/skills/engineering/afk/detectors/gradle.sh +38 -0
  42. package/runtime/plugins/dev/skills/engineering/afk/examples/only-mine.sh +32 -0
  43. package/runtime/plugins/dev/skills/engineering/afk/examples/red-afk-attempt-action.yml +53 -0
  44. package/runtime/plugins/dev/skills/engineering/afk/examples/rs-afk-attempt.yml +76 -0
  45. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-cargo +38 -0
  46. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-envelope +50 -0
  47. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-gradle +42 -0
  48. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-heartbeat +46 -0
  49. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-validation +79 -0
  50. package/runtime/plugins/dev/skills/engineering/diagnose/scripts/hitl-loop.template.sh +41 -0
  51. package/runtime/plugins/dev/skills/engineering/red-setup/scripts/install-runtime-shim.sh +181 -0
  52. package/runtime/plugins/dev/skills/engineering/red-setup/workflows/red-issues-needs-triage.yml +36 -0
  53. package/runtime/plugins/dev/skills/engineering/wizard/template.sh +211 -0
  54. package/runtime/plugins/dev/skills/engineering/zoom-out/scripts/graph-mode-plumbing.sh +36 -0
  55. package/runtime/plugins/dev/skills/engineering/zoom-out/scripts/tests/contract.test.sh +168 -0
  56. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock-hook.sh +113 -0
  57. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock-session-start.sh +81 -0
  58. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock.sh +68 -0
  59. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/dev-config.sh +126 -0
  60. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/git-command-classifier.sh +198 -0
  61. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/lock-store.sh +67 -0
  62. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/scope-resolver.sh +43 -0
  63. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/branch-lock-cli.test.sh +114 -0
  64. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/claude-plugin-hook.test.sh +117 -0
  65. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/codex-hook.test.sh +230 -0
  66. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/dev-config.test.sh +99 -0
  67. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/git-command-classifier.test.sh +187 -0
  68. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/lock-store.test.sh +90 -0
  69. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/scope-resolver.test.sh +63 -0
  70. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/session-start.test.sh +123 -0
  71. package/runtime/plugins/dev/skills/misc/git-guardrails-claude-code/scripts/block-dangerous-git.sh +231 -0
  72. package/runtime/plugins/dev/skills/misc/git-guardrails-claude-code/scripts/tests/block-dangerous-git.test.sh +143 -0
  73. package/runtime/plugins/internal/scripts/structural-smoke.sh +7 -0
  74. package/runtime/plugins/internal/skills/maintainer/create-plugin/scripts/create-plugin.sh +308 -0
  75. package/runtime/plugins/memory/scripts/bootstrap.mjs +659 -0
  76. package/runtime/plugins/memory/scripts/structural-smoke.sh +7 -0
  77. package/scripts/check-skills-boundary.mjs +32 -0
@@ -0,0 +1,893 @@
1
+ #!/usr/bin/env bash
2
+ # Dev PreToolUse shell-command guard.
3
+ #
4
+ # Reads a Claude/Codex/OpenCode-style PreToolUse payload on stdin, extracts the
5
+ # shell command, and blocks it when it matches a deny rule from .red/config.yaml:
6
+ #
7
+ # command_guard:
8
+ # global:
9
+ # - "sudo *"
10
+ # - "rm -rf *"
11
+ # main:
12
+ # - "git rebase"
13
+ # worktree:
14
+ # - "git clean"
15
+ #
16
+ # plugins:
17
+ # dev:
18
+ # enabled: true
19
+ #
20
+ # The hook is dormant unless plugins.dev.enabled is true. Once enabled, the dev
21
+ # invariant is built in: agent-created git worktrees must live under .red/tmp/,
22
+ # and branch-moving commands are blocked in the primary checkout. Missing/empty
23
+ # deny rules still allow every other command. `global` rules apply in every
24
+ # scope, `main` rules apply outside RedSkills runtime worktrees, and `worktree`
25
+ # rules apply inside `/afk` and `/go` worktrees. Deny rules accept explicit
26
+ # modes: `regex:<pattern>`, `prefix:<literal>`, `suffix:<literal>`,
27
+ # `exact:<literal>`, and `glob:<pattern>`. Bare rules with glob metacharacters
28
+ # are treated as globs; every other bare rule matches the exact command, a
29
+ # command prefix, or a command suffix at a shell-command boundary.
30
+
31
+ set -uo pipefail
32
+
33
+ INPUT="$(timeout "${RED_SKILLS_HOOK_STDIN_TIMEOUT_S:-5s}" cat 2>/dev/null || true)"
34
+
35
+ allow() {
36
+ printf '{}'
37
+ exit 0
38
+ }
39
+
40
+ deny() {
41
+ local reason="$1"
42
+ printf '%s\n' "$reason" >&2
43
+ jq -nc --arg reason "$reason" '{
44
+ decision: "block",
45
+ reason: $reason,
46
+ hookSpecificOutput: {
47
+ hookEventName: "PreToolUse",
48
+ permissionDecision: "deny",
49
+ permissionDecisionReason: $reason
50
+ }
51
+ }'
52
+ exit 0
53
+ }
54
+
55
+ command -v jq >/dev/null 2>&1 || allow
56
+
57
+ COMMAND="$(
58
+ jq -r '
59
+ def command_value:
60
+ .tool_input.command? //
61
+ .tool_input.cmd? //
62
+ .tool_input.args.command? //
63
+ .input.command? //
64
+ .input.cmd? //
65
+ .arguments.command? //
66
+ .arguments.cmd? //
67
+ .command? //
68
+ .cmd? //
69
+ empty;
70
+ command_value
71
+ | if type == "array" then map(tostring) | join(" ")
72
+ elif type == "string" then .
73
+ else ""
74
+ end
75
+ ' <<<"$INPUT" 2>/dev/null
76
+ )"
77
+ [[ -n "$COMMAND" && "$COMMAND" != "null" ]] || allow
78
+
79
+ ROOT="$(jq -r '.cwd // .workspace.current_dir // empty' <<<"$INPUT" 2>/dev/null)"
80
+ [[ -z "$ROOT" || "$ROOT" == "null" ]] && ROOT="${CLAUDE_PROJECT_DIR:-${CODEX_PROJECT_DIR:-}}"
81
+ [[ -z "$ROOT" || "$ROOT" == "null" ]] && ROOT="$(pwd)"
82
+
83
+ if [[ -n "$ROOT" && ! -d "$ROOT/.git" ]]; then
84
+ ROOT="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$ROOT")"
85
+ fi
86
+ [[ -n "$ROOT" ]] || allow
87
+
88
+ find_config() {
89
+ local dir="$1"
90
+ local i
91
+ for ((i = 0; i < 64; i++)); do
92
+ if [[ -f "$dir/.red/config.yaml" ]]; then
93
+ printf '%s\n' "$dir/.red/config.yaml"
94
+ return 0
95
+ fi
96
+ [[ "$dir" == "/" ]] && break
97
+ dir="$(dirname "$dir")"
98
+ done
99
+ return 1
100
+ }
101
+
102
+ CONFIG="$(find_config "$ROOT" || true)"
103
+ [[ -n "$CONFIG" ]] || allow
104
+ REPO_ROOT="${CONFIG%/.red/config.yaml}"
105
+
106
+ trim() {
107
+ local s="$1"
108
+ s="${s#"${s%%[![:space:]]*}"}"
109
+ s="${s%"${s##*[![:space:]]}"}"
110
+ printf '%s' "$s"
111
+ }
112
+
113
+ strip_comment() {
114
+ local s="$1"
115
+ if [[ "$s" != *\"* && "$s" != *\'* ]]; then
116
+ s="${s%%#*}"
117
+ fi
118
+ trim "$s"
119
+ }
120
+
121
+ strip_line_comment() {
122
+ local s="$1"
123
+ if [[ "$s" != *\"* && "$s" != *\'* ]]; then
124
+ s="${s%%#*}"
125
+ fi
126
+ s="${s%"${s##*[![:space:]]}"}"
127
+ printf '%s' "$s"
128
+ }
129
+
130
+ unquote_scalar() {
131
+ local s
132
+ s="$(strip_comment "$1")"
133
+ local q="${s:0:1}"
134
+ if [[ "$q" == '"' || "$q" == "'" ]]; then
135
+ local rest="${s:1}"
136
+ local before="${rest%%"$q"*}"
137
+ if [[ "$rest" == *"$q"* ]]; then
138
+ local close_len=$((1 + ${#before} + 1))
139
+ local tail="${s:$close_len}"
140
+ if [[ "$tail" =~ ^[[:space:]]*(#.*)?$ ]]; then
141
+ s="${s:1:${#before}}"
142
+ fi
143
+ fi
144
+ fi
145
+ trim "$s"
146
+ }
147
+
148
+ join_stack() {
149
+ local joined="" item
150
+ for item in "${STACK[@]}"; do
151
+ [[ -n "$joined" ]] && joined+="."
152
+ joined+="$item"
153
+ done
154
+ printf '%s' "$joined"
155
+ }
156
+
157
+ read_config_scalar() {
158
+ local wanted="$1"
159
+ local -a STACK=()
160
+ local -a INDENTS=()
161
+ local raw line indent_str indent rest key value full
162
+
163
+ while IFS= read -r raw || [[ -n "$raw" ]]; do
164
+ raw="${raw%$'\r'}"
165
+ line="$(strip_line_comment "$raw")"
166
+ [[ -z "${line//[[:space:]]/}" ]] && continue
167
+ indent_str="${raw%%[![:space:]]*}"
168
+ indent=${#indent_str}
169
+ ((indent % 2 == 0)) || return 1
170
+ rest="${line:$indent}"
171
+
172
+ [[ "$rest" =~ ^[A-Za-z_][A-Za-z0-9_-]*: ]] || continue
173
+ key="${rest%%:*}"
174
+ value="${rest#*:}"
175
+ value="$(trim "$value")"
176
+
177
+ while ((${#INDENTS[@]} > 0 && INDENTS[${#INDENTS[@]} - 1] >= indent)); do
178
+ unset "STACK[$((${#STACK[@]} - 1))]"
179
+ unset "INDENTS[$((${#INDENTS[@]} - 1))]"
180
+ done
181
+
182
+ full="$(join_stack)"
183
+ [[ -n "$full" ]] && full+="."
184
+ full+="$key"
185
+
186
+ if [[ -z "$value" ]]; then
187
+ STACK+=("$key")
188
+ INDENTS+=("$indent")
189
+ continue
190
+ fi
191
+
192
+ if [[ "$full" == "$wanted" ]]; then
193
+ unquote_scalar "$value"
194
+ return 0
195
+ fi
196
+ done <"$CONFIG"
197
+
198
+ return 1
199
+ }
200
+
201
+ enabled="$(read_config_scalar "plugins.dev.enabled" || true)"
202
+ [[ "$enabled" == "true" ]] || allow
203
+
204
+ scope_is_command_guard_worktree() {
205
+ local root="$1"
206
+ case "$root" in
207
+ */.red/tmp/work-* | */.red/tmp/work-*/*) return 0 ;;
208
+ */.red/tmp/workers/*/worktree | */.red/tmp/workers/*/worktree/*) return 0 ;;
209
+ *) return 1 ;;
210
+ esac
211
+ }
212
+
213
+ COMMAND_GUARD_SCOPE="main"
214
+ scope_is_command_guard_worktree "$ROOT" && COMMAND_GUARD_SCOPE="worktree"
215
+
216
+ canonical_path() {
217
+ local base="$1"
218
+ local path="$2"
219
+ if [[ "$path" == /* ]]; then
220
+ realpath -m -- "$path" 2>/dev/null || printf '%s\n' "$path"
221
+ else
222
+ realpath -m -- "$base/$path" 2>/dev/null || printf '%s/%s\n' "$base" "$path"
223
+ fi
224
+ }
225
+
226
+ git_subcommand_index() {
227
+ local -n tokens_ref="$1"
228
+ local git_index="$2"
229
+ local -n cwd_ref="$3"
230
+ local k=$((git_index + 1))
231
+ local len=${#tokens_ref[@]}
232
+ while ((k < len)); do
233
+ local tok="${tokens_ref[k]}"
234
+ case "$tok" in
235
+ -C)
236
+ if ((k + 1 < len)); then
237
+ cwd_ref="$(canonical_path "$cwd_ref" "${tokens_ref[k + 1]}")"
238
+ fi
239
+ k=$((k + 2))
240
+ continue
241
+ ;;
242
+ -c|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix)
243
+ k=$((k + 2))
244
+ continue
245
+ ;;
246
+ --*=*|-*)
247
+ k=$((k + 1))
248
+ continue
249
+ ;;
250
+ *)
251
+ break
252
+ ;;
253
+ esac
254
+ done
255
+ printf '%s\n' "$k"
256
+ }
257
+
258
+ skip_git_worktree_add_option() {
259
+ local -n tokens_ref="$1"
260
+ local -n index_ref="$2"
261
+ local len=${#tokens_ref[@]}
262
+ local tok="${tokens_ref[index_ref]}"
263
+ case "$tok" in
264
+ -b|-B|--orphan|--reason)
265
+ index_ref=$((index_ref + 2))
266
+ return 0
267
+ ;;
268
+ --reason=*|--orphan=*)
269
+ index_ref=$((index_ref + 1))
270
+ return 0
271
+ ;;
272
+ --detach|--checkout|--no-checkout|--guess-remote|--no-guess-remote|--force|-f|--lock)
273
+ index_ref=$((index_ref + 1))
274
+ return 0
275
+ ;;
276
+ --)
277
+ index_ref=$((index_ref + 1))
278
+ return 1
279
+ ;;
280
+ --*)
281
+ index_ref=$((index_ref + 1))
282
+ return 0
283
+ ;;
284
+ -*)
285
+ # Unknown short option. Treat it as a flag so the destination is still the
286
+ # first non-option token; if the option actually consumed an argument, the
287
+ # guard fails closed on a non-.red/tmp "destination".
288
+ index_ref=$((index_ref + 1))
289
+ return 0
290
+ ;;
291
+ *)
292
+ ((index_ref < len))
293
+ return 1
294
+ ;;
295
+ esac
296
+ }
297
+
298
+ worktree_add_destination() {
299
+ local tokens_name="$1"
300
+ local -n tokens_ref="$tokens_name"
301
+ local git_index="$2"
302
+ local cwd="$ROOT"
303
+ local s
304
+ s="$(git_subcommand_index "$tokens_name" "$git_index" cwd)"
305
+ [[ "${tokens_ref[s]:-}" == "worktree" && "${tokens_ref[s + 1]:-}" == "add" ]] || return 1
306
+
307
+ local j=$((s + 2))
308
+ local len=${#tokens_ref[@]}
309
+ while ((j < len)); do
310
+ if skip_git_worktree_add_option "$tokens_name" j; then
311
+ continue
312
+ fi
313
+ [[ -n "${tokens_ref[j]:-}" ]] || return 1
314
+ printf '%s\t%s\n' "$cwd" "${tokens_ref[j]}"
315
+ return 0
316
+ done
317
+ return 1
318
+ }
319
+
320
+ primary_branch_movement_reason() {
321
+ local tokens_name="$1"
322
+ local -n tokens_ref="$tokens_name"
323
+ local n=${#tokens_ref[@]}
324
+ local i j s sub
325
+
326
+ for ((i = 0; i < n; i++)); do
327
+ if [[ "${tokens_ref[i]}" == "gh" && "${tokens_ref[i + 1]:-}" == "pr" && "${tokens_ref[i + 2]:-}" == "checkout" ]]; then
328
+ printf 'gh pr checkout changes the primary checkout; create or reuse a .red/tmp worktree for the PR branch instead'
329
+ return 0
330
+ fi
331
+
332
+ [[ "${tokens_ref[i]}" == "git" ]] || continue
333
+ local cwd="$ROOT"
334
+ s="$(git_subcommand_index "$tokens_name" "$i" cwd)"
335
+ sub="${tokens_ref[s]:-}"
336
+ case "$sub" in
337
+ checkout|switch)
338
+ local target="" saw_doubledash=0
339
+ for ((j = s + 1; j < n; j++)); do
340
+ local t="${tokens_ref[j]}"
341
+ case "$t" in
342
+ --)
343
+ saw_doubledash=1
344
+ continue
345
+ ;;
346
+ -b|-B|-c|-C|--create|--force-create|--orphan|--track)
347
+ printf 'git %s creates or checks out a branch in the primary checkout; use git worktree add .red/tmp/work-<slug> -b <branch> ... instead' "$sub"
348
+ return 0
349
+ ;;
350
+ --create=*|--force-create=*|--orphan=*)
351
+ printf 'git %s creates a branch in the primary checkout; use git worktree add .red/tmp/work-<slug> -b <branch> ... instead' "$sub"
352
+ return 0
353
+ ;;
354
+ -*)
355
+ continue
356
+ ;;
357
+ *)
358
+ target="$t"
359
+ break
360
+ ;;
361
+ esac
362
+ done
363
+ ((saw_doubledash)) && continue
364
+ [[ -z "$target" || "$target" == "." ]] && continue
365
+ printf 'git %s changes the primary checkout branch; create or enter a .red/tmp worktree instead' "$sub"
366
+ return 0
367
+ ;;
368
+ esac
369
+ done
370
+
371
+ return 1
372
+ }
373
+
374
+ enforce_dev_worktree_policy() {
375
+ local -a tokens
376
+ read -ra tokens <<<"$COMMAND"
377
+ local n=${#tokens[@]}
378
+ ((n > 0)) || return 0
379
+
380
+ local i
381
+ for ((i = 0; i < n; i++)); do
382
+ [[ "${tokens[i]}" == "git" ]] || continue
383
+ local pair cwd dest abs allowed_root
384
+ pair="$(worktree_add_destination tokens "$i" || true)"
385
+ [[ -n "$pair" ]] || continue
386
+ cwd="${pair%%$'\t'*}"
387
+ dest="${pair#*$'\t'}"
388
+ abs="$(canonical_path "$cwd" "$dest")"
389
+ allowed_root="$(canonical_path "$REPO_ROOT" ".red/tmp")"
390
+ case "$abs" in
391
+ "$allowed_root"/*)
392
+ ;;
393
+ *)
394
+ deny "$(cat <<EOF
395
+ BLOCKED by RedSkills dev worktree guard.
396
+ plugins.dev.enabled is true, so agent-created git worktrees must live under .red/tmp/.
397
+
398
+ Requested worktree path: $dest
399
+ Resolved worktree path: $abs
400
+ Allowed root: $allowed_root/
401
+
402
+ Use: git worktree add .red/tmp/work-<slug> -b <branch> origin/main
403
+ EOF
404
+ )"
405
+ ;;
406
+ esac
407
+ done
408
+
409
+ if [[ "$COMMAND_GUARD_SCOPE" == "main" ]]; then
410
+ local reason
411
+ reason="$(primary_branch_movement_reason tokens || true)"
412
+ if [[ -n "$reason" ]]; then
413
+ deny "$(cat <<EOF
414
+ BLOCKED by RedSkills dev primary-checkout guard.
415
+ plugins.dev.enabled is true, so agents must not create or switch branches in the primary checkout.
416
+
417
+ $reason.
418
+ EOF
419
+ )"
420
+ fi
421
+ fi
422
+ }
423
+
424
+ enforce_dev_worktree_policy
425
+
426
+ is_gh_write_command() {
427
+ local upper
428
+ upper="$(printf '%s' "$COMMAND" | tr '[:lower:]' '[:upper:]')"
429
+ [[ "$upper" =~ (^|[[:space:]\;\&\|])GH[[:space:]]+ISSUE[[:space:]]+(COMMENT|CREATE|EDIT)([[:space:]]|$) ]] && return 0
430
+ [[ "$upper" =~ (^|[[:space:]\;\&\|])GH[[:space:]]+PR[[:space:]]+(COMMENT|CREATE|EDIT|REVIEW)([[:space:]]|$) ]] && return 0
431
+ [[ "$upper" =~ (^|[[:space:]\;\&\|])GH[[:space:]]+RELEASE[[:space:]]+(CREATE|EDIT)([[:space:]]|$) ]] && return 0
432
+ [[ "$upper" =~ (^|[[:space:]\;\&\|])GH[[:space:]]+GIST[[:space:]]+CREATE([[:space:]]|$) ]] && return 0
433
+ [[ "$upper" =~ (^|[[:space:]\;\&\|])RSP[[:space:]]+GH[[:space:]]+ISSUE[[:space:]]+(COMMENT|CREATE|EDIT)([[:space:]]|$) ]] && return 0
434
+ [[ "$upper" =~ (^|[[:space:]\;\&\|])RSP[[:space:]]+GH[[:space:]]+PR[[:space:]]+(COMMENT|CREATE|EDIT|REVIEW)([[:space:]]|$) ]] && return 0
435
+ return 1
436
+ }
437
+
438
+ has_gh_body_home_path() {
439
+ [[ "$COMMAND" =~ (^|[[:space:]])(--body|-b)(=|[[:space:]]+).*(/home/[^[:space:]\'\"]+|/Users/[^[:space:]\'\"]+) ]]
440
+ }
441
+
442
+ has_sensitive_assignment() {
443
+ local upper
444
+ upper="$(printf '%s' "$COMMAND" | tr '[:lower:]' '[:upper:]')"
445
+ [[ "$upper" =~ [A-Z0-9_]*(TOKEN|SECRET|PASSWORD|APIKEY|API_KEY|API-KEY)[A-Z0-9_-]*=[^[:space:]\'\"]+ ]]
446
+ }
447
+
448
+ enforce_no_leak_gh_write_policy() {
449
+ is_gh_write_command || return 0
450
+ local reason=""
451
+ if [[ "$COMMAND" == *"claude.ai/code/session_"* ]]; then
452
+ reason="Claude session link"
453
+ elif has_gh_body_home_path; then
454
+ reason="absolute home path in gh body text"
455
+ elif has_sensitive_assignment; then
456
+ reason="sensitive KEY=value assignment"
457
+ fi
458
+
459
+ [[ -n "$reason" ]] || return 0
460
+ deny "$(cat <<EOF
461
+ BLOCKED by RedSkills no-leak command guard.
462
+ The gh write command contains a public-output leak pattern: $reason.
463
+
464
+ Redact and retry. Use placeholders such as [REDACTED_HOME], [REDACTED_SECRET], or [REDACTED_CLAUDE_SESSION] when the reference is unavoidable.
465
+ EOF
466
+ )"
467
+ }
468
+
469
+ enforce_no_leak_gh_write_policy
470
+
471
+ # Returns the filename component if path targets the .red/tmp/ root directly
472
+ # (no named-lane subdirectory after the root). Returns nothing otherwise.
473
+ tmp_root_blocked_path() {
474
+ local raw="$1"
475
+ local s="$raw"
476
+ [[ "$s" == \'* ]] && s="${s:1}" && s="${s%\'}"
477
+ [[ "$s" == \"* ]] && s="${s:1}" && s="${s%\"}"
478
+ local rel
479
+ case "$s" in
480
+ "$REPO_ROOT/.red/tmp/"*)
481
+ rel="${s#$REPO_ROOT/.red/tmp/}"
482
+ ;;
483
+ ".red/tmp/"*)
484
+ rel="${s#.red/tmp/}"
485
+ ;;
486
+ *)
487
+ return 1
488
+ ;;
489
+ esac
490
+ [[ -n "$rel" && "$rel" != */* ]] || return 1
491
+ printf '%s' "$rel"
492
+ }
493
+
494
+ deny_tmp_root_write() {
495
+ local path="$1"
496
+ deny "$(cat <<EOF
497
+ BLOCKED by RedSkills tmp-root write guard.
498
+ plugins.dev.enabled is true, so files must not be created directly at .red/tmp/.
499
+
500
+ Requested path: $path
501
+ Correct lanes: .red/tmp/logs/<YYYY-MM-DD>/ for session logs
502
+ .red/tmp/scratch/ for ad-hoc scratch files
503
+
504
+ Write to the appropriate named lane instead.
505
+ EOF
506
+ )"
507
+ }
508
+
509
+ enforce_tmp_root_write_policy() {
510
+ local -a tokens
511
+ read -ra tokens <<<"$COMMAND"
512
+ local n=${#tokens[@]}
513
+ ((n > 0)) || return 0
514
+
515
+ local i j leaf
516
+
517
+ for ((i = 0; i < n; i++)); do
518
+ local tok="${tokens[i]}"
519
+ local redir_target=""
520
+
521
+ # Standalone or concatenated redirection operators
522
+ case "$tok" in
523
+ '>'|'>>'|[0-9]'>'|[0-9]'>>'|'&>'|'&>>')
524
+ redir_target="${tokens[i+1]:-}"
525
+ ;;
526
+ '>'?*|'>>'?*)
527
+ if [[ "$tok" == '>>'* ]]; then
528
+ redir_target="${tok#>>}"
529
+ else
530
+ redir_target="${tok#>}"
531
+ fi
532
+ ;;
533
+ [0-9]'>'?*|[0-9]'>>'?*)
534
+ local re_fd_append='^[0-9]+>>(.+)'
535
+ local re_fd_write='^[0-9]+>(.+)'
536
+ if [[ "$tok" =~ $re_fd_append ]]; then
537
+ redir_target="${BASH_REMATCH[1]}"
538
+ elif [[ "$tok" =~ $re_fd_write ]]; then
539
+ redir_target="${BASH_REMATCH[1]}"
540
+ fi
541
+ ;;
542
+ '&>'?*|'&>>'?*)
543
+ if [[ "$tok" == '&>>'* ]]; then
544
+ redir_target="${tok#&>>}"
545
+ else
546
+ redir_target="${tok#&>}"
547
+ fi
548
+ ;;
549
+ esac
550
+
551
+ if [[ -n "$redir_target" ]]; then
552
+ leaf="$(tmp_root_blocked_path "$redir_target" || true)"
553
+ [[ -n "$leaf" ]] && deny_tmp_root_write "$redir_target"
554
+ fi
555
+
556
+ # File-creating commands: touch, tee, cp, mv
557
+ case "$tok" in
558
+ touch|tee)
559
+ for ((j = i + 1; j < n; j++)); do
560
+ local arg="${tokens[j]}"
561
+ case "$arg" in '>'|'>>'|'|'|'&&'|'||'|';') break ;; esac
562
+ [[ "${arg:0:1}" == "-" ]] && continue
563
+ leaf="$(tmp_root_blocked_path "$arg" || true)"
564
+ [[ -n "$leaf" ]] && deny_tmp_root_write "$arg"
565
+ done
566
+ ;;
567
+ cp|mv)
568
+ local -a positional=()
569
+ for ((j = i + 1; j < n; j++)); do
570
+ local arg="${tokens[j]}"
571
+ case "$arg" in '>'|'>>'|'|'|'&&'|'||'|';') break ;; esac
572
+ [[ "${arg:0:1}" == "-" ]] && continue
573
+ positional+=("$arg")
574
+ done
575
+ if [[ ${#positional[@]} -ge 2 ]]; then
576
+ local dest="${positional[${#positional[@]}-1]}"
577
+ leaf="$(tmp_root_blocked_path "$dest" || true)"
578
+ [[ -n "$leaf" ]] && deny_tmp_root_write "$dest"
579
+ fi
580
+ ;;
581
+ esac
582
+ done
583
+ }
584
+
585
+ enforce_tmp_root_write_policy
586
+
587
+ strip_path_token() {
588
+ local s="$1"
589
+ [[ "$s" == \'* ]] && s="${s:1}" && s="${s%\'}"
590
+ [[ "$s" == \"* ]] && s="${s:1}" && s="${s%\"}"
591
+ printf '%s' "$s"
592
+ }
593
+
594
+ pid_file_live() {
595
+ local pid_file="$1" raw
596
+ [[ -f "$pid_file" ]] || return 1
597
+ raw="$(tr -d '[:space:]' <"$pid_file" 2>/dev/null || true)"
598
+ [[ "$raw" =~ ^[1-9][0-9]*$ ]] || return 1
599
+ kill -0 "$raw" 2>/dev/null
600
+ }
601
+
602
+ supervisor_delete_reason() {
603
+ local abs="$1" dir
604
+ for dir in \
605
+ "$(canonical_path "$REPO_ROOT" ".red/state/afk")" \
606
+ "$(canonical_path "$REPO_ROOT" ".red/tmp")"
607
+ do
608
+ case "$abs" in
609
+ "$dir"/afk-supervisor.*|"$dir")
610
+ if pid_file_live "$dir/afk-supervisor.pid"; then
611
+ printf 'refusing to delete live supervisor artifacts under %s' "$dir"
612
+ return 0
613
+ fi
614
+ ;;
615
+ esac
616
+ done
617
+ return 1
618
+ }
619
+
620
+ worker_delete_reason() {
621
+ local abs="$1" tmp_root ns prefix rel worker worker_dir
622
+ tmp_root="$(canonical_path "$REPO_ROOT" ".red/tmp")"
623
+ for ns in workers go-workers scout-workers; do
624
+ prefix="$tmp_root/$ns/"
625
+ case "$abs" in
626
+ "$prefix"*)
627
+ rel="${abs#$prefix}"
628
+ worker="${rel%%/*}"
629
+ [[ -n "$worker" ]] || continue
630
+ worker_dir="$prefix$worker"
631
+ if pid_file_live "$worker_dir/worker.pid"; then
632
+ printf 'refusing to delete live worker artifacts under %s' "$worker_dir"
633
+ return 0
634
+ fi
635
+ ;;
636
+ esac
637
+ done
638
+ return 1
639
+ }
640
+
641
+ delete_target_reason() {
642
+ local raw="$1" s abs tmp_root rel reason
643
+ s="$(strip_path_token "$raw")"
644
+ [[ -n "$s" ]] || return 1
645
+ abs="$(canonical_path "$ROOT" "$s")"
646
+ tmp_root="$(canonical_path "$REPO_ROOT" ".red/tmp")"
647
+
648
+ if [[ "$abs" == "$tmp_root" ]]; then
649
+ printf 'refusing to delete the .red/tmp root'
650
+ return 0
651
+ fi
652
+
653
+ case "$abs" in
654
+ "$tmp_root"/*)
655
+ rel="${abs#$tmp_root/}"
656
+ if [[ "$rel" == "*" ]]; then
657
+ printf 'refusing to delete every entry under .red/tmp'
658
+ return 0
659
+ fi
660
+ ;;
661
+ esac
662
+
663
+ reason="$(supervisor_delete_reason "$abs" || true)"
664
+ if [[ -n "$reason" ]]; then
665
+ printf '%s' "$reason"
666
+ return 0
667
+ fi
668
+
669
+ reason="$(worker_delete_reason "$abs" || true)"
670
+ if [[ -n "$reason" ]]; then
671
+ printf '%s' "$reason"
672
+ return 0
673
+ fi
674
+
675
+ return 1
676
+ }
677
+
678
+ deny_tmp_delete() {
679
+ local path="$1" reason="$2"
680
+ deny "$(cat <<EOF
681
+ BLOCKED by RedSkills tmp deletion guard.
682
+ plugins.dev.enabled is true, so cleanup commands must delete a named lane under .red/tmp/, not the tmp root or live liveness anchors.
683
+
684
+ Requested path: $path
685
+ Reason: $reason
686
+
687
+ Target a specific owned lane or stop the live supervisor/worker before removing its anchors.
688
+ EOF
689
+ )"
690
+ }
691
+
692
+ enforce_tmp_delete_policy() {
693
+ local -a tokens
694
+ read -ra tokens <<<"$COMMAND"
695
+ local n=${#tokens[@]}
696
+ ((n > 0)) || return 0
697
+
698
+ local i j arg reason
699
+ for ((i = 0; i < n; i++)); do
700
+ case "${tokens[i]}" in
701
+ rm|/bin/rm|/usr/bin/rm)
702
+ for ((j = i + 1; j < n; j++)); do
703
+ arg="${tokens[j]}"
704
+ case "$arg" in '>'|'>>'|'|'|'&&'|'||'|';') break ;; esac
705
+ [[ "$arg" == -- ]] && continue
706
+ [[ "${arg:0:1}" == "-" ]] && continue
707
+ reason="$(delete_target_reason "$arg" || true)"
708
+ [[ -n "$reason" ]] && deny_tmp_delete "$arg" "$reason"
709
+ done
710
+ ;;
711
+ esac
712
+ done
713
+ }
714
+
715
+ enforce_tmp_delete_policy
716
+
717
+ guard_scope_for_path() {
718
+ local path="$1"
719
+ case "$path" in
720
+ command_guard.global | \
721
+ command_guard.deny | \
722
+ plugins.dev.command_guard.global | \
723
+ plugins.dev.command_guard.deny | \
724
+ dev.command_guard.global | \
725
+ dev.command_guard.deny)
726
+ printf 'global'
727
+ ;;
728
+ command_guard.main | plugins.dev.command_guard.main | dev.command_guard.main)
729
+ printf 'main'
730
+ ;;
731
+ command_guard.worktree | plugins.dev.command_guard.worktree | dev.command_guard.worktree)
732
+ printf 'worktree'
733
+ ;;
734
+ *)
735
+ return 1
736
+ ;;
737
+ esac
738
+ }
739
+
740
+ emit_deny_pattern() {
741
+ local path="$1"
742
+ local raw_value="$2"
743
+ local scope item
744
+ scope="$(guard_scope_for_path "$path" || true)"
745
+ [[ -n "$scope" ]] || return 0
746
+ [[ "$scope" == "global" || "$scope" == "$COMMAND_GUARD_SCOPE" ]] || return 0
747
+ item="$(unquote_scalar "$raw_value")"
748
+ [[ -n "$item" ]] && printf '%s\t%s\n' "$scope" "$item"
749
+ }
750
+
751
+ read_deny_patterns() {
752
+ local -a STACK=()
753
+ local -a INDENTS=()
754
+ local raw line indent_str indent rest parent key value full
755
+
756
+ while IFS= read -r raw || [[ -n "$raw" ]]; do
757
+ raw="${raw%$'\r'}"
758
+ line="$(strip_line_comment "$raw")"
759
+ [[ -z "${line//[[:space:]]/}" ]] && continue
760
+ indent_str="${raw%%[![:space:]]*}"
761
+ indent=${#indent_str}
762
+ ((indent % 2 == 0)) || return 0
763
+ rest="${line:$indent}"
764
+
765
+ while ((${#INDENTS[@]} > 0 && INDENTS[${#INDENTS[@]} - 1] >= indent)); do
766
+ unset "STACK[$((${#STACK[@]} - 1))]"
767
+ unset "INDENTS[$((${#INDENTS[@]} - 1))]"
768
+ done
769
+
770
+ if [[ "$rest" =~ ^-([[:space:]]|$) ]]; then
771
+ parent="$(join_stack)"
772
+ emit_deny_pattern "$parent" "${rest#-}"
773
+ continue
774
+ fi
775
+
776
+ [[ "$rest" =~ ^[A-Za-z_][A-Za-z0-9_-]*: ]] || continue
777
+ key="${rest%%:*}"
778
+ value="${rest#*:}"
779
+ value="$(trim "$value")"
780
+ full="$(join_stack)"
781
+ [[ -n "$full" ]] && full+="."
782
+ full+="$key"
783
+
784
+ if [[ -z "$value" ]]; then
785
+ STACK+=("$key")
786
+ INDENTS+=("$indent")
787
+ continue
788
+ fi
789
+
790
+ emit_deny_pattern "$full" "$value"
791
+ done <"$CONFIG"
792
+ }
793
+
794
+ has_leading_command_boundary() {
795
+ local value="$1"
796
+ local first="${value:0:1}"
797
+ [[ -z "$value" ||
798
+ "$first" == ";" ||
799
+ "$first" == "&" ||
800
+ "$first" == "|" ||
801
+ "$first" =~ [[:space:]] ]]
802
+ }
803
+
804
+ has_trailing_command_boundary() {
805
+ local value="$1"
806
+ local last="${value: -1}"
807
+ [[ -z "$value" ||
808
+ "$last" == ";" ||
809
+ "$last" == "&" ||
810
+ "$last" == "|" ||
811
+ "$last" =~ [[:space:]] ]]
812
+ }
813
+
814
+ matches_rule() {
815
+ local command="$1"
816
+ local rule="$2"
817
+ local mode="" pattern="$rule"
818
+ case "$rule" in
819
+ regex:*|re:*)
820
+ pattern="${rule#*:}"
821
+ [[ -n "$pattern" && "$command" =~ $pattern ]]
822
+ return
823
+ ;;
824
+ prefix:*)
825
+ mode="prefix"
826
+ pattern="${rule#prefix:}"
827
+ ;;
828
+ suffix:*)
829
+ mode="suffix"
830
+ pattern="${rule#suffix:}"
831
+ ;;
832
+ exact:*)
833
+ mode="exact"
834
+ pattern="${rule#exact:}"
835
+ ;;
836
+ glob:*)
837
+ mode="glob"
838
+ pattern="${rule#glob:}"
839
+ ;;
840
+ esac
841
+
842
+ local left right
843
+ case "$mode" in
844
+ prefix)
845
+ right="${command#"$pattern"}"
846
+ [[ "$right" != "$command" ]] && has_leading_command_boundary "$right"
847
+ return
848
+ ;;
849
+ suffix)
850
+ left="${command%"$pattern"}"
851
+ [[ "$left" != "$command" ]] && has_trailing_command_boundary "$left"
852
+ return
853
+ ;;
854
+ exact)
855
+ [[ "$command" == "$pattern" ]]
856
+ return
857
+ ;;
858
+ glob)
859
+ [[ "$command" == $pattern ]]
860
+ return
861
+ ;;
862
+ esac
863
+
864
+ case "$rule" in
865
+ *[\*\?\[]*)
866
+ [[ "$command" == $rule ]]
867
+ ;;
868
+ *)
869
+ if [[ "$command" == "$rule" ]]; then
870
+ return 0
871
+ fi
872
+ right="${command#"$rule"}"
873
+ left="${command%"$rule"}"
874
+ ([[ "$right" != "$command" ]] && has_leading_command_boundary "$right") ||
875
+ ([[ "$left" != "$command" ]] && has_trailing_command_boundary "$left")
876
+ ;;
877
+ esac
878
+ }
879
+
880
+ while IFS=$'\t' read -r rule_scope rule; do
881
+ [[ -n "$rule" ]] || continue
882
+ if matches_rule "$COMMAND" "$rule"; then
883
+ deny "$(cat <<EOF
884
+ BLOCKED by RedSkills command guard.
885
+ The command '$COMMAND' matched command_guard.$rule_scope rule '$rule' from .red/config.yaml.
886
+
887
+ Remove or narrow command_guard.$rule_scope if this command is intentional.
888
+ EOF
889
+ )"
890
+ fi
891
+ done < <(read_deny_patterns)
892
+
893
+ allow