@mmerterden/multi-agent-pipeline 11.4.0 → 11.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 (54) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +1 -0
  3. package/index.js +9 -2
  4. package/install/_common.mjs +107 -5
  5. package/install/_telemetry.mjs +5 -23
  6. package/install/claude.mjs +70 -10
  7. package/install/copilot.mjs +75 -6
  8. package/package.json +4 -10
  9. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
  10. package/pipeline/lib/account-resolver.sh +61 -23
  11. package/pipeline/lib/channels-multi-repo.sh +7 -2
  12. package/pipeline/lib/count-lib.sh +27 -0
  13. package/pipeline/lib/credential-store.sh +23 -6
  14. package/pipeline/lib/extract-conventions.sh +27 -21
  15. package/pipeline/lib/fetch-confluence.sh +25 -13
  16. package/pipeline/lib/fetch-crashlytics.sh +30 -8
  17. package/pipeline/lib/fetch-fortify.sh +11 -2
  18. package/pipeline/lib/fetch-swagger.sh +18 -7
  19. package/pipeline/lib/figma-mcp-refresh.sh +26 -11
  20. package/pipeline/lib/figma-screenshot.sh +11 -2
  21. package/pipeline/lib/issue-fetcher.sh +31 -6
  22. package/pipeline/lib/multi-repo-pipeline.sh +84 -20
  23. package/pipeline/lib/plan-todos.sh +12 -3
  24. package/pipeline/lib/post-pr-review.sh +5 -3
  25. package/pipeline/lib/repo-cache.sh +53 -9
  26. package/pipeline/lib/review-watch.sh +26 -6
  27. package/pipeline/lib/shadow-git.sh +45 -4
  28. package/pipeline/lib/vercel-deploy.sh +10 -1
  29. package/pipeline/scripts/audit-log-rotate.sh +38 -10
  30. package/pipeline/scripts/check-md-links.mjs +34 -9
  31. package/pipeline/scripts/diff-risk-score.mjs +4 -1
  32. package/pipeline/scripts/evidence-gate.mjs +10 -1
  33. package/pipeline/scripts/fixtures/install-layout.tsv +4 -4
  34. package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
  35. package/pipeline/scripts/keychain-save.sh +13 -9
  36. package/pipeline/scripts/lint-skills.mjs +40 -2
  37. package/pipeline/scripts/migrate-prefs.mjs +26 -7
  38. package/pipeline/scripts/phase-tracker.sh +71 -0
  39. package/pipeline/scripts/pre-commit-check.sh +39 -0
  40. package/pipeline/scripts/scan-skills.sh +217 -127
  41. package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
  42. package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
  43. package/pipeline/scripts/smoke-install-layout.sh +11 -3
  44. package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
  45. package/pipeline/scripts/smoke-pack-contents.sh +140 -0
  46. package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
  47. package/pipeline/scripts/smoke-shadow-git.sh +48 -1
  48. package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
  49. package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
  50. package/pipeline/scripts/test-gap-scan.mjs +12 -1
  51. package/pipeline/scripts/triage-memory.mjs +10 -1
  52. package/pipeline/scripts/uninstall.mjs +105 -36
  53. package/pipeline/scripts/update-issue-progress.sh +60 -41
  54. package/pipeline/scripts/write-state.mjs +14 -4
@@ -289,6 +289,61 @@ save_state() {
289
289
  mv "${TRACKER_FILE}.tmp" "$TRACKER_FILE"
290
290
  }
291
291
 
292
+ # --- read-modify-write lock ---------------------------------------------------
293
+ # tmp+rename makes each save atomic, but two concurrent invocations (e.g. two
294
+ # parallel Phase 4 reviewers reporting `tokens`) both load the same state and
295
+ # the second save silently drops the first delta. Guard every load->save
296
+ # sequence with a portable mkdir spinlock (macOS bash 3.2: no flock builtin).
297
+ # The wait is bounded and the lock FAILS OPEN with a warning so a stuck lock
298
+ # can never hang the pipeline; a dead owner pid or a lock older than 30s is
299
+ # reclaimed.
300
+ TRACKER_LOCK_DIR=""
301
+ TRACKER_LOCK_HELD=0
302
+
303
+ state_lock_stale() {
304
+ local pid mtime now age
305
+ pid=$(cat "$TRACKER_LOCK_DIR/pid" 2>/dev/null || true)
306
+ if [ -n "$pid" ]; then
307
+ kill -0 "$pid" 2>/dev/null && return 1
308
+ return 0
309
+ fi
310
+ mtime=$(stat -f %m "$TRACKER_LOCK_DIR" 2>/dev/null || stat -c %Y "$TRACKER_LOCK_DIR" 2>/dev/null || echo "")
311
+ [ -z "$mtime" ] && return 1
312
+ now=$(date +%s)
313
+ age=$((now - mtime))
314
+ [ "$age" -gt 30 ]
315
+ }
316
+
317
+ acquire_state_lock() {
318
+ TRACKER_LOCK_DIR="${TRACKER_FILE}.lock"
319
+ mkdir -p "$TRACKER_DIR" 2>/dev/null
320
+ local tries=0
321
+ # ~5s bound: 50 tries x 0.1s.
322
+ while ! mkdir "$TRACKER_LOCK_DIR" 2>/dev/null; do
323
+ if state_lock_stale; then
324
+ rm -rf "$TRACKER_LOCK_DIR" 2>/dev/null
325
+ continue
326
+ fi
327
+ tries=$((tries + 1))
328
+ if [ "$tries" -ge 50 ]; then
329
+ echo "phase-tracker: WARN - state lock busy ($TRACKER_LOCK_DIR); proceeding without lock" >&2
330
+ return 0
331
+ fi
332
+ sleep 0.1
333
+ done
334
+ echo "$$" > "$TRACKER_LOCK_DIR/pid" 2>/dev/null
335
+ TRACKER_LOCK_HELD=1
336
+ }
337
+
338
+ release_state_lock() {
339
+ if [ "$TRACKER_LOCK_HELD" = "1" ]; then
340
+ rm -rf "$TRACKER_LOCK_DIR" 2>/dev/null
341
+ TRACKER_LOCK_HELD=0
342
+ fi
343
+ }
344
+ # Never leave a lock behind if a mutating action dies mid-critical-section.
345
+ trap release_state_lock EXIT
346
+
292
347
  render() {
293
348
  need_jq
294
349
  local state
@@ -509,8 +564,10 @@ case "$ACTION" in
509
564
  TRACKER_DIR="$(dirname "$TRACKER_FILE")"
510
565
  mkdir -p "$TRACKER_DIR" 2>/dev/null
511
566
  NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
567
+ acquire_state_lock
512
568
  save_state "$(jq -nc --arg id "$TASK_ID" --arg ts "$NOW" \
513
569
  '{task_id:$id,started_at:$ts,phases:[]}')"
570
+ release_state_lock
514
571
  # Update pointer so subsequent calls without env can find this tracker.
515
572
  # NOTE: the pointer is global; for concurrent runs on the same user account,
516
573
  # callers should set MULTI_AGENT_TASK_ID in their shell or prefix every
@@ -528,6 +585,7 @@ case "$ACTION" in
528
585
  need_jq
529
586
  [ "$#" -ge 2 ] || { echo "add needs <phase_id> <name>" >&2; exit 64; }
530
587
  PID="$1"; PNAME="$2"
588
+ acquire_state_lock
531
589
  state=$(load_state)
532
590
  # Idempotent: a phase id that already exists is a no-op (name, status, and
533
591
  # token history are preserved). This is what lets continuation commands
@@ -538,6 +596,7 @@ case "$ACTION" in
538
596
  else .phases += [{"id":$id,"name":$name,"status":"pending","subs":[]}]
539
597
  end')
540
598
  save_state "$new"
599
+ release_state_lock
541
600
  render
542
601
  ;;
543
602
 
@@ -550,6 +609,7 @@ case "$ACTION" in
550
609
  *) echo "bad status: $STATUS" >&2; exit 64 ;;
551
610
  esac
552
611
  NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
612
+ acquire_state_lock
553
613
  state=$(load_state)
554
614
  # Set status, stamp started_at on first transition to in_progress,
555
615
  # stamp completed_at on transition to terminal states (completed/failed/skipped).
@@ -563,6 +623,7 @@ case "$ACTION" in
563
623
  )
564
624
  ')
565
625
  save_state "$new"
626
+ release_state_lock
566
627
  emit_otel_span "phase.update" "$PID" "$STATUS" "{\"multi_agent.status\": \"$STATUS\"}"
567
628
  render
568
629
  ;;
@@ -575,6 +636,7 @@ case "$ACTION" in
575
636
  pending|in_progress|completed|failed|skipped) ;;
576
637
  *) echo "bad status: $SSTATUS" >&2; exit 64 ;;
577
638
  esac
639
+ acquire_state_lock
578
640
  state=$(load_state)
579
641
  # If sub with this id exists under this phase, update; else append.
580
642
  new=$(echo "$state" | jq \
@@ -590,6 +652,7 @@ case "$ACTION" in
590
652
  else . end
591
653
  )')
592
654
  save_state "$new"
655
+ release_state_lock
593
656
  emit_otel_span "phase.sub" "$PID" "$SNAME" "{\"multi_agent.sub_id\": \"$SID\", \"multi_agent.status\": \"$SSTATUS\"}"
594
657
  render
595
658
  ;;
@@ -602,6 +665,7 @@ case "$ACTION" in
602
665
  case "$T_IN$T_OUT$T_CACHED" in
603
666
  *[!0-9]*) echo "tokens: in/out/cached must be non-negative integers" >&2; exit 64 ;;
604
667
  esac
668
+ acquire_state_lock
605
669
  state=$(load_state)
606
670
  # Additive: add to existing totals so multiple LLM calls accumulate.
607
671
  new=$(echo "$state" | jq --arg id "$PID" --argjson ti "$T_IN" --argjson to "$T_OUT" --argjson tc "$T_CACHED" '
@@ -614,6 +678,7 @@ case "$ACTION" in
614
678
  )
615
679
  ')
616
680
  save_state "$new"
681
+ release_state_lock
617
682
  emit_otel_span "phase.tokens" "$PID" "" "{\"multi_agent.tokens_in_delta\": $T_IN, \"multi_agent.tokens_out_delta\": $T_OUT, \"multi_agent.tokens_cached_delta\": $T_CACHED}"
618
683
  # Re-render so live token count is visible on the active phase. Callers
619
684
  # that want silent accumulation (e.g. tight loops) can suppress with
@@ -627,6 +692,7 @@ case "$ACTION" in
627
692
  need_jq
628
693
  [ "$#" -ge 3 ] || { echo "meta needs <phase_id> <key> <value>" >&2; exit 64; }
629
694
  PID="$1"; KEY="$2"; VALUE="$3"
695
+ acquire_state_lock
630
696
  state=$(load_state)
631
697
  new=$(echo "$state" | jq --arg id "$PID" --arg k "$KEY" --arg v "$VALUE" '
632
698
  .phases |= map(
@@ -635,6 +701,7 @@ case "$ACTION" in
635
701
  else . end
636
702
  )')
637
703
  save_state "$new"
704
+ release_state_lock
638
705
  emit_otel_span "phase.meta" "$PID" "$KEY" "{\"multi_agent.meta_key\": \"$KEY\", \"multi_agent.meta_value\": \"$VALUE\"}"
639
706
  if [ "${TRACKER_QUIET:-0}" != "1" ]; then
640
707
  render
@@ -645,12 +712,14 @@ case "$ACTION" in
645
712
  need_jq
646
713
  [ "$#" -ge 2 ] || { echo "model needs <phase_id> <model_name>" >&2; exit 64; }
647
714
  PID="$1"; MODEL="$2"
715
+ acquire_state_lock
648
716
  state=$(load_state)
649
717
  new=$(echo "$state" | jq --arg id "$PID" --arg m "$MODEL" '
650
718
  .phases |= map(
651
719
  if .id == $id then .model = $m else . end
652
720
  )')
653
721
  save_state "$new"
722
+ release_state_lock
654
723
  emit_otel_span "phase.model" "$PID" "" "{\"multi_agent.model\": \"$MODEL\"}"
655
724
  if [ "${TRACKER_QUIET:-0}" != "1" ]; then
656
725
  render
@@ -667,12 +736,14 @@ case "$ACTION" in
667
736
  if [ "${#TEXT}" -gt 60 ]; then
668
737
  TEXT="${TEXT:0:59}…"
669
738
  fi
739
+ acquire_state_lock
670
740
  state=$(load_state)
671
741
  new=$(echo "$state" | jq --arg id "$PID" --arg v "$TEXT" '
672
742
  .phases |= map(
673
743
  if .id == $id then .meta = ((.meta // {}) + {"Now": $v}) else . end
674
744
  )')
675
745
  save_state "$new"
746
+ release_state_lock
676
747
  emit_otel_span "phase.now" "$PID" "$TEXT" "{}"
677
748
  ;;
678
749
 
@@ -5,6 +5,45 @@
5
5
 
6
6
  set -uo pipefail
7
7
 
8
+ # Hook-mode fast exit. The Claude Code PreToolUse matcher is the tool name
9
+ # ("Bash"), so this script now fires for EVERY Bash call and receives JSON on
10
+ # stdin: {"tool_name":"Bash","tool_input":{"command":"..."}}. Extract the
11
+ # command and exit 0 immediately unless it actually invokes `git commit`.
12
+ # Direct invocations (empty/non-JSON stdin, e.g. smoke tests or manual runs)
13
+ # fall through to the full scan. Extraction failure also falls through - the
14
+ # only cost is an unnecessary scan, never a skipped one on a real commit.
15
+ if [ ! -t 0 ]; then
16
+ HOOK_INPUT="$(cat 2>/dev/null || true)"
17
+ if [ -n "$HOOK_INPUT" ]; then
18
+ HOOK_COMMAND=""
19
+ if command -v python3 >/dev/null 2>&1; then
20
+ HOOK_COMMAND="$(printf '%s' "$HOOK_INPUT" | python3 -c '
21
+ import json, sys
22
+ try:
23
+ print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))
24
+ except Exception:
25
+ pass
26
+ ' 2>/dev/null || true)"
27
+ elif command -v node >/dev/null 2>&1; then
28
+ HOOK_COMMAND="$(printf '%s' "$HOOK_INPUT" | node -e '
29
+ let raw = "";
30
+ process.stdin.on("data", (c) => (raw += c));
31
+ process.stdin.on("end", () => {
32
+ try {
33
+ process.stdout.write(String(JSON.parse(raw)?.tool_input?.command ?? ""));
34
+ } catch { /* fall through to full scan */ }
35
+ });
36
+ ' 2>/dev/null || true)"
37
+ fi
38
+ if [ -n "$HOOK_COMMAND" ]; then
39
+ if ! printf '%s\n' "$HOOK_COMMAND" \
40
+ | grep -qE '(^|[^[:alnum:]_./-])git[[:space:]]+([^|&;]*[[:space:]])?commit([^[:alnum:]_-]|$)'; then
41
+ exit 0
42
+ fi
43
+ fi
44
+ fi
45
+ fi
46
+
8
47
  if [ -z "$(git diff --cached --name-only 2>/dev/null)" ]; then
9
48
  exit 0
10
49
  fi
@@ -85,12 +85,24 @@ add_finding() {
85
85
  FINDINGS+=("$sev|$file|$line|$pattern|$message")
86
86
  }
87
87
 
88
- # --- Pattern scanning ---------------------------------------------------
88
+ # --- Pattern scanning (single-pass tree-wide) ----------------------------
89
+ #
90
+ # Each pattern family runs as ONE grep over the whole file list instead of
91
+ # one grep per file (the per-file design spawned thousands of processes and
92
+ # dominated install time). Findings are tagged with (fileIdx, familyIdx,
93
+ # seq) sort keys and re-ordered afterwards so the output is byte-identical
94
+ # to the historical per-file scan order:
95
+ # per file: critical(1 shell-pipe, 2 base64-pipe, 3 eval-net, 4 bidi)
96
+ # high(5 js-eval / 6 py-exec, 7 credential, 8 pastebin, 9 chmod)
97
+ # medium(10 long-base64) low(11 unknown-endpoint, 12 SKILL.md)
89
98
 
90
99
  # Files to consider: *.md, *.sh, *.py, *.mjs, *.js, *.ts (skill content)
91
100
  # Exclude: binary, images, large fixtures, hidden
92
- SCAN_LIST=$(mktemp)
93
- trap 'rm -f "$SCAN_LIST"' EXIT
101
+ WORK="$(mktemp -d)"
102
+ trap 'rm -rf "$WORK"' EXIT
103
+ SCAN_LIST="$WORK/files"
104
+ RAW="$WORK/raw"
105
+ : > "$RAW"
94
106
 
95
107
  find "$ROOT" -type f \
96
108
  \( -name "*.md" -o -name "*.sh" -o -name "*.py" -o -name "*.mjs" -o -name "*.js" -o -name "*.ts" \) \
@@ -98,156 +110,230 @@ find "$ROOT" -type f \
98
110
  -not -path "*/.git/*" \
99
111
  2>/dev/null > "$SCAN_LIST"
100
112
 
101
- scan_critical() {
102
- local f="$1"
103
- # curl|wget piping to shell
104
- while IFS= read -r hit; do
105
- [ -z "$hit" ] && continue
106
- local ln="${hit%%:*}"
107
- add_finding critical "$f" "$ln" "shell-pipe-exec" "curl/wget piped to shell interpreter"
108
- done < <(grep -nE '(curl|wget)[^|]*\|[[:space:]]*(sh|bash|zsh|ksh|ash|dash)([[:space:]]|$)' "$f" 2>/dev/null)
113
+ FILE_COUNT=$(wc -l < "$SCAN_LIST" | tr -d '[:space:]')
109
114
 
110
- # base64 -d | shell
111
- while IFS= read -r hit; do
112
- [ -z "$hit" ] && continue
113
- local ln="${hit%%:*}"
114
- add_finding critical "$f" "$ln" "base64-pipe-exec" "base64 decoded output piped to shell"
115
- done < <(grep -nE '(base64|openssl[[:space:]]+base64)[^|]*-d[^|]*\|[[:space:]]*(sh|bash|zsh|eval|sudo)' "$f" 2>/dev/null)
115
+ # One grep invocation for a whole pattern family. xargs preserves argument
116
+ # order, so hits come out grouped per file in SCAN_LIST order.
117
+ tree_grep() {
118
+ tr '\n' '\0' < "$SCAN_LIST" | xargs -0 grep -nHE -- "$1" 2>/dev/null
119
+ return 0
120
+ }
116
121
 
117
- # eval $(curl|wget ...) or eval `curl ...`
118
- while IFS= read -r hit; do
119
- [ -z "$hit" ] && continue
120
- local ln="${hit%%:*}"
121
- add_finding critical "$f" "$ln" "eval-of-network" "eval of network-fetched content"
122
- done < <(grep -nE 'eval[[:space:]]+(\$\([[:space:]]*(curl|wget|fetch)|`[[:space:]]*(curl|wget|fetch))' "$f" 2>/dev/null)
122
+ # stdin: "path:line:content" grep hits -> stdout: "fileIdx|path|line|content"
123
+ index_hits() {
124
+ awk -v listfile="$SCAN_LIST" '
125
+ BEGIN {
126
+ i = 0
127
+ while ((getline l < listfile) > 0) { i++; idx[l] = i }
128
+ close(listfile)
129
+ }
130
+ {
131
+ p = index($0, ":"); f = substr($0, 1, p - 1)
132
+ rest = substr($0, p + 1)
133
+ q = index(rest, ":"); ln = substr(rest, 1, q - 1)
134
+ printf "%d|%s|%s|%s\n", (f in idx ? idx[f] : 999999), f, ln, substr(rest, q + 1)
135
+ }'
136
+ }
123
137
 
124
- # Unicode bidi override characters - invisible injection attack
125
- if LC_ALL=C grep -l $'\xe2\x80\xad\|\xe2\x80\xae\|\xe2\x81\xa6\|\xe2\x81\xa7\|\xe2\x81\xa8\|\xe2\x81\xa9' "$f" 2>/dev/null >/dev/null; then
126
- add_finding critical "$f" "0" "unicode-bidi" "bidirectional control chars (U+202D-202E, U+2066-2069) - trojan source risk"
127
- fi
138
+ # stdin: bare file paths -> stdout: "fileIdx|path"
139
+ index_files() {
140
+ awk -v listfile="$SCAN_LIST" '
141
+ BEGIN {
142
+ i = 0
143
+ while ((getline l < listfile) > 0) { i++; idx[l] = i }
144
+ close(listfile)
145
+ }
146
+ { printf "%d|%s\n", ($0 in idx ? idx[$0] : 999999), $0 }'
128
147
  }
129
148
 
130
- scan_high() {
131
- local f="$1"
132
- local ext="${f##*.}"
149
+ emit_raw() {
150
+ # $1 fileIdx $2 familyIdx $3 seq $4 sev $5 file $6 line $7 pattern $8 message
151
+ printf '%s|%s|%s|%s|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >> "$RAW"
152
+ }
153
+
154
+ # Split an indexed hit "fileIdx|path|line|content" into HIT_IDX/HIT_FILE/
155
+ # HIT_LINE/HIT_CONTENT (content may itself contain pipes - it is the rest).
156
+ parse_hit() {
157
+ HIT_IDX="${1%%|*}"; local rest="${1#*|}"
158
+ HIT_FILE="${rest%%|*}"; rest="${rest#*|}"
159
+ HIT_LINE="${rest%%|*}"
160
+ HIT_CONTENT="${rest#*|}"
161
+ }
133
162
 
163
+ # --- critical families (rank 0 - always at/above threshold) ------------
164
+
165
+ seq=0
166
+ while IFS= read -r hit; do
167
+ [ -z "$hit" ] && continue
168
+ parse_hit "$hit"
169
+ seq=$((seq+1))
170
+ emit_raw "$HIT_IDX" 1 "$seq" critical "$HIT_FILE" "$HIT_LINE" "shell-pipe-exec" "curl/wget piped to shell interpreter"
171
+ done < <(tree_grep '(curl|wget)[^|]*\|[[:space:]]*(sh|bash|zsh|ksh|ash|dash)([[:space:]]|$)' | index_hits)
172
+
173
+ seq=0
174
+ while IFS= read -r hit; do
175
+ [ -z "$hit" ] && continue
176
+ parse_hit "$hit"
177
+ seq=$((seq+1))
178
+ emit_raw "$HIT_IDX" 2 "$seq" critical "$HIT_FILE" "$HIT_LINE" "base64-pipe-exec" "base64 decoded output piped to shell"
179
+ done < <(tree_grep '(base64|openssl[[:space:]]+base64)[^|]*-d[^|]*\|[[:space:]]*(sh|bash|zsh|eval|sudo)' | index_hits)
180
+
181
+ seq=0
182
+ while IFS= read -r hit; do
183
+ [ -z "$hit" ] && continue
184
+ parse_hit "$hit"
185
+ seq=$((seq+1))
186
+ emit_raw "$HIT_IDX" 3 "$seq" critical "$HIT_FILE" "$HIT_LINE" "eval-of-network" "eval of network-fetched content"
187
+ done < <(tree_grep 'eval[[:space:]]+(\$\([[:space:]]*(curl|wget|fetch)|`[[:space:]]*(curl|wget|fetch))' | index_hits)
188
+
189
+ # Unicode bidi override characters - invisible injection attack
190
+ seq=0
191
+ while IFS= read -r row; do
192
+ [ -z "$row" ] && continue
193
+ seq=$((seq+1))
194
+ emit_raw "${row%%|*}" 4 "$seq" critical "${row#*|}" "0" "unicode-bidi" "bidirectional control chars (U+202D-202E, U+2066-2069) - trojan source risk"
195
+ done < <(tr '\n' '\0' < "$SCAN_LIST" \
196
+ | LC_ALL=C xargs -0 grep -l $'\xe2\x80\xad\|\xe2\x80\xae\|\xe2\x81\xa6\|\xe2\x81\xa7\|\xe2\x81\xa8\|\xe2\x81\xa9' 2>/dev/null \
197
+ | index_files; true)
198
+
199
+ # --- high families (rank 1) ----------------------------------------------
200
+
201
+ if [ "$THRESHOLD_RANK" -ge 1 ]; then
134
202
  # JavaScript/TypeScript: eval(, new Function(, Function(
135
- if [ "$ext" = "js" ] || [ "$ext" = "mjs" ] || [ "$ext" = "ts" ]; then
136
- while IFS= read -r hit; do
137
- [ -z "$hit" ] && continue
138
- local ln="${hit%%:*}"
139
- add_finding high "$f" "$ln" "js-dynamic-eval" "JavaScript dynamic code execution"
140
- done < <(grep -nE '\b(eval|new[[:space:]]+Function|Function)\s*\(' "$f" 2>/dev/null)
141
- fi
203
+ seq=0
204
+ while IFS= read -r hit; do
205
+ [ -z "$hit" ] && continue
206
+ parse_hit "$hit"
207
+ case "$HIT_FILE" in *.js|*.mjs|*.ts) ;; *) continue ;; esac
208
+ seq=$((seq+1))
209
+ emit_raw "$HIT_IDX" 5 "$seq" high "$HIT_FILE" "$HIT_LINE" "js-dynamic-eval" "JavaScript dynamic code execution"
210
+ done < <(tree_grep '\b(eval|new[[:space:]]+Function|Function)\s*\(' | index_hits)
142
211
 
143
212
  # Python: exec(, eval( on non-literal - exclude re.compile (regex) and subprocess
144
- if [ "$ext" = "py" ]; then
145
- while IFS= read -r hit; do
146
- [ -z "$hit" ] && continue
147
- local ln="${hit%%:*}"
148
- local content="${hit#*:}"
149
- # Skip re.compile (standard regex) and subprocess.* (legitimate process invocation)
150
- echo "$content" | grep -qE '(\bre\.compile|\bre2\.compile|subprocess\.|typing\.)' && continue
151
- add_finding high "$f" "$ln" "py-dynamic-exec" "Python dynamic code execution"
152
- done < <(grep -nE '(^|[^a-zA-Z0-9_.])(exec|eval)[[:space:]]*\(' "$f" 2>/dev/null)
153
- fi
213
+ seq=0
214
+ while IFS= read -r hit; do
215
+ [ -z "$hit" ] && continue
216
+ parse_hit "$hit"
217
+ case "$HIT_FILE" in *.py) ;; *) continue ;; esac
218
+ # Skip re.compile (standard regex) and subprocess.* (legitimate process invocation)
219
+ echo "$HIT_CONTENT" | grep -qE '(\bre\.compile|\bre2\.compile|subprocess\.|typing\.)' && continue
220
+ seq=$((seq+1))
221
+ emit_raw "$HIT_IDX" 6 "$seq" high "$HIT_FILE" "$HIT_LINE" "py-dynamic-exec" "Python dynamic code execution"
222
+ done < <(tree_grep '(^|[^a-zA-Z0-9_.])(exec|eval)[[:space:]]*\(' | index_hits)
154
223
 
155
224
  # Hardcoded API keys - AWS, OpenAI, GitHub, generic sk-* with length
156
225
  # Skip lines in FORBIDDEN/NEVER/example blocks (false positives from docs)
226
+ seq=0
157
227
  while IFS= read -r hit; do
158
228
  [ -z "$hit" ] && continue
159
- local ln="${hit%%:*}"
160
- # Check ±3 lines around match for documentation context markers
161
- local ctx_start=$((ln - 3))
229
+ parse_hit "$hit"
230
+ # Check +/-3 lines around match for documentation context markers
231
+ ctx_start=$((HIT_LINE - 3))
162
232
  [ "$ctx_start" -lt 1 ] && ctx_start=1
163
- local ctx_end=$((ln + 3))
164
- local ctx
165
- ctx=$(sed -n "${ctx_start},${ctx_end}p" "$f" 2>/dev/null)
233
+ ctx_end=$((HIT_LINE + 3))
234
+ ctx=$(sed -n "${ctx_start},${ctx_end}p" "$HIT_FILE" 2>/dev/null)
166
235
  if echo "$ctx" | grep -qiE '(FORBIDDEN|NEVER do|don.t do|example:|placeholder|sample credential|DO NOT|✗|XXX|YYY|dummy|fake.?key)'; then
167
236
  continue
168
237
  fi
169
- add_finding high "$f" "$ln" "hardcoded-credential" "possible hardcoded API credential"
170
- done < <(grep -nE '(AKIA[0-9A-Z]{16}|sk-(live|test|proj|ant|or)-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82}|gho_[A-Za-z0-9]{36}|xox[bp]-[A-Za-z0-9-]{10,})' "$f" 2>/dev/null)
238
+ seq=$((seq+1))
239
+ emit_raw "$HIT_IDX" 7 "$seq" high "$HIT_FILE" "$HIT_LINE" "hardcoded-credential" "possible hardcoded API credential"
240
+ done < <(tree_grep '(AKIA[0-9A-Z]{16}|sk-(live|test|proj|ant|or)-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82}|gho_[A-Za-z0-9]{36}|xox[bp]-[A-Za-z0-9-]{10,})' | index_hits)
171
241
 
172
242
  # Pastebin / URL shortener raw content fetching
243
+ seq=0
173
244
  while IFS= read -r hit; do
174
245
  [ -z "$hit" ] && continue
175
- local ln="${hit%%:*}"
176
- add_finding high "$f" "$ln" "pastebin-fetch" "fetch from ephemeral/obscured content host"
177
- done < <(grep -nE 'https?://(pastebin\.com/raw|paste\.ee|ghostbin|hastebin|bit\.ly|tinyurl\.com|goo\.gl|t\.co|is\.gd|ow\.ly|rebrand\.ly|gist\.github\.com/[^/]+/[a-f0-9]+/raw)' "$f" 2>/dev/null)
246
+ parse_hit "$hit"
247
+ seq=$((seq+1))
248
+ emit_raw "$HIT_IDX" 8 "$seq" high "$HIT_FILE" "$HIT_LINE" "pastebin-fetch" "fetch from ephemeral/obscured content host"
249
+ done < <(tree_grep 'https?://(pastebin\.com/raw|paste\.ee|ghostbin|hastebin|bit\.ly|tinyurl\.com|goo\.gl|t\.co|is\.gd|ow\.ly|rebrand\.ly|gist\.github\.com/[^/]+/[a-f0-9]+/raw)' | index_hits)
178
250
 
179
251
  # chmod +x followed by execution on same file in same block
252
+ seq=0
180
253
  while IFS= read -r hit; do
181
254
  [ -z "$hit" ] && continue
182
- local ln="${hit%%:*}"
183
- add_finding high "$f" "$ln" "chmod-then-exec" "script made executable and immediately invoked"
184
- done < <(grep -nE 'chmod[[:space:]]+\+x[[:space:]]+[^&;]+[[:space:]]*(&&|;)[[:space:]]*\./' "$f" 2>/dev/null)
185
- }
255
+ parse_hit "$hit"
256
+ seq=$((seq+1))
257
+ emit_raw "$HIT_IDX" 9 "$seq" high "$HIT_FILE" "$HIT_LINE" "chmod-then-exec" "script made executable and immediately invoked"
258
+ done < <(tree_grep 'chmod[[:space:]]+\+x[[:space:]]+[^&;]+[[:space:]]*(&&|;)[[:space:]]*\./' | index_hits)
259
+ fi
186
260
 
187
- scan_medium() {
188
- local f="$1"
261
+ # --- medium families (rank 2) ---------------------------------------------
189
262
 
263
+ if [ "$THRESHOLD_RANK" -ge 2 ]; then
190
264
  # Long base64 blobs - obfuscation indicator.
191
265
  # Skip .md files (documentation often shows example b64) - only scan executable content types.
192
- case "$f" in
193
- *.md) ;;
194
- *)
195
- while IFS= read -r hit; do
196
- [ -z "$hit" ] && continue
197
- local ln="${hit%%:*}"
198
- add_finding medium "$f" "$ln" "long-base64" "base64-looking blob >200 chars (possible obfuscation)"
199
- done < <(grep -nE '[A-Za-z0-9+/]{200,}={0,2}' "$f" 2>/dev/null)
200
- ;;
201
- esac
202
- }
266
+ seq=0
267
+ while IFS= read -r hit; do
268
+ [ -z "$hit" ] && continue
269
+ parse_hit "$hit"
270
+ case "$HIT_FILE" in *.md) continue ;; esac
271
+ seq=$((seq+1))
272
+ emit_raw "$HIT_IDX" 10 "$seq" medium "$HIT_FILE" "$HIT_LINE" "long-base64" "base64-looking blob >200 chars (possible obfuscation)"
273
+ done < <(tree_grep '[A-Za-z0-9+/]{200,}={0,2}' | index_hits)
274
+ fi
203
275
 
204
- scan_info() {
205
- local f="$1"
276
+ # --- low families (rank 3) -------------------------------------------------
206
277
 
278
+ if [ "$THRESHOLD_RANK" -ge 3 ]; then
207
279
  # Unknown network endpoints - LOW severity (informational). URLs in skills
208
280
  # are usually doc references, not exfil channels. Only the critical
209
281
  # shell-pipe-exec patterns detect active network abuse.
282
+ # Historical behavior: at most the first 20 URL-bearing lines per file.
283
+ url_re='https?://[a-zA-Z0-9._-]+'
284
+ allow_re="^${ALLOW_DOMAINS}\$"
285
+ seq=0
286
+ prev_file=""
287
+ url_lines=0
210
288
  while IFS= read -r hit; do
211
289
  [ -z "$hit" ] && continue
212
- local ln="${hit%%:*}"
213
- local url_line="${hit#*:}"
214
- local url
215
- url=$(echo "$url_line" | grep -oE 'https?://[a-zA-Z0-9._-]+' | head -1)
216
- [ -z "$url" ] && continue
217
- local host="${url#*://}"
290
+ parse_hit "$hit"
291
+ if [ "$HIT_FILE" != "$prev_file" ]; then
292
+ prev_file="$HIT_FILE"
293
+ url_lines=0
294
+ fi
295
+ url_lines=$((url_lines+1))
296
+ [ "$url_lines" -gt 20 ] && continue
297
+ [[ "$HIT_CONTENT" =~ $url_re ]] || continue
298
+ url="${BASH_REMATCH[0]}"
299
+ host="${url#*://}"
218
300
  host="${host%%/*}"
219
- if ! echo "$host" | grep -qE "^${ALLOW_DOMAINS}$" 2>/dev/null; then
220
- add_finding low "$f" "$ln" "unknown-endpoint" "network endpoint not in allow-list: $host"
301
+ if ! [[ "$host" =~ $allow_re ]]; then
302
+ seq=$((seq+1))
303
+ emit_raw "$HIT_IDX" 11 "$seq" low "$HIT_FILE" "$HIT_LINE" "unknown-endpoint" "network endpoint not in allow-list: $host"
221
304
  fi
222
- done < <(grep -nE 'https?://[a-zA-Z0-9._-]+' "$f" 2>/dev/null | head -20)
223
- }
305
+ done < <(tree_grep 'https?://[a-zA-Z0-9._-]+' | index_hits)
224
306
 
225
- scan_low() {
226
- local f="$1"
227
307
  # Missing SKILL.md frontmatter (only check files named SKILL.md)
228
- case "$f" in
229
- */SKILL.md)
230
- if ! head -1 "$f" | grep -Fxq -- "---"; then
231
- add_finding low "$f" "1" "missing-frontmatter" "SKILL.md without YAML frontmatter"
232
- elif ! grep -qE '^description:' "$f"; then
233
- add_finding low "$f" "0" "missing-description" "SKILL.md frontmatter missing 'description'"
234
- fi
235
- ;;
236
- esac
237
- }
238
-
239
- # --- Run scan -----------------------------------------------------------
308
+ seq=0
309
+ file_idx=0
310
+ while IFS= read -r file; do
311
+ file_idx=$((file_idx+1))
312
+ [ -z "$file" ] && continue
313
+ case "$file" in
314
+ */SKILL.md)
315
+ if ! head -1 "$file" | grep -Fxq -- "---"; then
316
+ seq=$((seq+1))
317
+ emit_raw "$file_idx" 12 "$seq" low "$file" "1" "missing-frontmatter" "SKILL.md without YAML frontmatter"
318
+ elif ! grep -qE '^description:' "$file"; then
319
+ seq=$((seq+1))
320
+ emit_raw "$file_idx" 12 "$seq" low "$file" "0" "missing-description" "SKILL.md frontmatter missing 'description'"
321
+ fi
322
+ ;;
323
+ esac
324
+ done < "$SCAN_LIST"
325
+ fi
240
326
 
241
- FILE_COUNT=0
242
- while IFS= read -r file; do
243
- [ -z "$file" ] && continue
244
- FILE_COUNT=$((FILE_COUNT+1))
245
- scan_critical "$file"
246
- scan_high "$file"
247
- scan_medium "$file"
248
- scan_info "$file"
249
- scan_low "$file"
250
- done < "$SCAN_LIST"
327
+ # Re-order into the historical per-file scan order and load into FINDINGS.
328
+ while IFS= read -r row; do
329
+ [ -z "$row" ] && continue
330
+ rest="${row#*|}"; rest="${rest#*|}"; rest="${rest#*|}" # drop idx|fam|seq|
331
+ sev="${rest%%|*}"; rest="${rest#*|}"
332
+ file="${rest%%|*}"; rest="${rest#*|}"
333
+ line="${rest%%|*}"; rest="${rest#*|}"
334
+ pat="${rest%%|*}"; msg="${rest#*|}"
335
+ add_finding "$sev" "$file" "$line" "$pat" "$msg"
336
+ done < <(sort -t'|' -k1,1n -k2,2n -k3,3n "$RAW")
251
337
 
252
338
  # --- Report -------------------------------------------------------------
253
339
 
@@ -261,20 +347,24 @@ if [ "$JSON" -eq 1 ]; then
261
347
  printf ' "findings": [\n'
262
348
  local_i=0
263
349
  total=${#FINDINGS[@]}
264
- for f in "${FINDINGS[@]:-}"; do
265
- local_i=$((local_i+1))
266
- sev="${f%%|*}"; rest="${f#*|}"
267
- file="${rest%%|*}"; rest="${rest#*|}"
268
- line="${rest%%|*}"; rest="${rest#*|}"
269
- pat="${rest%%|*}"; msg="${rest#*|}"
270
- # json-escape file/msg minimally
271
- jf=$(printf '%s' "$file" | sed 's/"/\\"/g')
272
- jm=$(printf '%s' "$msg" | sed 's/"/\\"/g')
273
- printf ' { "severity": "%s", "file": "%s", "line": "%s", "pattern": "%s", "message": "%s" }' \
274
- "$sev" "$jf" "$line" "$pat" "$jm"
275
- [ "$local_i" -lt "$total" ] && printf ','
276
- printf '\n'
277
- done
350
+ # Guard the expansion: on an empty array "${FINDINGS[@]:-}" yields one empty
351
+ # word, which used to emit a spurious all-empty findings element.
352
+ if [ "$total" -gt 0 ]; then
353
+ for f in "${FINDINGS[@]}"; do
354
+ local_i=$((local_i+1))
355
+ sev="${f%%|*}"; rest="${f#*|}"
356
+ file="${rest%%|*}"; rest="${rest#*|}"
357
+ line="${rest%%|*}"; rest="${rest#*|}"
358
+ pat="${rest%%|*}"; msg="${rest#*|}"
359
+ # json-escape file/msg minimally
360
+ jf=$(printf '%s' "$file" | sed 's/"/\\"/g')
361
+ jm=$(printf '%s' "$msg" | sed 's/"/\\"/g')
362
+ printf ' { "severity": "%s", "file": "%s", "line": "%s", "pattern": "%s", "message": "%s" }' \
363
+ "$sev" "$jf" "$line" "$pat" "$jm"
364
+ [ "$local_i" -lt "$total" ] && printf ','
365
+ printf '\n'
366
+ done
367
+ fi
278
368
  printf ' ]\n}\n'
279
369
  else
280
370
  # Colored text report
@@ -295,7 +385,7 @@ else
295
385
  printf ' ✓ clean (0 findings)\n'
296
386
  else
297
387
  printf ' %sfound %d%s: critical=%d high=%d medium=%d low=%d\n\n' "$C_BLD" "$total" "$C_RST" "$CRIT" "$HIGH" "$MED" "$LOW"
298
- for f in "${FINDINGS[@]:-}"; do
388
+ for f in "${FINDINGS[@]}"; do
299
389
  sev="${f%%|*}"; rest="${f#*|}"
300
390
  file="${rest%%|*}"; rest="${rest#*|}"
301
391
  line="${rest%%|*}"; rest="${rest#*|}"
@@ -307,7 +397,7 @@ else
307
397
  low) ico="· "; col="$C_DIM" ;;
308
398
  *) ico="? "; col="" ;;
309
399
  esac
310
- rel="${file#$REPO_ROOT/}"
400
+ rel="${file#"$REPO_ROOT"/}"
311
401
  printf ' %s%s%s %-8s %s:%s [%s]\n' "$col" "$ico" "$C_RST" "$sev" "$rel" "$line" "$pat"
312
402
  printf ' %s%s%s\n' "$C_DIM" "$msg" "$C_RST"
313
403
  done