@aldegad/safedeps 2.1.1 → 2.2.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.
@@ -76,10 +76,16 @@ release_state_lock() {
76
76
  write_state_file() {
77
77
  local target_path="$1"
78
78
  local value="$2"
79
- local temp_path="${target_path}.$$"
80
-
79
+ local target_dir
80
+ local target_base
81
+ local temp_path
82
+
83
+ target_dir=$(dirname "${target_path}")
84
+ target_base=$(basename "${target_path}")
85
+ mkdir -p "${target_dir}" || return 1
86
+ temp_path=$(mktemp "${target_dir}/.${target_base}.XXXXXX") || return 1
81
87
  printf '%s\n' "${value}" > "${temp_path}"
82
- mv "${temp_path}" "${target_path}"
88
+ mv -f "${temp_path}" "${target_path}"
83
89
  }
84
90
 
85
91
  compute_dir_hash() {
@@ -99,10 +105,13 @@ command_is_dependency_install() {
99
105
  local scan_command
100
106
  local install_pattern
101
107
 
102
- scan_command=$(command_scan_text "${command}")
103
- install_pattern='(^|[;&|]+[[:space:]]*)((npm[[:space:]]+(install|i|add|update|up|upgrade))|npx[[:space:]]|pnpm[[:space:]]+(add|install|update|up|dlx)|yarn[[:space:]]+(add|install|upgrade|dlx)|((python3?|py)[[:space:]]+-m[[:space:]]+pip|pip3?)[[:space:]]+install|poetry[[:space:]]+add|uv[[:space:]]+(add|pip[[:space:]]+install)|pipenv[[:space:]]+install|cargo[[:space:]]+(add|install)|go[[:space:]]+(get|install)|gem[[:space:]]+install|bundle[[:space:]]+add|mvn[[:space:]]+dependency:get|dotnet[[:space:]]+add[[:space:]]+package)([[:space:]]|$)'
108
+ install_pattern='(^|[;&|]+[[:space:]]*)((npm([[:space:]]+--?[a-zA-Z0-9_-]+([=[:space:]][^[:space:]]+)?)?[[:space:]]+(install|i|add|ci|update|up|upgrade))|npx[[:space:]]|pnpm([[:space:]]+--?[a-zA-Z0-9_-]+([=[:space:]][^[:space:]]+)?)?[[:space:]]+(add|install|update|up|dlx)|yarn([[:space:]]+--?[a-zA-Z0-9_-]+([=[:space:]][^[:space:]]+)?)?[[:space:]]+(add|install|upgrade|dlx)|((python3?|py)[[:space:]]+-m[[:space:]]+pip|pip3?)[[:space:]]+install|poetry[[:space:]]+add|uv[[:space:]]+(add|pip[[:space:]]+install)|pipenv[[:space:]]+install|cargo[[:space:]]+(add|install)|go[[:space:]]+(get|install)|gem[[:space:]]+install|bundle[[:space:]]+add|mvn[[:space:]]+dependency:get|dotnet[[:space:]]+add[[:space:]]+package)([[:space:]]|$)'
104
109
 
105
- echo "${scan_command}" | grep -qEi "${install_pattern}"
110
+ while IFS= read -r scan_command; do
111
+ scan_command=$(command_scan_text "${scan_command}")
112
+ echo "${scan_command}" | grep -qEi "${install_pattern}" && return 0
113
+ done < <(command_candidate_texts "${command}")
114
+ return 1
106
115
  }
107
116
 
108
117
  command_hides_dependency_install() {
@@ -113,7 +122,7 @@ command_hides_dependency_install() {
113
122
  manager_pattern='(npm|npx|pnpm|yarn|pip3?|python3?[[:space:]]+-m[[:space:]]+pip|poetry|uv|pipenv|cargo|go|gem|bundle|mvn|dotnet)'
114
123
  verb_pattern='(install|i|add|update|up|upgrade|dlx|get|dependency:get|package)'
115
124
 
116
- echo "${command}" | grep -qEi '(eval[[:space:]]|\$\(|`)' && \
125
+ echo "${command}" | grep -qEi '(eval[[:space:]]|\$\(|`|(^|[[:space:];|&])(bash|sh|zsh)[[:space:]]+-[A-Za-z]*c[[:space:]])' && \
117
126
  echo "${command}" | grep -qEi "${manager_pattern}.*${verb_pattern}"
118
127
  }
119
128
 
@@ -154,6 +163,93 @@ command_scan_text() {
154
163
  printf '%s' "${output}"
155
164
  }
156
165
 
166
+ normalize_install_text() {
167
+ local text="$1"
168
+
169
+ for _ in 1 2 3; do
170
+ text=$(printf '%s' "${text}" | sed -E \
171
+ -e 's#(^|[[:space:];|&])(/[^[:space:];|&]+/)(npm|npx|pnpm|yarn|pip3?|python3?|py|poetry|uv|pipenv|cargo|go|gem|bundle|mvn|dotnet)([[:space:];|&]|$)#\1\3\4#g' \
172
+ -e 's#(^|[;&|][[:space:]]*)(env[[:space:]]+([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)*|command[[:space:]]+)#\1#g')
173
+ done
174
+ printf '%s' "${text}"
175
+ }
176
+
177
+ strip_heredoc_bodies() {
178
+ local input="$1"
179
+ local line
180
+ local delimiter=""
181
+ local heredoc_re="<<-?[[:space:]]*[\"']?([A-Za-z0-9_][A-Za-z0-9_.-]*)[\"']?"
182
+
183
+ while IFS= read -r line || [[ -n "${line}" ]]; do
184
+ if [[ -n "${delimiter}" ]]; then
185
+ if [[ "${line}" == "${delimiter}" ]]; then
186
+ delimiter=""
187
+ fi
188
+ continue
189
+ fi
190
+
191
+ if [[ "${line}" =~ ${heredoc_re} ]]; then
192
+ delimiter="${BASH_REMATCH[1]}"
193
+ fi
194
+ printf '%s\n' "${line}"
195
+ done <<< "${input}"
196
+ }
197
+
198
+ extract_shell_c_payloads() {
199
+ local rest="$1"
200
+
201
+ while [[ "${rest}" =~ (bash|sh|zsh)[[:space:]]+-[A-Za-z]*c[[:space:]]+\"([^\"]*)\" ]]; do
202
+ printf '%s\n' "${BASH_REMATCH[2]}"
203
+ rest="${rest#*"${BASH_REMATCH[0]}"}"
204
+ done
205
+
206
+ rest="$1"
207
+ while [[ "${rest}" =~ (bash|sh|zsh)[[:space:]]+-[A-Za-z]*c[[:space:]]+\'([^\']*)\' ]]; do
208
+ printf '%s\n' "${BASH_REMATCH[2]}"
209
+ rest="${rest#*"${BASH_REMATCH[0]}"}"
210
+ done
211
+ }
212
+
213
+ command_candidate_texts() {
214
+ local command="$1"
215
+ local payload
216
+
217
+ command=$(strip_heredoc_bodies "${command}")
218
+
219
+ normalize_install_text "${command}"
220
+ printf '\n'
221
+ while IFS= read -r payload; do
222
+ [[ -z "${payload}" ]] && continue
223
+ normalize_install_text "${payload}"
224
+ printf '\n'
225
+ done < <(extract_shell_c_payloads "${command}")
226
+ }
227
+
228
+ command_is_injectable_npm_install() {
229
+ local command="$1"
230
+ local scan_command
231
+ local npm_install_pattern
232
+
233
+ npm_install_pattern='(^|[;&|]+[[:space:]]*)npm([[:space:]]+--?[a-zA-Z0-9_-]+([=[:space:]][^[:space:]]+)?)?[[:space:]]+(install|i|add|ci|update|up|upgrade)([[:space:]]|$)'
234
+
235
+ while IFS= read -r scan_command; do
236
+ scan_command=$(command_scan_text "${scan_command}")
237
+ echo "${scan_command}" | grep -qEi "${npm_install_pattern}" && return 0
238
+ done < <(command_candidate_texts "${command}")
239
+ return 1
240
+ }
241
+
242
+ command_has_ignore_scripts_flag() {
243
+ local command="$1"
244
+ local scan_command
245
+
246
+ while IFS= read -r scan_command; do
247
+ scan_command=$(command_scan_text "${scan_command}")
248
+ echo "${scan_command}" | grep -qEi -- '(^|[[:space:]])--ignore-scripts([=[:space:]]|$)' && return 0
249
+ done < <(command_candidate_texts "${command}")
250
+ return 1
251
+ }
252
+
157
253
  snapshot_project_file() {
158
254
  local relative_file="$1"
159
255
  local category="${2:-manifest}"
@@ -275,10 +371,24 @@ cat > "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_meta.json" << META_EOF
275
371
  "timestamp": ${TIMESTAMP},
276
372
  "project_dir": $(printf '%s' "${PROJECT_DIR}" | jq -Rs .),
277
373
  "command": $(printf '%s' "${COMMAND}" | jq -Rs .),
374
+ "ignore_scripts_injected": false,
278
375
  "lock_files_found": ${SNAPSHOTTED}
279
376
  }
280
377
  META_EOF
281
378
 
379
+ mark_ignore_scripts_injected() {
380
+ local meta_file="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_meta.json"
381
+ local temp_file
382
+
383
+ [[ -f "${meta_file}" ]] || return 0
384
+ temp_file=$(mktemp "${SNAPSHOT_DIR}/.${SNAPSHOT_ID}_meta.XXXXXX") || return 0
385
+ if jq '.ignore_scripts_injected = true' "${meta_file}" > "${temp_file}"; then
386
+ mv -f "${temp_file}" "${meta_file}"
387
+ else
388
+ rm -f "${temp_file}"
389
+ fi
390
+ }
391
+
282
392
  # --- Pre-flight security checks on the command itself ---
283
393
 
284
394
  SUSPICIOUS=false
@@ -320,8 +430,10 @@ fi
320
430
 
321
431
  # --- Phase 2 advisory gate — ledger enforcement -------------------------------
322
432
  # For commands that name specific packages, require an entry in the approved-
323
- # spec ledger. Miss/expired → block with a structured message that names the
324
- # exact `safedeps check` command the caller (agent or human) should run next.
433
+ # spec ledger. Miss/expired → block with a structured message that names a
434
+ # runnable `safedeps check` command the caller (agent or human) should run
435
+ # next — PATH command when present, else an absolute path, so the self-heal
436
+ # loop never dead-ends on a missing PATH symlink.
325
437
  #
326
438
  # Conservative: only block when at least one pkg@spec token is parseable. Bare
327
439
  # `npm install` (lockfile install) falls through to the v1 reorg checks.
@@ -333,33 +445,132 @@ guard_detect_ecosystem() {
333
445
  local cmd="$1"
334
446
  local scan_cmd
335
447
 
336
- scan_cmd=$(command_scan_text "${cmd}")
337
- if echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(npm|pnpm|yarn|npx)([[:space:]]|$)'; then
338
- printf 'npm'
339
- elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(pip3?|poetry|uv|pipenv|((python3?|py)[[:space:]]+-m[[:space:]]+pip))([[:space:]]|$)'; then
340
- printf 'pypi'
341
- elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)cargo([[:space:]]|$)'; then
342
- printf 'crates.io'
343
- elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)go([[:space:]]|$)'; then
344
- printf 'go'
345
- elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(gem|bundle)([[:space:]]|$)'; then
346
- printf 'rubygems'
347
- elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)mvn([[:space:]]|$)'; then
348
- printf 'maven'
349
- elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)dotnet([[:space:]]|$)'; then
350
- printf 'nuget'
351
- else
352
- printf ''
353
- fi
448
+ while IFS= read -r scan_cmd; do
449
+ scan_cmd=$(command_scan_text "${scan_cmd}")
450
+ if echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(npm|pnpm|yarn|npx)([[:space:]]|$)'; then
451
+ printf 'npm'
452
+ return 0
453
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(pip3?|poetry|uv|pipenv|((python3?|py)[[:space:]]+-m[[:space:]]+pip))([[:space:]]|$)'; then
454
+ printf 'pypi'
455
+ return 0
456
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)cargo([[:space:]]|$)'; then
457
+ printf 'crates.io'
458
+ return 0
459
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)go([[:space:]]|$)'; then
460
+ printf 'go'
461
+ return 0
462
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(gem|bundle)([[:space:]]|$)'; then
463
+ printf 'rubygems'
464
+ return 0
465
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)mvn([[:space:]]|$)'; then
466
+ printf 'maven'
467
+ return 0
468
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)dotnet([[:space:]]|$)'; then
469
+ printf 'nuget'
470
+ return 0
471
+ fi
472
+ done < <(command_candidate_texts "${cmd}")
473
+ printf ''
474
+ }
475
+
476
+ guard_runner_operands() {
477
+ # Runner forms (`npx`, `pnpm dlx`, `yarn dlx`) EXECUTE a package; tokens after
478
+ # the executed package are arguments to that program, NOT package specs. Emit
479
+ # only the spec-bearing operands: any `-p/--package <pkg>` value plus the first
480
+ # bare token (the executed package). This stops an argument such as an email
481
+ # (`dev1@block-s.io`) or a secret value passed to `npx wrangler ...` from being
482
+ # misread as a `pkg@spec` install.
483
+ local scan="$1"
484
+ local after want_value tok
485
+ after=$(printf '%s' "${scan}" | grep -oiE '(npx|dlx)[[:space:]].*' | head -n1 || true)
486
+ after="${after#* }" # drop the runner keyword, keep its operands
487
+ [[ -z "${after}" ]] && return 0
488
+
489
+ want_value=false
490
+ for tok in ${after}; do
491
+ if [[ "${want_value}" == true ]]; then
492
+ printf '%s\n' "${tok}"
493
+ want_value=false
494
+ continue
495
+ fi
496
+ case "${tok}" in
497
+ -p|--package) want_value=true ;;
498
+ --package=*) printf '%s\n' "${tok#--package=}" ;;
499
+ -*) : ;; # other flag (e.g. -y/--yes), skip
500
+ *)
501
+ printf '%s\n' "${tok}" # executed package; rest are program args
502
+ break
503
+ ;;
504
+ esac
505
+ done
506
+ }
507
+
508
+ guard_extract_flagged_specs() {
509
+ awk '
510
+ {
511
+ for (i = 1; i <= NF; i++) {
512
+ if ($i ~ /^[A-Za-z][A-Za-z0-9._-]*==[A-Za-z0-9][A-Za-z0-9._+!~-]*$/) {
513
+ split($i, parts, "==")
514
+ print parts[1] "\t" parts[2]
515
+ }
516
+
517
+ if ($i == "gem" && $(i + 1) == "install") {
518
+ pkg = $(i + 2)
519
+ for (j = i + 3; j <= NF; j++) {
520
+ if (($j == "-v" || $j == "--version") && $(j + 1) != "") print pkg "\t" $(j + 1)
521
+ if ($j ~ /^--version=/) { sub(/^--version=/, "", $j); print pkg "\t" $j }
522
+ }
523
+ }
524
+
525
+ if ($i == "cargo" && $(i + 1) == "add") {
526
+ pkg = $(i + 2)
527
+ for (j = i + 3; j <= NF; j++) {
528
+ if (($j == "--vers" || $j == "--version") && $(j + 1) != "") print pkg "\t" $(j + 1)
529
+ if ($j ~ /^--(vers|version)=/) { sub(/^--(vers|version)=/, "", $j); print pkg "\t" $j }
530
+ }
531
+ }
532
+
533
+ if ($i == "dotnet" && $(i + 1) == "add" && $(i + 2) == "package") {
534
+ pkg = $(i + 3)
535
+ for (j = i + 4; j <= NF; j++) {
536
+ if ($j == "--version" && $(j + 1) != "") print pkg "\t" $(j + 1)
537
+ if ($j ~ /^--version=/) { sub(/^--version=/, "", $j); print pkg "\t" $j }
538
+ }
539
+ }
540
+ }
541
+ }
542
+ '
354
543
  }
355
544
 
356
545
  guard_extract_specs() {
357
- # Echo one "pkg<TAB>spec" line per pkg@spec token found in the command.
358
- # Captures @scope/name@spec and bare-name@spec forms.
546
+ # Echo one "pkg<TAB>spec" line per pkg@spec OPERAND genuinely being installed.
547
+ # Handles @scope/name@spec and bare-name@spec. Two precision rules keep
548
+ # non-package "@" tokens from being misread as an install:
549
+ # 1. Runner segments (npx / pnpm dlx / yarn dlx) contribute ONLY their
550
+ # executed package — trailing tokens are program arguments, not specs
551
+ # (so `npx wrangler ... dev1@block-s.io` is never read as a spec).
552
+ # 2. Email / host operands (user@domain.tld) are never package specs.
553
+ # Each shell segment is judged independently so a genuine install in one
554
+ # segment is still gated even when another segment just runs a tool via npx.
359
555
  local cmd="$1"
360
- echo "${cmd}" \
361
- | grep -oE '(@[a-zA-Z0-9._/-]+/)?[a-zA-Z][a-zA-Z0-9._-]*@[a-zA-Z0-9._^~|<>=*+-]+' \
556
+ local seg source=""
557
+
558
+ while IFS= read -r seg; do
559
+ [[ -z "${seg//[[:space:]]/}" ]] && continue
560
+ if printf '%s' "${seg}" | grep -qEi '(^|[[:space:]])(npx|dlx)([[:space:]]|$)'; then
561
+ source+="$(guard_runner_operands "${seg}")"$'\n'
562
+ else
563
+ source+="${seg}"$'\n'
564
+ fi
565
+ done < <(command_candidate_texts "${cmd}" | tr ';|&' '\n')
566
+
567
+ { printf '%s' "${source}" \
568
+ | grep -oE '(@[a-zA-Z0-9._/-]+/)?[a-zA-Z][a-zA-Z0-9._-]*@[a-zA-Z0-9._^~|<>=*+-]+' || true; } \
362
569
  | while IFS= read -r token; do
570
+ # An email / host operand (user@domain.tld) is never a package spec.
571
+ if [[ "${token}" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
572
+ continue
573
+ fi
363
574
  local pkg spec
364
575
  if [[ "${token}" =~ ^(@[^@]+)@(.+)$ ]]; then
365
576
  pkg="${BASH_REMATCH[1]}"
@@ -370,12 +581,18 @@ guard_extract_specs() {
370
581
  fi
371
582
  printf '%s\t%s\n' "${pkg}" "${spec}"
372
583
  done
584
+ printf '%s\n' "${source}" | guard_extract_flagged_specs
373
585
  }
374
586
 
375
587
  LEDGER_ECOSYSTEM=$(guard_detect_ecosystem "${COMMAND}")
376
588
  LEDGER_SPECS=()
377
589
  while IFS= read -r ledger_spec_line; do
378
590
  [[ -z "${ledger_spec_line}" ]] && continue
591
+ if [[ ${#LEDGER_SPECS[@]} -gt 0 ]]; then
592
+ for existing_spec_line in "${LEDGER_SPECS[@]}"; do
593
+ [[ "${existing_spec_line}" == "${ledger_spec_line}" ]] && continue 2
594
+ done
595
+ fi
379
596
  LEDGER_SPECS+=("${ledger_spec_line}")
380
597
  done < <(guard_extract_specs "${COMMAND}")
381
598
 
@@ -383,6 +600,17 @@ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 && -f "${SAFEDEPS_LE
383
600
  # shellcheck source=../lib/ledger/ledger.sh
384
601
  source "${SAFEDEPS_LEDGER_LIB}"
385
602
 
603
+ # Resolve a runnable `safedeps` invocation for the block message so the
604
+ # self-heal loop works whether or not the CLI is on PATH. Prefer the PATH
605
+ # command (clean UX); otherwise name the absolute repo bin (quoted via %q so
606
+ # it survives spaces in $HOME). Keeps the gate self-contained — the install
607
+ # of a `~/.local/bin/safedeps` symlink is a convenience, never a requirement.
608
+ if command -v safedeps >/dev/null 2>&1; then
609
+ SAFEDEPS_INVOKE="safedeps"
610
+ else
611
+ printf -v SAFEDEPS_INVOKE '%q' "${SAFEDEPS_REPO_BIN}"
612
+ fi
613
+
386
614
  GUARD_BLOCKED_CMDS=()
387
615
  for entry in "${LEDGER_SPECS[@]}"; do
388
616
  pkg="${entry%%$'\t'*}"
@@ -390,7 +618,7 @@ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 && -f "${SAFEDEPS_LE
390
618
  [[ -z "${pkg}" || -z "${spec}" ]] && continue
391
619
  if ! safedeps_ledger_check "${LEDGER_ECOSYSTEM}" "${pkg}" "${spec}" 2>/dev/null \
392
620
  | jq -e '.approved == true' >/dev/null 2>&1; then
393
- GUARD_BLOCKED_CMDS+=("safedeps check ${LEDGER_ECOSYSTEM} ${pkg}@${spec}")
621
+ GUARD_BLOCKED_CMDS+=("${SAFEDEPS_INVOKE} check ${LEDGER_ECOSYSTEM} ${pkg}@${spec}")
394
622
  fi
395
623
  done
396
624
 
@@ -423,5 +651,15 @@ CURRENT_STATE=$(jq -n --arg sid "${SNAPSHOT_ID}" --arg pdir "${PROJECT_DIR}" --a
423
651
  '{snapshot_id: $sid, project_dir: $pdir, dir_hash: $dhash}')
424
652
  write_state_file "${GUARD_DIR}/current_state" "${CURRENT_STATE}"
425
653
 
654
+ if ! jq -e 'has("turn_id")' <<< "${INPUT}" >/dev/null 2>&1 && \
655
+ command_is_injectable_npm_install "${COMMAND}" && \
656
+ ! command_has_ignore_scripts_flag "${COMMAND}"; then
657
+ UPDATED_COMMAND="${COMMAND} --ignore-scripts"
658
+ mark_ignore_scripts_injected
659
+ jq -nc --arg command "${UPDATED_COMMAND}" \
660
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",updatedInput:{command:$command}}}'
661
+ exit 0
662
+ fi
663
+
426
664
  # Allow the command to proceed — PostToolUse will verify the result
427
665
  exit 0
@@ -38,19 +38,68 @@ port=$(cat "${port_file}")
38
38
 
39
39
  export SAFEDEPS_HOME="${tmp_root}/safe"
40
40
  export SAFEDEPS_OSV_API_URL="http://127.0.0.1:${port}/osv/v1/query"
41
+ export SAFEDEPS_OSV_BATCH_API_URL="http://127.0.0.1:${port}/osv/v1/querybatch"
41
42
  export SAFEDEPS_KEV_CATALOG_URL="http://127.0.0.1:${port}/kev.json"
42
43
  export SAFEDEPS_GHSA_API_URL="http://127.0.0.1:${port}/advisories"
43
44
  export SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS=0
44
45
 
46
+ closure_fixture="${tmp_root}/closure-fixture.json"
47
+ cat > "${closure_fixture}" <<'EOF'
48
+ {
49
+ "fixture-clean@1.0.0": [
50
+ {"package":"fixture-clean","version":"1.0.0","direct":true}
51
+ ],
52
+ "fixture-vuln@1.0.0": [
53
+ {"package":"fixture-vuln","version":"1.0.0","direct":true}
54
+ ],
55
+ "fixture-vuln@1.0.1": [
56
+ {"package":"fixture-vuln","version":"1.0.1","direct":true}
57
+ ],
58
+ "fixture-multi-vuln@1.0.0": [
59
+ {"package":"fixture-multi-vuln","version":"1.0.0","direct":true}
60
+ ],
61
+ "fixture-multi-vuln@1.0.1": [
62
+ {"package":"fixture-multi-vuln","version":"1.0.1","direct":true}
63
+ ],
64
+ "fixture-multi-vuln@1.0.5": [
65
+ {"package":"fixture-multi-vuln","version":"1.0.5","direct":true}
66
+ ],
67
+ "fixture-unpatched@1.0.0": [
68
+ {"package":"fixture-unpatched","version":"1.0.0","direct":true}
69
+ ],
70
+ "fixture-kev@1.0.0": [
71
+ {"package":"fixture-kev","version":"1.0.0","direct":true}
72
+ ],
73
+ "fixture-parent@1.0.0": [
74
+ {"package":"fixture-parent","version":"1.0.0","direct":true},
75
+ {"package":"fixture-child","version":"1.0.0","direct":false}
76
+ ]
77
+ }
78
+ EOF
79
+ export SAFEDEPS_NPM_CLOSURE_FIXTURE_JSON="${closure_fixture}"
80
+
45
81
  clean_json=$(./bin/safedeps --json check npm fixture-clean@1.0.0)
46
82
  [[ "$(jq -r '.result' <<< "${clean_json}")" == "clean" ]] || fail "clean fixture approved"
47
83
  pass "clean advisory approval"
48
84
 
85
+ closure_json=$(./bin/safedeps --json check npm fixture-parent@1.0.0)
86
+ [[ "$(jq -r '.result' <<< "${closure_json}")" == "clean" ]] || fail "closure fixture approved"
87
+ [[ "$(jq -r '.transitive_count' <<< "${closure_json}")" == "1" ]] || fail "closure fixture records transitive count"
88
+ parent_hash=$(jq -r '.spec_hash' <<< "${closure_json}")
89
+ parent_file="${SAFEDEPS_HOME}/approved-specs/${parent_hash/:/-}.json"
90
+ [[ "$(jq -r '.transitive_specs[0].package' "${parent_file}")" == "fixture-child" ]] || fail "ledger transitive_specs records fixture child"
91
+ pass "closure approval records transitive_specs"
92
+
49
93
  patched_json=$(./bin/safedeps --json check npm fixture-vuln@1.0.0)
50
94
  [[ "$(jq -r '.result' <<< "${patched_json}")" == "patched_available" ]] || fail "patched fixture narrows"
51
95
  [[ "$(jq -r '.suggested_spec' <<< "${patched_json}")" == "1.0.1" ]] || fail "patched fixture suggests fixed version"
52
96
  pass "patched advisory narrowing"
53
97
 
98
+ multi_patched_json=$(./bin/safedeps --json check npm fixture-multi-vuln@1.0.0)
99
+ [[ "$(jq -r '.result' <<< "${multi_patched_json}")" == "patched_available" ]] || fail "multi patched fixture narrows"
100
+ [[ "$(jq -r '.suggested_spec' <<< "${multi_patched_json}")" == "1.0.5" ]] || fail "multi patched fixture tries later clean fixed version"
101
+ pass "patched advisory tries all fixed candidates"
102
+
54
103
  set +e
55
104
  unpatched_json=$(./bin/safedeps --json check npm fixture-unpatched@1.0.0)
56
105
  unpatched_status=$?
@@ -68,18 +117,136 @@ mkdir -p "${project_dir}"
68
117
  printf '{"dependencies":{}}\n' > "${project_dir}/package.json"
69
118
  hook_allow=$(
70
119
  scripts/safedeps-pre-guard.sh <<EOF
71
- {"tool_name":"Bash","tool_input":{"command":"npm install fixture-vuln@1.0.1"},"cwd":"${project_dir}"}
120
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-vuln@1.0.1"},"cwd":"${project_dir}","turn_id":"turn-e2e","model":"codex-test"}
72
121
  EOF
73
122
  )
74
123
  [[ -z "${hook_allow}" ]] || fail "hook allows narrowed approved spec"
75
124
  pass "hook allows approved narrowed spec"
76
125
 
126
+ effect_project="${tmp_root}/effect-project"
127
+ mkdir -p "${effect_project}"
128
+ printf '{"dependencies":{}}\n' > "${effect_project}/package.json"
129
+
130
+ effect_clean_pre=$(
131
+ scripts/safedeps-pre-guard.sh <<EOF
132
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${effect_project}","turn_id":"turn-e2e","model":"codex-test"}
133
+ EOF
134
+ )
135
+ [[ -z "${effect_clean_pre}" ]] || fail "effect clean pre hook allows closure-approved direct spec"
136
+ cat > "${effect_project}/package-lock.json" <<'EOF'
137
+ {
138
+ "name": "effect-project",
139
+ "lockfileVersion": 3,
140
+ "packages": {
141
+ "": {"dependencies": {"fixture-parent": "1.0.0"}},
142
+ "node_modules/fixture-parent": {"version": "1.0.0", "dependencies": {"fixture-child": "1.0.0"}},
143
+ "node_modules/fixture-child": {"version": "1.0.0"}
144
+ }
145
+ }
146
+ EOF
147
+ effect_clean_post=$(
148
+ scripts/safedeps-post-verify.sh <<EOF
149
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"}}
150
+ EOF
151
+ )
152
+ [[ -z "${effect_clean_post}" ]] || fail "post hook passes approved full closure"
153
+ pass "post hook passes approved full closure"
154
+
155
+ inert_project="${tmp_root}/inert-project"
156
+ mkdir -p "${inert_project}"
157
+ printf '{"dependencies":{}}\n' > "${inert_project}/package.json"
158
+ inert_pre=$(
159
+ scripts/safedeps-pre-guard.sh <<EOF
160
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${inert_project}"}
161
+ EOF
162
+ )
163
+ [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${inert_pre}")" == "allow" ]] || fail "inert pre hook emits Claude allow"
164
+ [[ "$(jq -r '.hookSpecificOutput.updatedInput.command' <<< "${inert_pre}")" == "npm install fixture-parent@1.0.0 --ignore-scripts" ]] || fail "inert pre hook injects ignore-scripts"
165
+ cat > "${inert_project}/package-lock.json" <<'EOF'
166
+ {
167
+ "name": "inert-project",
168
+ "lockfileVersion": 3,
169
+ "packages": {
170
+ "": {"dependencies": {"fixture-parent": "1.0.0"}},
171
+ "node_modules/fixture-parent": {"version": "1.0.0", "dependencies": {"fixture-child": "1.0.0"}},
172
+ "node_modules/fixture-child": {"version": "1.0.0"}
173
+ }
174
+ }
175
+ EOF
176
+ stub_bin="${tmp_root}/stub-bin"
177
+ mkdir -p "${stub_bin}"
178
+ cat > "${stub_bin}/npm" <<EOF
179
+ #!/usr/bin/env bash
180
+ printf '%s\n' "\$*" >> "${tmp_root}/npm-calls.log"
181
+ exit 0
182
+ EOF
183
+ chmod +x "${stub_bin}/npm"
184
+ inert_post=$(
185
+ PATH="${stub_bin}:${PATH}" scripts/safedeps-post-verify.sh <<EOF
186
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0 --ignore-scripts"}}
187
+ EOF
188
+ )
189
+ [[ -z "${inert_post}" ]] || fail "post hook keeps verified inert rebuild success quiet"
190
+ grep -qx 'rebuild' "${tmp_root}/npm-calls.log" || fail "post hook runs npm rebuild after verified injected install"
191
+ pass "post hook rebuilds after verified inert install"
192
+
193
+ export SAFEDEPS_HOME="${tmp_root}/safe-missing-transitive"
194
+ export SAFEDEPS_OSV_API_URL="http://127.0.0.1:${port}/osv/v1/query"
195
+ export SAFEDEPS_OSV_BATCH_API_URL="http://127.0.0.1:${port}/osv/v1/querybatch"
196
+ export SAFEDEPS_KEV_CATALOG_URL="http://127.0.0.1:${port}/kev.json"
197
+ export SAFEDEPS_GHSA_API_URL="http://127.0.0.1:${port}/advisories"
198
+ export SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS=0
199
+ export SAFEDEPS_NPM_CLOSURE_FIXTURE_JSON="${closure_fixture}"
200
+ missing_project="${tmp_root}/missing-project"
201
+ mkdir -p "${missing_project}"
202
+ printf '{"dependencies":{}}\n' > "${missing_project}/package.json"
203
+ SAFEDEPS_HOME="${SAFEDEPS_HOME}" lib/ledger/ledger.sh approve npm fixture-parent 1.0.0 1.0.0 direct-only >/dev/null
204
+ missing_pre=$(
205
+ scripts/safedeps-pre-guard.sh <<EOF
206
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${missing_project}","turn_id":"turn-e2e","model":"codex-test"}
207
+ EOF
208
+ )
209
+ [[ -z "${missing_pre}" ]] || fail "missing-transitive pre hook allows direct-only approved spec"
210
+ cat > "${missing_project}/package-lock.json" <<'EOF'
211
+ {
212
+ "name": "missing-project",
213
+ "lockfileVersion": 3,
214
+ "packages": {
215
+ "": {"dependencies": {"fixture-parent": "1.0.0"}},
216
+ "node_modules/fixture-parent": {"version": "1.0.0", "dependencies": {"fixture-child": "1.0.0"}},
217
+ "node_modules/fixture-child": {"version": "1.0.0"}
218
+ }
219
+ }
220
+ EOF
221
+ missing_post=$(
222
+ scripts/safedeps-post-verify.sh <<EOF
223
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"}}
224
+ EOF
225
+ )
226
+ grep -q '의심스러운 패키지 변경 감지' <<< "${missing_post}" || fail "post hook reorgs unapproved transitive package"
227
+ grep -q 'fixture-child@1.0.0' <<< "${missing_post}" || fail "post hook names unapproved transitive package"
228
+ pass "post hook reorgs unapproved transitive package"
229
+
230
+ export SAFEDEPS_HOME="${tmp_root}/safe"
231
+ export SAFEDEPS_OSV_API_URL="http://127.0.0.1:${port}/osv/v1/query"
232
+ export SAFEDEPS_OSV_BATCH_API_URL="http://127.0.0.1:${port}/osv/v1/querybatch"
233
+ export SAFEDEPS_KEV_CATALOG_URL="http://127.0.0.1:${port}/kev.json"
234
+ export SAFEDEPS_GHSA_API_URL="http://127.0.0.1:${port}/advisories"
235
+ export SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS=0
236
+ export SAFEDEPS_NPM_CLOSURE_FIXTURE_JSON="${closure_fixture}"
237
+
77
238
  printf '%s\n' '{"vulnerable":["fixture-clean@1.0.0"]}' > "${state_file}"
78
239
  recheck_json=$(./bin/safedeps --json re-check)
79
240
  [[ "$(jq -r '.revoked | length' <<< "${recheck_json}")" == "1" ]] || fail "re-check revokes newly vulnerable spec"
80
241
  [[ "$(jq -r '.revoked[0].package' <<< "${recheck_json}")" == "fixture-clean" ]] || fail "re-check revoked expected package"
81
242
  pass "re-check revocation"
82
243
 
244
+ SAFEDEPS_HOME="${SAFEDEPS_HOME}" lib/ledger/ledger.sh approve npm fixture-forged 1.0.0 1.0.0 forged-test >/dev/null
245
+ forgery_json=$(./bin/safedeps --json re-check)
246
+ [[ "$(jq -r '.suspected_forgery | length' <<< "${forgery_json}")" == "1" ]] || fail "re-check flags direct ledger write without approval provenance"
247
+ [[ "$(jq -r '.suspected_forgery[0].package' <<< "${forgery_json}")" == "fixture-forged" ]] || fail "re-check flags expected forged package"
248
+ pass "re-check flags ledger approval provenance mismatch"
249
+
83
250
  legacy_home="${tmp_root}/legacy"
84
251
  target_home="${tmp_root}/migrated"
85
252
  mkdir -p "${legacy_home}/approved-specs"
@@ -93,12 +260,21 @@ pass "legacy state migration"
93
260
  installer_home="${tmp_root}/installer-home"
94
261
  mkdir -p "${installer_home}/.claude" "${installer_home}/.codex"
95
262
  cat > "${installer_home}/.claude/settings.json" <<EOF
96
- {"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"${installer_home}/.claude/skills/npm-reorg-guard/scripts/guard.sh"}]}],"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"${installer_home}/.claude/skills/npm-reorg-guard/scripts/verify.sh"}]}]}}
263
+ {"hooks":{"PreToolUse":[{"matcher":"Other","hooks":[{"type":"command","command":"~/.claude/skills/safedeps/scripts/safedeps-pre-guard.sh"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"${installer_home}/.claude/skills/npm-reorg-guard/scripts/guard.sh"}]}],"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"${installer_home}/.claude/skills/npm-reorg-guard/scripts/verify.sh"}]}]}}
97
264
  EOF
98
265
  HOME="${installer_home}" node scripts/install/install-safedeps-hooks.mjs >/dev/null
99
- jq -e --arg pre "${ROOT_DIR}/scripts/safedeps-pre-guard.sh" '
100
- [.hooks.PreToolUse[]?.hooks[]?.command] | index($pre)
266
+ jq -e --arg pre "~/.claude/skills/safedeps/scripts/safedeps-pre-guard.sh" '
267
+ [.hooks.PreToolUse[]? | select(.matcher == "Bash") | .hooks[]?.command] | index($pre)
101
268
  ' "${installer_home}/.claude/settings.json" >/dev/null || fail "installer writes new pre hook"
269
+ jq -e --arg post "~/.claude/skills/safedeps/scripts/safedeps-post-verify.sh" '
270
+ [.hooks.PostToolUse[]?.hooks[]?.command] | index($post)
271
+ ' "${installer_home}/.claude/settings.json" >/dev/null || fail "installer writes new post hook"
272
+ jq -e --arg pre "~/.codex/skills/safedeps/scripts/safedeps-pre-guard.sh" '
273
+ [.hooks.PreToolUse[]?.hooks[]?.command] | index($pre)
274
+ ' "${installer_home}/.codex/hooks.json" >/dev/null || fail "installer writes codex pre hook"
275
+ jq -e --arg post "~/.codex/skills/safedeps/scripts/safedeps-post-verify.sh" '
276
+ [.hooks.PostToolUse[]?.hooks[]?.command] | index($post)
277
+ ' "${installer_home}/.codex/hooks.json" >/dev/null || fail "installer writes codex post hook"
102
278
  if jq -e '[.. | strings] | any(contains("npm-reorg-guard"))' "${installer_home}/.claude/settings.json" >/dev/null; then
103
279
  fail "installer removes legacy hook"
104
280
  fi
@@ -55,6 +55,17 @@ function osvResponse(packageName, version) {
55
55
  if (packageName === 'fixture-vuln' && version === '1.0.0') {
56
56
  return { vulns: [osvVuln('CVE-2026-1001', '1.0.1')] };
57
57
  }
58
+ if (packageName === 'fixture-multi-vuln' && version === '1.0.0') {
59
+ return {
60
+ vulns: [
61
+ osvVuln('CVE-2026-1003', '1.0.1'),
62
+ osvVuln('CVE-2026-1004', '1.0.5')
63
+ ]
64
+ };
65
+ }
66
+ if (packageName === 'fixture-multi-vuln' && version === '1.0.1') {
67
+ return { vulns: [osvVuln('CVE-2026-1004', '1.0.5')] };
68
+ }
58
69
  if (packageName === 'fixture-unpatched') {
59
70
  return { vulns: [osvVuln('CVE-2026-1002', null)] };
60
71
  }
@@ -74,6 +85,16 @@ const server = http.createServer(async (req, res) => {
74
85
  return;
75
86
  }
76
87
 
88
+ if (req.method === 'POST' && req.url === '/osv/v1/querybatch') {
89
+ const body = await readJson(req);
90
+ const queries = Array.isArray(body.queries) ? body.queries : [];
91
+ res.setHeader('content-type', 'application/json');
92
+ res.end(JSON.stringify({
93
+ results: queries.map((query) => osvResponse(query.package?.name || '', query.version || ''))
94
+ }));
95
+ return;
96
+ }
97
+
77
98
  if (req.method === 'GET' && req.url === '/kev.json') {
78
99
  res.setHeader('content-type', 'application/json');
79
100
  res.end(JSON.stringify({