@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
@@ -66,11 +66,20 @@ cat > "$TMP/fixture.json" <<'EOF'
66
66
  EOF
67
67
 
68
68
  # Stub curl that captures invocation and returns the fixture. PATH-shadows the
69
- # real curl for this smoke only.
69
+ # real curl for this smoke only. Auth travels via `-K <config-file>` (so the
70
+ # token never hits argv); the stub dumps the config file so the smoke can
71
+ # assert its contents.
70
72
  cat > "$TMP/curl" <<EOF
71
73
  #!/usr/bin/env bash
72
- # Capture every invocation so we can assert the URL + auth header shape.
74
+ # Capture every invocation so we can assert the URL + auth config shape.
73
75
  echo "INVOCATION:" "\$@" >> "$TMP/curl-calls.log"
76
+ prev=""
77
+ for arg in "\$@"; do
78
+ if [ "\$prev" = "-K" ]; then
79
+ echo "AUTH-CONFIG: \$(cat "\$arg" 2>/dev/null)" >> "$TMP/curl-calls.log"
80
+ fi
81
+ prev="\$arg"
82
+ done
74
83
  # Echo the fixture body (BB Server response) to stdout - same as real curl -sf.
75
84
  cat "$TMP/fixture.json"
76
85
  exit 0
@@ -153,10 +162,17 @@ else
153
162
  fail "limit param missing"
154
163
  fi
155
164
 
156
- if grep -q "test-bb-user:test-bb-token" "$TMP/curl-calls.log"; then
157
- ok "Basic auth uses {user}:{token} from credential store"
165
+ if grep -q 'AUTH-CONFIG: user = "test-bb-user:test-bb-token"' "$TMP/curl-calls.log"; then
166
+ ok "Basic auth uses {user}:{token} via curl -K config (not argv)"
158
167
  else
159
- fail "auth header wrong; expected user:token, got: $(grep -o '\-u [^ ]*' "$TMP/curl-calls.log" || echo none)"
168
+ fail "auth config wrong; captured: $(grep 'AUTH-CONFIG' "$TMP/curl-calls.log" || echo none)"
169
+ fi
170
+
171
+ # The raw token must never appear on curl argv (visible to ps).
172
+ if grep "^INVOCATION:" "$TMP/curl-calls.log" | grep -q "test-bb-token"; then
173
+ fail "token leaked onto curl argv"
174
+ else
175
+ ok "token absent from curl argv"
160
176
  fi
161
177
 
162
178
  # --- Assert: shape conversion produced canonical schema ---------------------
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # smoke-fetchers-offline.sh - offline invocation tests for the pipeline/lib
4
+ # fetchers. No network, no real tokens.
5
+ #
6
+ # Asserts, per fetcher:
7
+ # 1. usage / arg-validation paths exit with the documented code
8
+ # 2. URL/host parsing succeeds for a representative URL. This is the
9
+ # permanent regression guard for the env-prefix bug where
10
+ # `VAR=x PARSED=$(python3 ...)` never exported VAR to the child, so
11
+ # fetch-confluence always failed with "could not determine Confluence
12
+ # host" before reaching the credential stage.
13
+ # 3. with stub credentials (fake credential-store in a temp HOME, fake curl
14
+ # on PATH) the failure moves past parsing to the documented
15
+ # credential/network exit code
16
+ # 4. static source checks: no bash-4-only constructs (macOS /bin/bash is
17
+ # 3.2) and no secret-bearing argv patterns (curl -H auth headers,
18
+ # --data-urlencode secrets, security add-generic-password -w on argv)
19
+ #
20
+ # Fetchers run under /bin/bash when available so bash-4-isms fail loudly on
21
+ # macOS instead of passing under a newer Homebrew bash.
22
+ #
23
+ # Exit codes: 0=pass, 1=failure
24
+
25
+ set -euo pipefail
26
+
27
+ REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
28
+ LIB="$REPO_ROOT/pipeline/lib"
29
+ SCRIPTS="$REPO_ROOT/pipeline/scripts"
30
+
31
+ # Prefer the system bash (3.2 on macOS) so bash-4-isms regress loudly here.
32
+ SH="/bin/bash"
33
+ [ -x "$SH" ] || SH="$(command -v bash)"
34
+
35
+ PASS=0
36
+ FAIL=0
37
+ note() { echo " -> $*"; }
38
+ pass() { echo " ok $*"; PASS=$((PASS + 1)); }
39
+ fail() { echo " FAIL $*"; FAIL=$((FAIL + 1)); }
40
+
41
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/smoke-fetchers.XXXXXX")
42
+ trap 'rm -rf "$WORK"' EXIT
43
+
44
+ OUT="$WORK/out.log"
45
+ ERR="$WORK/err.log"
46
+
47
+ # expect_rc <expected-rc> <label> <cmd...>
48
+ # Runs cmd, captures stdout/stderr to $OUT/$ERR, asserts the exit code.
49
+ expect_rc() {
50
+ local expected="$1" label="$2"
51
+ shift 2
52
+ local rc=0
53
+ "$@" >"$OUT" 2>"$ERR" || rc=$?
54
+ if [ "$rc" -eq "$expected" ]; then
55
+ pass "$label (rc=$rc)"
56
+ else
57
+ fail "$label (rc=$rc, expected $expected)"
58
+ tail -5 "$ERR" | sed 's/^/ /'
59
+ fi
60
+ }
61
+
62
+ assert_in_file() {
63
+ local needle="$1" file="$2" label="$3"
64
+ if grep -q -e "$needle" "$file" 2>/dev/null; then
65
+ pass "$label"
66
+ else
67
+ fail "$label (pattern '$needle' not found)"
68
+ fi
69
+ }
70
+
71
+ assert_not_in_file() {
72
+ local needle="$1" file="$2" label="$3"
73
+ if grep -q -e "$needle" "$file" 2>/dev/null; then
74
+ fail "$label (pattern '$needle' present)"
75
+ else
76
+ pass "$label"
77
+ fi
78
+ }
79
+
80
+ # -----------------------------------------------------------------------------
81
+ note "0. fixtures: temp HOME with resolver + fake credential store, fake curls"
82
+ # -----------------------------------------------------------------------------
83
+ FAKE_HOME="$WORK/home"
84
+ mkdir -p "$FAKE_HOME/.claude/lib"
85
+ cp "$LIB/credential-store-resolver.sh" "$FAKE_HOME/.claude/lib/credential-store-resolver.sh"
86
+
87
+ # Fake credential store: empty by default, returns FAKE_CRED_VALUE when set.
88
+ cat > "$FAKE_HOME/.claude/lib/credential-store.sh" <<'FAKE'
89
+ #!/bin/bash
90
+ case "${1:-}" in
91
+ get)
92
+ if [ -n "${FAKE_CRED_VALUE:-}" ]; then printf '%s' "$FAKE_CRED_VALUE"; exit 0; fi
93
+ exit 1 ;;
94
+ platform) echo "fake" ;;
95
+ *) exit 3 ;;
96
+ esac
97
+ FAKE
98
+ chmod +x "$FAKE_HOME/.claude/lib/credential-store.sh"
99
+
100
+ # Fake curl variants, each in its own PATH-prepend dir.
101
+ BIN_CURL_FAIL="$WORK/bin-curl-fail"
102
+ mkdir -p "$BIN_CURL_FAIL"
103
+ cat > "$BIN_CURL_FAIL/curl" <<'FAKE'
104
+ #!/bin/bash
105
+ echo "curl: (7) offline stub - connection refused" >&2
106
+ exit 7
107
+ FAKE
108
+ chmod +x "$BIN_CURL_FAIL/curl"
109
+
110
+ # Swagger meta stub: honours --output, emits the --write-out triple with a
111
+ # content type that matches neither json nor yaml, forcing the URL-suffix
112
+ # lowercase path (the former bash-4 ${URL,,} site).
113
+ BIN_CURL_META="$WORK/bin-curl-meta"
114
+ mkdir -p "$BIN_CURL_META"
115
+ cat > "$BIN_CURL_META/curl" <<'FAKE'
116
+ #!/bin/bash
117
+ out=""
118
+ prev=""
119
+ for a in "$@"; do
120
+ if [ "$prev" = "--output" ]; then out="$a"; fi
121
+ prev="$a"
122
+ done
123
+ if [ -n "$out" ]; then printf '{}' > "$out"; fi
124
+ printf '200\napplication/octet-stream\n2\n'
125
+ exit 0
126
+ FAKE
127
+ chmod +x "$BIN_CURL_META/curl"
128
+
129
+ # 401 stub: honours -o, prints the HTTP code the way curl -w '%{http_code}' does.
130
+ BIN_CURL_401="$WORK/bin-curl-401"
131
+ mkdir -p "$BIN_CURL_401"
132
+ cat > "$BIN_CURL_401/curl" <<'FAKE'
133
+ #!/bin/bash
134
+ out=""
135
+ prev=""
136
+ for a in "$@"; do
137
+ if [ "$prev" = "-o" ] || [ "$prev" = "--output" ]; then out="$a"; fi
138
+ prev="$a"
139
+ done
140
+ if [ -n "$out" ]; then printf '{}' > "$out"; fi
141
+ printf '401'
142
+ exit 0
143
+ FAKE
144
+ chmod +x "$BIN_CURL_401/curl"
145
+
146
+ # Fake security + uname (forces the macOS branch of credential-store.sh) so
147
+ # the no-secret-on-argv property of `security -i` writes can be asserted on
148
+ # any platform without touching a real keychain.
149
+ BIN_DARWIN="$WORK/bin-darwin"
150
+ mkdir -p "$BIN_DARWIN"
151
+ cat > "$BIN_DARWIN/uname" <<'FAKE'
152
+ #!/bin/bash
153
+ echo "Darwin"
154
+ FAKE
155
+ cat > "$BIN_DARWIN/security" <<'FAKE'
156
+ #!/bin/bash
157
+ printf '%s\n' "$*" >> "$SMOKE_CAPTURE_DIR/security-argv.log"
158
+ if [ "${1:-}" = "-i" ]; then
159
+ cat >> "$SMOKE_CAPTURE_DIR/security-stdin.log"
160
+ fi
161
+ exit 0
162
+ FAKE
163
+ chmod +x "$BIN_DARWIN/uname" "$BIN_DARWIN/security"
164
+
165
+ # -----------------------------------------------------------------------------
166
+ note "1. fetch-confluence.sh"
167
+ # -----------------------------------------------------------------------------
168
+ expect_rc 4 "no args exits 4 (usage)" \
169
+ "$SH" "$LIB/fetch-confluence.sh"
170
+ expect_rc 4 "--help exits 4 (usage)" \
171
+ "$SH" "$LIB/fetch-confluence.sh" --help
172
+
173
+ # Regression guard for the env-prefix parse bug: a plain page URL must get
174
+ # past host/pageId resolution and fail at the credential stage (exit 2),
175
+ # never with the parse error.
176
+ expect_rc 2 "URL parse reaches credential stage (viewpage.action?pageId)" \
177
+ env HOME="$FAKE_HOME" USER=smoke \
178
+ "$SH" "$LIB/fetch-confluence.sh" \
179
+ "https://confluence.example.com/pages/viewpage.action?pageId=12345"
180
+ assert_in_file "missing-token" "$ERR" "blocked JSON names missing-token"
181
+ assert_not_in_file "could not determine Confluence host" "$ERR" \
182
+ "no host-parse error (env-prefix regression)"
183
+
184
+ expect_rc 2 "URL parse reaches credential stage (cloud /wiki/spaces form)" \
185
+ env HOME="$FAKE_HOME" USER=smoke \
186
+ "$SH" "$LIB/fetch-confluence.sh" \
187
+ "https://tenant.atlassian.net/wiki/spaces/ENG/pages/99887/Some+Page"
188
+ assert_in_file "missing-token" "$ERR" "cloud form also blocked on token only"
189
+
190
+ # With a stub token and a failing curl, the failure must be the documented
191
+ # network error (3), proving parsing and auth wiring are past.
192
+ expect_rc 3 "stub token + offline curl exits 3 (network stage)" \
193
+ env HOME="$FAKE_HOME" USER=smoke FAKE_CRED_VALUE=fake-token \
194
+ PATH="$BIN_CURL_FAIL:$PATH" \
195
+ "$SH" "$LIB/fetch-confluence.sh" \
196
+ "https://confluence.example.com/pages/viewpage.action?pageId=12345"
197
+ assert_in_file "Confluence GET failed" "$ERR" "network-stage error message"
198
+
199
+ # -----------------------------------------------------------------------------
200
+ note "2. fetch-crashlytics.sh"
201
+ # -----------------------------------------------------------------------------
202
+ expect_rc 4 "no args exits 4 (usage)" \
203
+ "$SH" "$LIB/fetch-crashlytics.sh"
204
+ expect_rc 4 "--help exits 4 (usage)" \
205
+ "$SH" "$LIB/fetch-crashlytics.sh" --help
206
+
207
+ CRASH_URL="https://console.firebase.google.com/project/my-proj/crashlytics/app/ios:com.example.app/issues/abc123"
208
+ expect_rc 2 "URL parse reaches credential stage" \
209
+ env HOME="$FAKE_HOME" USER=smoke \
210
+ "$SH" "$LIB/fetch-crashlytics.sh" "$CRASH_URL"
211
+ assert_in_file "missing-token" "$ERR" "blocked JSON names missing-token"
212
+
213
+ # Stub SA JSON whose project_id mismatches the URL: proves parse + credential
214
+ # read + base64 decode + JSON validation all pass, fully offline (exit 5).
215
+ MISMATCH_SA_B64=$(printf '{"project_id":"other-proj"}' | base64 | tr -d '\n')
216
+ expect_rc 5 "stub SA JSON reaches project-mismatch check (exit 5)" \
217
+ env HOME="$FAKE_HOME" USER=smoke FAKE_CRED_VALUE="$MISMATCH_SA_B64" \
218
+ "$SH" "$LIB/fetch-crashlytics.sh" "$CRASH_URL"
219
+ assert_in_file "project_id" "$ERR" "mismatch error mentions project_id"
220
+
221
+ # The decoded SA JSON must never touch a predictable /tmp path.
222
+ assert_not_in_file "/tmp/.fc-sa" "$LIB/fetch-crashlytics.sh" \
223
+ "predictable /tmp SA path removed from source"
224
+
225
+ # -----------------------------------------------------------------------------
226
+ note "3. fetch-swagger.sh"
227
+ # -----------------------------------------------------------------------------
228
+ expect_rc 4 "no args exits 4 (usage)" \
229
+ "$SH" "$LIB/fetch-swagger.sh"
230
+
231
+ expect_rc 2 "offline curl exits 2 (network stage)" \
232
+ env PATH="$BIN_CURL_FAIL:$PATH" \
233
+ "$SH" "$LIB/fetch-swagger.sh" "https://spec.example.com/openapi.json"
234
+
235
+ # Uppercase .YAML suffix with a non-matching content type exercises the
236
+ # lowercase-URL branch (formerly bash-4 ${URL,,}, dies on /bin/bash 3.2).
237
+ expect_rc 0 "uppercase .YAML suffix detected via lowercase branch" \
238
+ env PATH="$BIN_CURL_META:$PATH" \
239
+ "$SH" "$LIB/fetch-swagger.sh" "https://spec.example.com/openapi.YAML"
240
+ assert_in_file '"format": "yaml"' "$OUT" "output reports yaml format"
241
+
242
+ # -----------------------------------------------------------------------------
243
+ note "4. fetch-fortify.sh"
244
+ # -----------------------------------------------------------------------------
245
+ expect_rc 6 "no host configured exits 6" \
246
+ env HOME="$FAKE_HOME" USER=smoke \
247
+ "$SH" "$LIB/fetch-fortify.sh" --version-id 123
248
+ expect_rc 4 "host override + no version id exits 4 (usage)" \
249
+ env HOME="$FAKE_HOME" USER=smoke FORTIFY_HOST_OVERRIDE=ssc.example.com \
250
+ "$SH" "$LIB/fetch-fortify.sh"
251
+
252
+ FORTIFY_URL="https://ssc.example.com/ssc/html/ssc/version/123/fix/ABCD-1"
253
+ expect_rc 2 "URL parse reaches credential stage" \
254
+ env HOME="$FAKE_HOME" USER=smoke FORTIFY_HOST_OVERRIDE=ssc.example.com \
255
+ "$SH" "$LIB/fetch-fortify.sh" "$FORTIFY_URL"
256
+ assert_in_file "missing-token" "$ERR" "blocked JSON names missing-token"
257
+
258
+ expect_rc 0 "stub token + offline curl degrades to empty result (exit 0)" \
259
+ env HOME="$FAKE_HOME" USER=smoke FORTIFY_HOST_OVERRIDE=ssc.example.com \
260
+ FAKE_CRED_VALUE=fake-token PATH="$BIN_CURL_FAIL:$PATH" \
261
+ "$SH" "$LIB/fetch-fortify.sh" "$FORTIFY_URL"
262
+ assert_in_file '"gateOutcome"' "$OUT" "normalized JSON still emitted"
263
+
264
+ # -----------------------------------------------------------------------------
265
+ note "5. figma-screenshot.sh"
266
+ # -----------------------------------------------------------------------------
267
+ expect_rc 1 "no args exits 1 (usage)" \
268
+ "$SH" "$LIB/figma-screenshot.sh"
269
+ expect_rc 0 "--help exits 0" \
270
+ "$SH" "$LIB/figma-screenshot.sh" --help
271
+
272
+ expect_rc 3 "no PAT resolvable exits 3 (Tier 3 required)" \
273
+ env -u FIGMA_PAT HOME="$FAKE_HOME" USER=smoke \
274
+ "$SH" "$LIB/figma-screenshot.sh" --file-key abc123 --node-id 1-2 \
275
+ --output-dir "$WORK/shots-no-pat"
276
+
277
+ expect_rc 2 "stub PAT + 401 curl exits 2 (auth stage, past parsing)" \
278
+ env HOME="$FAKE_HOME" USER=smoke FIGMA_PAT=fake-pat \
279
+ PATH="$BIN_CURL_401:$PATH" \
280
+ "$SH" "$LIB/figma-screenshot.sh" --file-key abc123 --node-id 1-2 \
281
+ --output-dir "$WORK/shots-401"
282
+
283
+ # -----------------------------------------------------------------------------
284
+ note "6. figma-mcp-refresh.sh"
285
+ # -----------------------------------------------------------------------------
286
+ expect_rc 2 "missing prefs exits 2" \
287
+ env HOME="$FAKE_HOME" USER=smoke \
288
+ "$SH" "$LIB/figma-mcp-refresh.sh"
289
+
290
+ if command -v jq >/dev/null 2>&1; then
291
+ PREFS_HOME="$WORK/home-prefs"
292
+ mkdir -p "$PREFS_HOME/.claude"
293
+ printf '{"global":{"keychainMapping":{"figma_mcp":"Smoke_Figma_MCP"}}}\n' \
294
+ > "$PREFS_HOME/.claude/multi-agent-preferences.json"
295
+ export SMOKE_CAPTURE_DIR="$WORK"
296
+ expect_rc 2 "mapped key + empty keychain exits 2 (no refresh token)" \
297
+ env HOME="$PREFS_HOME" USER=smoke SMOKE_CAPTURE_DIR="$WORK" \
298
+ PATH="$BIN_DARWIN:$PATH" \
299
+ "$SH" "$LIB/figma-mcp-refresh.sh"
300
+ else
301
+ note " (jq not installed - skipping mapped-key case)"
302
+ fi
303
+
304
+ # -----------------------------------------------------------------------------
305
+ note "7. credential-store.sh + keychain-save.sh"
306
+ # -----------------------------------------------------------------------------
307
+ expect_rc 0 "platform subcommand works" \
308
+ "$SH" "$LIB/credential-store.sh" platform
309
+ expect_rc 3 "get without key exits 3 (usage)" \
310
+ "$SH" "$LIB/credential-store.sh" get
311
+ expect_rc 1 "keychain-save without service name exits 1 (usage)" \
312
+ "$SH" "$SCRIPTS/keychain-save.sh"
313
+
314
+ # set <key> - : the secret must travel via stdin to `security -i`, never on
315
+ # the security argv. Fake uname forces the macOS branch; fake security logs
316
+ # argv and stdin separately.
317
+ rm -f "$WORK/security-argv.log" "$WORK/security-stdin.log"
318
+ SECRET_VALUE="topsecret-smoke-value-123"
319
+ rc=0
320
+ printf '%s' "$SECRET_VALUE" \
321
+ | env PATH="$BIN_DARWIN:$PATH" KEYCHAIN_DELEGATE=0 SMOKE_CAPTURE_DIR="$WORK" \
322
+ "$SH" "$LIB/credential-store.sh" set smoke-test-key - >"$OUT" 2>"$ERR" || rc=$?
323
+ if [ "$rc" -eq 0 ]; then
324
+ pass "set <key> - via stdin succeeds (rc=0)"
325
+ else
326
+ fail "set <key> - via stdin failed (rc=$rc)"
327
+ tail -5 "$ERR" | sed 's/^/ /'
328
+ fi
329
+ assert_in_file "^-i" "$WORK/security-argv.log" "security invoked in -i stdin mode"
330
+ assert_not_in_file "$SECRET_VALUE" "$WORK/security-argv.log" "secret absent from security argv"
331
+ assert_in_file "$SECRET_VALUE" "$WORK/security-stdin.log" "secret delivered via stdin"
332
+
333
+ # -----------------------------------------------------------------------------
334
+ note "8. static source checks (bash-3.2 compat + no secrets on argv)"
335
+ # -----------------------------------------------------------------------------
336
+ OWNED_FILES=(
337
+ "$LIB/fetch-confluence.sh"
338
+ "$LIB/fetch-crashlytics.sh"
339
+ "$LIB/fetch-swagger.sh"
340
+ "$LIB/fetch-fortify.sh"
341
+ "$LIB/figma-mcp-refresh.sh"
342
+ "$LIB/figma-screenshot.sh"
343
+ "$LIB/credential-store.sh"
344
+ "$SCRIPTS/keychain-save.sh"
345
+ )
346
+
347
+ if grep -nE '\$\{[A-Za-z_0-9]+(,,|\^\^)|mapfile |readarray |declare -A' "${OWNED_FILES[@]}"; then
348
+ fail "bash-4-only construct found (must run on /bin/bash 3.2)"
349
+ else
350
+ pass "no bash-4-only constructs"
351
+ fi
352
+
353
+ if grep -nE -- '-H "(Authorization|X-Figma-Token)' "${OWNED_FILES[@]}"; then
354
+ fail "auth header passed to curl via argv (-H)"
355
+ else
356
+ pass "no auth headers on curl argv"
357
+ fi
358
+
359
+ if grep -nE -- '--data-urlencode "(refresh_token|client_secret)' "${OWNED_FILES[@]}"; then
360
+ fail "OAuth secret passed to curl via argv (--data-urlencode)"
361
+ else
362
+ pass "no OAuth secrets on curl argv"
363
+ fi
364
+
365
+ if grep -nE 'add-generic-password[^|]*-w "\$' "${OWNED_FILES[@]}"; then
366
+ fail "keychain secret passed to security via argv (-w)"
367
+ else
368
+ pass "no keychain secrets on security argv"
369
+ fi
370
+
371
+ # shellcheck disable=SC2016 # literal $HOME is the pattern being hunted
372
+ if grep -nE '\$HOME/\.claude/lib/credential-store\.sh|~/\.claude/lib/credential-store\.sh' "${OWNED_FILES[@]}"; then
373
+ fail "hardcoded credential-store path (must go through the resolver)"
374
+ else
375
+ pass "credential-store access routed through the resolver"
376
+ fi
377
+
378
+ # -----------------------------------------------------------------------------
379
+ echo ""
380
+ echo "smoke-fetchers-offline: $PASS passed, $FAIL failed"
381
+ [ "$FAIL" -eq 0 ] || exit 1
382
+ exit 0
@@ -110,11 +110,19 @@ assert_file "$TMP_CLAUDE/.claude/CLAUDE.md" "~/.claude/CLAUDE.md"
110
110
  assert_file "$TMP_CLAUDE/.claude/multi-agent-preferences.json" "~/.claude/multi-agent-preferences.json"
111
111
  assert_file "$TMP_CLAUDE/.claude/settings.json" "~/.claude/settings.json"
112
112
 
113
- # settings.json must have the secret-detection hook
113
+ # settings.json must have the secret-detection hook. v11.4.1 changed the
114
+ # matcher from the dead "Bash(git commit:*)" (tool-name matchers never matched
115
+ # it) to "Bash"; command-level filtering happens inside pre-commit-check.sh.
116
+ if grep -q '"matcher": "Bash"' "$TMP_CLAUDE/.claude/settings.json" \
117
+ && grep -q "pre-commit-check.sh" "$TMP_CLAUDE/.claude/settings.json"; then
118
+ pass "settings.json registers PreToolUse secret-scan hook (matcher Bash)"
119
+ else
120
+ fail "settings.json missing PreToolUse secret-scan hook with matcher Bash"
121
+ fi
114
122
  if grep -q "Bash(git commit:\*)" "$TMP_CLAUDE/.claude/settings.json"; then
115
- pass "settings.json registers PreToolUse git-commit hook"
123
+ fail "settings.json still contains the dead Bash(git commit:*) matcher"
116
124
  else
117
- fail "settings.json missing PreToolUse git-commit hook"
125
+ pass "settings.json has no dead Bash(git commit:*) matcher"
118
126
  fi
119
127
  if grep -q "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE" "$TMP_CLAUDE/.claude/settings.json"; then
120
128
  pass "settings.json sets CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"
@@ -136,6 +136,18 @@ else
136
136
  else
137
137
  fail "jira-id path output unexpected"
138
138
  fi
139
+
140
+ # Bare "#316" and "316" must land in the gh-num branch (needsRepoPicker
141
+ # descriptor when no default owner/repo), NOT gh-short with an empty repo
142
+ # prefix that builds https://github.com/<owner>//issues/316.
143
+ for bare in "#316" "316"; do
144
+ out=$(ACCOUNT_DEFAULT_OWNER="" ACCOUNT_DEFAULT_REPO="" "$FETCHER" "$bare" 2>/dev/null || echo "")
145
+ if printf '%s' "$out" | python3 -c 'import json,sys; d=json.loads(sys.stdin.read()); assert d["kind"]=="github", d["kind"]; assert d["key"]=="316", d["key"]; assert d.get("needsRepoPicker") is True, d' 2>/dev/null; then
146
+ ok "bare '$bare' classified as gh-num (key=316, needsRepoPicker)"
147
+ else
148
+ fail "bare '$bare' misclassified: $(printf '%s' "$out" | head -c 200)"
149
+ fi
150
+ done
139
151
  fi
140
152
 
141
153
  # --- multi-repo-pipeline.sh: bridge --------------------------------------
@@ -294,6 +306,20 @@ assert d["projects"][1]["project"] == "repo-b"
294
306
  fail "bridge output missing projects[] entries"
295
307
  fi
296
308
 
309
+ # teardown guard: a worktreeRoot of "." (the current project root) must be
310
+ # refused loudly, never rm -rf'd.
311
+ UNSAFE_STATE="$FIXTURE_ROOT/unsafe-state.json"
312
+ sed 's|"worktreeRoot":"[^"]*"|"worktreeRoot":"."|' "$FIXTURE_ROOT/state.json" > "$UNSAFE_STATE"
313
+ GUARD_DIR=$(mktemp -d)
314
+ ( cd "$GUARD_DIR" && "$MRP" teardown "$UNSAFE_STATE" ) >/dev/null 2>&1
315
+ guard_rc=$?
316
+ if [ "$guard_rc" -ne 0 ] && [ -d "$GUARD_DIR" ]; then
317
+ ok "teardown refuses worktreeRoot='.' (exit=$guard_rc, dir intact)"
318
+ else
319
+ fail "teardown did not refuse unsafe worktreeRoot='.' (exit=$guard_rc)"
320
+ fi
321
+ rm -rf "$GUARD_DIR"
322
+
297
323
  # teardown
298
324
  "$MRP" teardown "$FIXTURE_ROOT/state.json" >/dev/null 2>&1
299
325
  if [ ! -d "$WORKTREE_ROOT" ]; then
@@ -357,13 +383,40 @@ EOF
357
383
 
358
384
  # render-jira: bulleted PR list at top
359
385
  rendered=$("$CMR" render-jira "$TMP/multi.json" "$TMP/body.md" 2>/dev/null)
360
- bullet_count=$(printf '%s' "$rendered" | grep -c '^\* PR:' || echo 0)
386
+ bullet_count=$(printf '%s' "$rendered" | grep -c '^\* PR:' || true)
361
387
  if [ "$bullet_count" = "2" ]; then
362
388
  ok "render-jira prepends bulleted PR list (2 entries)"
363
389
  else
364
390
  fail "render-jira bullet count $bullet_count != 2"
365
391
  fi
366
392
 
393
+ # render-jira/-conf with ZERO PR urls: must pass the body through unchanged
394
+ # (the old `grep -c || echo 0` idiom emitted "0\n0" here and prepended
395
+ # bogus "* PR:" bullets to the posted body).
396
+ cat > "$TMP/noprs.json" <<'EOF'
397
+ {
398
+ "schemaVersion":"2.1.0","taskId":"Z","shortId":1,"project":"my-app",
399
+ "branch":"feature/Z","baseBranch":"develop","currentPhase":6,"status":"in_progress",
400
+ "startedAt":"2026-04-27T12:00:00Z","phases":{},"identity":{"name":"T","email":"t@e.com"},
401
+ "projects":[
402
+ {"project":"my-app","projectRoot":"/tmp/a","branch":"feature/Z","baseBranch":"develop","identity":{"name":"T","email":"t@e.com"},"pr":null},
403
+ {"project":"common","projectRoot":"/tmp/b","branch":"feature/Z","baseBranch":"develop","identity":{"name":"T","email":"t@e.com"},"pr":null}
404
+ ]
405
+ }
406
+ EOF
407
+ rendered=$("$CMR" render-jira "$TMP/noprs.json" "$TMP/body.md" 2>/dev/null)
408
+ if [ "$rendered" = "body content" ]; then
409
+ ok "render-jira with zero PR urls passes body through unchanged"
410
+ else
411
+ fail "render-jira with zero PR urls mutated body: $rendered"
412
+ fi
413
+ rendered=$("$CMR" render-conf "$TMP/noprs.json" "$TMP/body.md" 2>/dev/null)
414
+ if [ "$rendered" = "body content" ]; then
415
+ ok "render-conf with zero PR urls passes body through unchanged"
416
+ else
417
+ fail "render-conf with zero PR urls mutated body: $rendered"
418
+ fi
419
+
367
420
  # render-conf: ## Related PRs heading
368
421
  rendered=$("$CMR" render-conf "$TMP/multi.json" "$TMP/body.md" 2>/dev/null)
369
422
  if printf '%s' "$rendered" | grep -q "## Related PRs"; then
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # smoke-pack-contents.sh - guard what `npm publish` would actually ship.
4
+ #
5
+ # 1. Deny patterns: package.json files[] carries negations (`!...`) for
6
+ # dev-only / personal-data files. The published tarball must contain
7
+ # no file matching any of them. Patterns are read from package.json at
8
+ # runtime (via node), so the smoke never goes stale when the deny list
9
+ # changes.
10
+ # 2. File-count band: the total packed file count is compared against the
11
+ # committed fixture pipeline/scripts/fixtures/pack-expected-count.txt.
12
+ # Drift beyond +-15 percent fails - that magnitude means the files[]
13
+ # globs picked up (or dropped) a whole subtree, which deserves a human
14
+ # look. Refresh the fixture after reviewing:
15
+ # npm pack --dry-run --json 2>/dev/null \
16
+ # | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s)[0].files.length))' \
17
+ # > pipeline/scripts/fixtures/pack-expected-count.txt
18
+ # 3. publint (optional): linted via `npx --no-install` so it only runs
19
+ # when the package is already installed - never a network fetch.
20
+ #
21
+ # Exit codes: 0=pass, 1=violation, 2=environment broken
22
+
23
+ set -uo pipefail
24
+
25
+ REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
26
+ FIXTURE="$REPO_ROOT/pipeline/scripts/fixtures/pack-expected-count.txt"
27
+
28
+ command -v npm >/dev/null 2>&1 || { echo "FAIL: npm not on PATH"; exit 2; }
29
+ command -v node >/dev/null 2>&1 || { echo "FAIL: node not on PATH"; exit 2; }
30
+
31
+ PASS=0
32
+ FAIL=0
33
+ pass() { PASS=$((PASS + 1)); echo " ✓ $1"; }
34
+ fail() { FAIL=$((FAIL + 1)); echo " ✗ $1"; }
35
+
36
+ cleanup() { [ -n "${TMP:-}" ] && rm -rf "$TMP"; }
37
+ trap cleanup EXIT
38
+ TMP=$(mktemp -d)
39
+
40
+ # ──────────────────────────────────────────────────────────────────────────
41
+ echo "→ 1. npm pack --dry-run --json"
42
+ if ! (cd "$REPO_ROOT" && npm pack --dry-run --json 2>/dev/null > "$TMP/pack.json"); then
43
+ echo "FAIL: npm pack --dry-run failed"
44
+ exit 2
45
+ fi
46
+
47
+ # Deny check runs in node so the patterns come straight from package.json
48
+ # files[] negations - never hardcoded here.
49
+ cat > "$TMP/check.mjs" <<'MJS'
50
+ import { readFileSync } from "node:fs";
51
+
52
+ const [packJsonPath, pkgJsonPath] = process.argv.slice(2);
53
+ const packed = JSON.parse(readFileSync(packJsonPath, "utf-8"))[0].files.map((f) => f.path);
54
+ const files = JSON.parse(readFileSync(pkgJsonPath, "utf-8")).files || [];
55
+ const denies = files.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
56
+
57
+ // Minimal glob -> regex: '**' spans path separators, '*' does not.
58
+ const toRegex = (glob) => {
59
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
60
+ const body = escaped
61
+ .split("**")
62
+ .map((part) => part.replace(/\*/g, "[^/]*"))
63
+ .join(".*");
64
+ return new RegExp("^" + body + "$");
65
+ };
66
+
67
+ const regexes = denies.map((d) => [d, toRegex(d)]);
68
+ let bad = 0;
69
+ for (const path of packed) {
70
+ for (const [glob, re] of regexes) {
71
+ if (re.test(path)) {
72
+ console.log(`DENY ${path} (matches !${glob})`);
73
+ bad++;
74
+ }
75
+ }
76
+ }
77
+ console.log(`DENYCOUNT ${bad}`);
78
+ console.log(`PATTERNS ${denies.length}`);
79
+ console.log(`COUNT ${packed.length}`);
80
+ MJS
81
+
82
+ CHECK=$(node "$TMP/check.mjs" "$TMP/pack.json" "$REPO_ROOT/package.json")
83
+ COUNT=$(echo "$CHECK" | awk '/^COUNT /{print $2}')
84
+ DENYCOUNT=$(echo "$CHECK" | awk '/^DENYCOUNT /{print $2}')
85
+ PATTERNS=$(echo "$CHECK" | awk '/^PATTERNS /{print $2}')
86
+
87
+ if [ -z "$COUNT" ] || [ -z "$DENYCOUNT" ]; then
88
+ echo "FAIL: deny-pattern checker produced no result (node error?)"
89
+ exit 2
90
+ fi
91
+
92
+ pass "pack dry-run produced $COUNT files ($PATTERNS deny patterns loaded from package.json)"
93
+
94
+ # ──────────────────────────────────────────────────────────────────────────
95
+ echo "→ 2. deny patterns (files[] negations must not ship)"
96
+ if [ "$DENYCOUNT" -eq 0 ]; then
97
+ pass "no packed file matches a files[] negation"
98
+ else
99
+ fail "$DENYCOUNT packed file(s) match dev-only/personal-data exclusions:"
100
+ echo "$CHECK" | grep '^DENY ' | sed 's/^/ /'
101
+ fi
102
+
103
+ # ──────────────────────────────────────────────────────────────────────────
104
+ echo "→ 3. file-count band vs fixture (+-15 percent)"
105
+ if [ ! -f "$FIXTURE" ]; then
106
+ fail "fixture missing: $FIXTURE - create it from the refresh command in this script's header"
107
+ else
108
+ EXPECTED=$(tr -cd '0-9' < "$FIXTURE")
109
+ if [ -z "$EXPECTED" ] || [ "$EXPECTED" -eq 0 ]; then
110
+ fail "fixture unreadable or zero: $FIXTURE"
111
+ else
112
+ DIFF=$((COUNT - EXPECTED))
113
+ [ "$DIFF" -lt 0 ] && DIFF=$((-DIFF))
114
+ LIMIT=$((EXPECTED * 15 / 100))
115
+ if [ "$DIFF" -le "$LIMIT" ]; then
116
+ pass "count $COUNT within +-15 percent of expected $EXPECTED (drift $DIFF, limit $LIMIT)"
117
+ else
118
+ fail "count $COUNT drifted beyond +-15 percent of expected $EXPECTED (drift $DIFF, limit $LIMIT)"
119
+ echo " Review what the files[] globs now include, then refresh the fixture"
120
+ echo " (refresh command is in this script's header comment)."
121
+ fi
122
+ fi
123
+ fi
124
+
125
+ # ──────────────────────────────────────────────────────────────────────────
126
+ echo "→ 4. publint (optional, npx --no-install only)"
127
+ if (cd "$REPO_ROOT" && npx --no-install publint --help >/dev/null 2>&1); then
128
+ if OUT=$(cd "$REPO_ROOT" && npx --no-install publint 2>&1); then
129
+ pass "publint: no errors"
130
+ else
131
+ fail "publint reported errors"
132
+ echo "$OUT" | sed 's/^/ /'
133
+ fi
134
+ else
135
+ echo " ↷ SKIP: publint not installed (npx --no-install found no local package)"
136
+ fi
137
+
138
+ echo ""
139
+ echo "══ smoke-pack-contents: $PASS passed, $FAIL failed ══"
140
+ [ "$FAIL" -gt 0 ] && exit 1 || exit 0