@aldegad/safedeps 2.1.1 → 2.4.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.
@@ -36,8 +36,26 @@ SAFEDEPS_MANIFEST_FILES=(
36
36
  umask 077
37
37
  mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
38
38
 
39
+ # Observable record of any gate bypass / unavailability (AGENTS.md: no silent fallback —
40
+ # every bypass must be observable and logged).
41
+ log_advisory() {
42
+ printf '%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "${GUARD_DIR}/advisory.log" 2>/dev/null || true
43
+ }
44
+
39
45
  if ! command -v jq >/dev/null 2>&1; then
40
- echo "safedeps: jq is not installed; skipping guard hook." >&2
46
+ # jq is required to parse the hook payload. Without it we cannot read the exact
47
+ # command, so do a best-effort fail-closed: read the raw payload and, if it
48
+ # looks like a dependency install, DENY (an install we cannot verify must not
49
+ # proceed). Non-install commands are allowed — jq absence must not block `ls`.
50
+ # Either branch is recorded in advisory.log; never a silent skip.
51
+ raw_input=$(cat)
52
+ log_advisory "pre-guard: jq missing — gate cannot parse the payload."
53
+ if printf '%s' "${raw_input}" | grep -qiE '(npm|pnpm|yarn|bun)([^"]*)(install|add|dlx)|[^a-z]npx[[:space:]]|pip[0-9]*[[: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([^"]*)dependency:get|dotnet[[:space:]]+add[[:space:]]+package'; then
54
+ log_advisory "pre-guard DENY: jq missing on a likely dependency-install command — fail-closed."
55
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"safedeps: jq is required to gate dependency installs and is not installed — install blocked fail-closed. Install jq, then retry."}}\n'
56
+ exit 0
57
+ fi
58
+ echo "safedeps: jq is not installed — install gate disabled (non-install commands still allowed); logged to advisory.log." >&2
41
59
  exit 0
42
60
  fi
43
61
 
@@ -48,8 +66,10 @@ acquire_state_lock() {
48
66
  # Detect stale locks left by SIGKILL/OOM (V-005)
49
67
  if [[ -d "${STATE_LOCK_DIR}" ]]; then
50
68
  local lock_mtime=""
51
- if lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null) || \
52
- lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null); then
69
+ # GNU (`-c %Y`, Linux) first, then BSD/macOS (`-f %m`): on Linux `stat -f`
70
+ # means --file-system and would not yield an mtime.
71
+ if lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null) || \
72
+ lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null); then
53
73
  local now
54
74
  now=$(date +%s)
55
75
  if [[ $(( now - lock_mtime )) -gt 60 ]]; then
@@ -61,8 +81,11 @@ acquire_state_lock() {
61
81
  fi
62
82
 
63
83
  attempts=$((attempts + 1))
64
- if [[ ${attempts} -ge 100 ]]; then
65
- echo "safedeps: could not acquire state lock; skipping guard hook." >&2
84
+ if [[ ${attempts} -ge ${SAFEDEPS_LOCK_MAX_ATTEMPTS:-100} ]]; then
85
+ # acquire_state_lock is only reached for install candidates, so failing to
86
+ # serialize/snapshot means this install cannot be gated — fail CLOSED (deny).
87
+ log_advisory "pre-guard DENY: state lock unavailable for an install command — fail-closed."
88
+ jq -nc '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"safedeps: could not acquire the state lock (another safedeps run may be active). Install blocked fail-closed — retry in a moment."}}'
66
89
  exit 0
67
90
  fi
68
91
  sleep 0.1
@@ -76,10 +99,16 @@ release_state_lock() {
76
99
  write_state_file() {
77
100
  local target_path="$1"
78
101
  local value="$2"
79
- local temp_path="${target_path}.$$"
80
-
102
+ local target_dir
103
+ local target_base
104
+ local temp_path
105
+
106
+ target_dir=$(dirname "${target_path}")
107
+ target_base=$(basename "${target_path}")
108
+ mkdir -p "${target_dir}" || return 1
109
+ temp_path=$(mktemp "${target_dir}/.${target_base}.XXXXXX") || return 1
81
110
  printf '%s\n' "${value}" > "${temp_path}"
82
- mv "${temp_path}" "${target_path}"
111
+ mv -f "${temp_path}" "${target_path}"
83
112
  }
84
113
 
85
114
  compute_dir_hash() {
@@ -99,10 +128,13 @@ command_is_dependency_install() {
99
128
  local scan_command
100
129
  local install_pattern
101
130
 
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:]]|$)'
131
+ 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
132
 
105
- echo "${scan_command}" | grep -qEi "${install_pattern}"
133
+ while IFS= read -r scan_command; do
134
+ scan_command=$(command_scan_text "${scan_command}")
135
+ echo "${scan_command}" | grep -qEi "${install_pattern}" && return 0
136
+ done < <(command_candidate_texts "${command}")
137
+ return 1
106
138
  }
107
139
 
108
140
  command_hides_dependency_install() {
@@ -113,7 +145,7 @@ command_hides_dependency_install() {
113
145
  manager_pattern='(npm|npx|pnpm|yarn|pip3?|python3?[[:space:]]+-m[[:space:]]+pip|poetry|uv|pipenv|cargo|go|gem|bundle|mvn|dotnet)'
114
146
  verb_pattern='(install|i|add|update|up|upgrade|dlx|get|dependency:get|package)'
115
147
 
116
- echo "${command}" | grep -qEi '(eval[[:space:]]|\$\(|`)' && \
148
+ echo "${command}" | grep -qEi '(eval[[:space:]]|\$\(|`|(^|[[:space:];|&])(bash|sh|zsh)[[:space:]]+-[A-Za-z]*c[[:space:]])' && \
117
149
  echo "${command}" | grep -qEi "${manager_pattern}.*${verb_pattern}"
118
150
  }
119
151
 
@@ -154,6 +186,93 @@ command_scan_text() {
154
186
  printf '%s' "${output}"
155
187
  }
156
188
 
189
+ normalize_install_text() {
190
+ local text="$1"
191
+
192
+ for _ in 1 2 3; do
193
+ text=$(printf '%s' "${text}" | sed -E \
194
+ -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' \
195
+ -e 's#(^|[;&|][[:space:]]*)(env[[:space:]]+([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)*|command[[:space:]]+)#\1#g')
196
+ done
197
+ printf '%s' "${text}"
198
+ }
199
+
200
+ strip_heredoc_bodies() {
201
+ local input="$1"
202
+ local line
203
+ local delimiter=""
204
+ local heredoc_re="<<-?[[:space:]]*[\"']?([A-Za-z0-9_][A-Za-z0-9_.-]*)[\"']?"
205
+
206
+ while IFS= read -r line || [[ -n "${line}" ]]; do
207
+ if [[ -n "${delimiter}" ]]; then
208
+ if [[ "${line}" == "${delimiter}" ]]; then
209
+ delimiter=""
210
+ fi
211
+ continue
212
+ fi
213
+
214
+ if [[ "${line}" =~ ${heredoc_re} ]]; then
215
+ delimiter="${BASH_REMATCH[1]}"
216
+ fi
217
+ printf '%s\n' "${line}"
218
+ done <<< "${input}"
219
+ }
220
+
221
+ extract_shell_c_payloads() {
222
+ local rest="$1"
223
+
224
+ while [[ "${rest}" =~ (bash|sh|zsh)[[:space:]]+-[A-Za-z]*c[[:space:]]+\"([^\"]*)\" ]]; do
225
+ printf '%s\n' "${BASH_REMATCH[2]}"
226
+ rest="${rest#*"${BASH_REMATCH[0]}"}"
227
+ done
228
+
229
+ rest="$1"
230
+ while [[ "${rest}" =~ (bash|sh|zsh)[[:space:]]+-[A-Za-z]*c[[:space:]]+\'([^\']*)\' ]]; do
231
+ printf '%s\n' "${BASH_REMATCH[2]}"
232
+ rest="${rest#*"${BASH_REMATCH[0]}"}"
233
+ done
234
+ }
235
+
236
+ command_candidate_texts() {
237
+ local command="$1"
238
+ local payload
239
+
240
+ command=$(strip_heredoc_bodies "${command}")
241
+
242
+ normalize_install_text "${command}"
243
+ printf '\n'
244
+ while IFS= read -r payload; do
245
+ [[ -z "${payload}" ]] && continue
246
+ normalize_install_text "${payload}"
247
+ printf '\n'
248
+ done < <(extract_shell_c_payloads "${command}")
249
+ }
250
+
251
+ command_is_injectable_npm_install() {
252
+ local command="$1"
253
+ local scan_command
254
+ local npm_install_pattern
255
+
256
+ npm_install_pattern='(^|[;&|]+[[:space:]]*)npm([[:space:]]+--?[a-zA-Z0-9_-]+([=[:space:]][^[:space:]]+)?)?[[:space:]]+(install|i|add|ci|update|up|upgrade)([[:space:]]|$)'
257
+
258
+ while IFS= read -r scan_command; do
259
+ scan_command=$(command_scan_text "${scan_command}")
260
+ echo "${scan_command}" | grep -qEi "${npm_install_pattern}" && return 0
261
+ done < <(command_candidate_texts "${command}")
262
+ return 1
263
+ }
264
+
265
+ command_has_ignore_scripts_flag() {
266
+ local command="$1"
267
+ local scan_command
268
+
269
+ while IFS= read -r scan_command; do
270
+ scan_command=$(command_scan_text "${scan_command}")
271
+ echo "${scan_command}" | grep -qEi -- '(^|[[:space:]])--ignore-scripts([=[:space:]]|$)' && return 0
272
+ done < <(command_candidate_texts "${command}")
273
+ return 1
274
+ }
275
+
157
276
  snapshot_project_file() {
158
277
  local relative_file="$1"
159
278
  local category="${2:-manifest}"
@@ -275,10 +394,24 @@ cat > "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_meta.json" << META_EOF
275
394
  "timestamp": ${TIMESTAMP},
276
395
  "project_dir": $(printf '%s' "${PROJECT_DIR}" | jq -Rs .),
277
396
  "command": $(printf '%s' "${COMMAND}" | jq -Rs .),
397
+ "ignore_scripts_injected": false,
278
398
  "lock_files_found": ${SNAPSHOTTED}
279
399
  }
280
400
  META_EOF
281
401
 
402
+ mark_ignore_scripts_injected() {
403
+ local meta_file="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_meta.json"
404
+ local temp_file
405
+
406
+ [[ -f "${meta_file}" ]] || return 0
407
+ temp_file=$(mktemp "${SNAPSHOT_DIR}/.${SNAPSHOT_ID}_meta.XXXXXX") || return 0
408
+ if jq '.ignore_scripts_injected = true' "${meta_file}" > "${temp_file}"; then
409
+ mv -f "${temp_file}" "${meta_file}"
410
+ else
411
+ rm -f "${temp_file}"
412
+ fi
413
+ }
414
+
282
415
  # --- Pre-flight security checks on the command itself ---
283
416
 
284
417
  SUSPICIOUS=false
@@ -320,46 +453,147 @@ fi
320
453
 
321
454
  # --- Phase 2 advisory gate — ledger enforcement -------------------------------
322
455
  # 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.
456
+ # spec ledger. Miss/expired → block with a structured message that names a
457
+ # runnable `safedeps check` command the caller (agent or human) should run
458
+ # next — PATH command when present, else an absolute path, so the self-heal
459
+ # loop never dead-ends on a missing PATH symlink.
325
460
  #
326
461
  # Conservative: only block when at least one pkg@spec token is parseable. Bare
327
462
  # `npm install` (lockfile install) falls through to the v1 reorg checks.
328
463
 
329
- SAFEDEPS_LEDGER_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh"
464
+ SAFEDEPS_LEDGER_LIB="${SAFEDEPS_LEDGER_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh}"
330
465
  SAFEDEPS_REPO_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/safedeps"
331
466
 
332
467
  guard_detect_ecosystem() {
333
468
  local cmd="$1"
334
469
  local scan_cmd
335
470
 
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
471
+ while IFS= read -r scan_cmd; do
472
+ scan_cmd=$(command_scan_text "${scan_cmd}")
473
+ if echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(npm|pnpm|yarn|npx)([[:space:]]|$)'; then
474
+ printf 'npm'
475
+ return 0
476
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(pip3?|poetry|uv|pipenv|((python3?|py)[[:space:]]+-m[[:space:]]+pip))([[:space:]]|$)'; then
477
+ printf 'pypi'
478
+ return 0
479
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)cargo([[:space:]]|$)'; then
480
+ printf 'crates.io'
481
+ return 0
482
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)go([[:space:]]|$)'; then
483
+ printf 'go'
484
+ return 0
485
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(gem|bundle)([[:space:]]|$)'; then
486
+ printf 'rubygems'
487
+ return 0
488
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)mvn([[:space:]]|$)'; then
489
+ printf 'maven'
490
+ return 0
491
+ elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)dotnet([[:space:]]|$)'; then
492
+ printf 'nuget'
493
+ return 0
494
+ fi
495
+ done < <(command_candidate_texts "${cmd}")
496
+ printf ''
497
+ }
498
+
499
+ guard_runner_operands() {
500
+ # Runner forms (`npx`, `pnpm dlx`, `yarn dlx`) EXECUTE a package; tokens after
501
+ # the executed package are arguments to that program, NOT package specs. Emit
502
+ # only the spec-bearing operands: any `-p/--package <pkg>` value plus the first
503
+ # bare token (the executed package). This stops an argument such as an email
504
+ # (`dev1@block-s.io`) or a secret value passed to `npx wrangler ...` from being
505
+ # misread as a `pkg@spec` install.
506
+ local scan="$1"
507
+ local after want_value tok
508
+ after=$(printf '%s' "${scan}" | grep -oiE '(npx|dlx)[[:space:]].*' | head -n1 || true)
509
+ after="${after#* }" # drop the runner keyword, keep its operands
510
+ [[ -z "${after}" ]] && return 0
511
+
512
+ want_value=false
513
+ for tok in ${after}; do
514
+ if [[ "${want_value}" == true ]]; then
515
+ printf '%s\n' "${tok}"
516
+ want_value=false
517
+ continue
518
+ fi
519
+ case "${tok}" in
520
+ -p|--package) want_value=true ;;
521
+ --package=*) printf '%s\n' "${tok#--package=}" ;;
522
+ -*) : ;; # other flag (e.g. -y/--yes), skip
523
+ *)
524
+ printf '%s\n' "${tok}" # executed package; rest are program args
525
+ break
526
+ ;;
527
+ esac
528
+ done
529
+ }
530
+
531
+ guard_extract_flagged_specs() {
532
+ awk '
533
+ {
534
+ for (i = 1; i <= NF; i++) {
535
+ if ($i ~ /^[A-Za-z][A-Za-z0-9._-]*==[A-Za-z0-9][A-Za-z0-9._+!~-]*$/) {
536
+ split($i, parts, "==")
537
+ print parts[1] "\t" parts[2]
538
+ }
539
+
540
+ if ($i == "gem" && $(i + 1) == "install") {
541
+ pkg = $(i + 2)
542
+ for (j = i + 3; j <= NF; j++) {
543
+ if (($j == "-v" || $j == "--version") && $(j + 1) != "") print pkg "\t" $(j + 1)
544
+ if ($j ~ /^--version=/) { sub(/^--version=/, "", $j); print pkg "\t" $j }
545
+ }
546
+ }
547
+
548
+ if ($i == "cargo" && $(i + 1) == "add") {
549
+ pkg = $(i + 2)
550
+ for (j = i + 3; j <= NF; j++) {
551
+ if (($j == "--vers" || $j == "--version") && $(j + 1) != "") print pkg "\t" $(j + 1)
552
+ if ($j ~ /^--(vers|version)=/) { sub(/^--(vers|version)=/, "", $j); print pkg "\t" $j }
553
+ }
554
+ }
555
+
556
+ if ($i == "dotnet" && $(i + 1) == "add" && $(i + 2) == "package") {
557
+ pkg = $(i + 3)
558
+ for (j = i + 4; j <= NF; j++) {
559
+ if ($j == "--version" && $(j + 1) != "") print pkg "\t" $(j + 1)
560
+ if ($j ~ /^--version=/) { sub(/^--version=/, "", $j); print pkg "\t" $j }
561
+ }
562
+ }
563
+ }
564
+ }
565
+ '
354
566
  }
355
567
 
356
568
  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.
569
+ # Echo one "pkg<TAB>spec" line per pkg@spec OPERAND genuinely being installed.
570
+ # Handles @scope/name@spec and bare-name@spec. Two precision rules keep
571
+ # non-package "@" tokens from being misread as an install:
572
+ # 1. Runner segments (npx / pnpm dlx / yarn dlx) contribute ONLY their
573
+ # executed package — trailing tokens are program arguments, not specs
574
+ # (so `npx wrangler ... dev1@block-s.io` is never read as a spec).
575
+ # 2. Email / host operands (user@domain.tld) are never package specs.
576
+ # Each shell segment is judged independently so a genuine install in one
577
+ # segment is still gated even when another segment just runs a tool via npx.
359
578
  local cmd="$1"
360
- echo "${cmd}" \
361
- | grep -oE '(@[a-zA-Z0-9._/-]+/)?[a-zA-Z][a-zA-Z0-9._-]*@[a-zA-Z0-9._^~|<>=*+-]+' \
579
+ local seg source=""
580
+
581
+ while IFS= read -r seg; do
582
+ [[ -z "${seg//[[:space:]]/}" ]] && continue
583
+ if printf '%s' "${seg}" | grep -qEi '(^|[[:space:]])(npx|dlx)([[:space:]]|$)'; then
584
+ source+="$(guard_runner_operands "${seg}")"$'\n'
585
+ else
586
+ source+="${seg}"$'\n'
587
+ fi
588
+ done < <(command_candidate_texts "${cmd}" | tr ';|&' '\n')
589
+
590
+ { printf '%s' "${source}" \
591
+ | grep -oE '(@[a-zA-Z0-9._/-]+/)?[a-zA-Z][a-zA-Z0-9._-]*@[a-zA-Z0-9._^~|<>=*+-]+' || true; } \
362
592
  | while IFS= read -r token; do
593
+ # An email / host operand (user@domain.tld) is never a package spec.
594
+ if [[ "${token}" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
595
+ continue
596
+ fi
363
597
  local pkg spec
364
598
  if [[ "${token}" =~ ^(@[^@]+)@(.+)$ ]]; then
365
599
  pkg="${BASH_REMATCH[1]}"
@@ -370,19 +604,45 @@ guard_extract_specs() {
370
604
  fi
371
605
  printf '%s\t%s\n' "${pkg}" "${spec}"
372
606
  done
607
+ printf '%s\n' "${source}" | guard_extract_flagged_specs
373
608
  }
374
609
 
375
610
  LEDGER_ECOSYSTEM=$(guard_detect_ecosystem "${COMMAND}")
376
611
  LEDGER_SPECS=()
377
612
  while IFS= read -r ledger_spec_line; do
378
613
  [[ -z "${ledger_spec_line}" ]] && continue
614
+ if [[ ${#LEDGER_SPECS[@]} -gt 0 ]]; then
615
+ for existing_spec_line in "${LEDGER_SPECS[@]}"; do
616
+ [[ "${existing_spec_line}" == "${ledger_spec_line}" ]] && continue 2
617
+ done
618
+ fi
379
619
  LEDGER_SPECS+=("${ledger_spec_line}")
380
620
  done < <(guard_extract_specs "${COMMAND}")
381
621
 
382
- if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 && -f "${SAFEDEPS_LEDGER_LIB}" ]]; then
622
+ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 ]]; then
623
+ if [[ ! -f "${SAFEDEPS_LEDGER_LIB}" ]]; then
624
+ # The ledger library is the gate for direct install specs. If it is missing
625
+ # (broken install / moved repo) the gate cannot run — fail CLOSED, observably,
626
+ # instead of falling through to allow.
627
+ log_advisory "pre-guard DENY: ledger library missing (${SAFEDEPS_LEDGER_LIB}) — cannot enforce ${LEDGER_ECOSYSTEM} install, fail-closed."
628
+ jq -nc --arg eco "${LEDGER_ECOSYSTEM}" \
629
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("safedeps: the ledger library is missing, so the " + $eco + " install gate cannot run — install blocked fail-closed. Reinstall safedeps: node scripts/install/install-safedeps-hooks.mjs")}}'
630
+ exit 0
631
+ fi
383
632
  # shellcheck source=../lib/ledger/ledger.sh
384
633
  source "${SAFEDEPS_LEDGER_LIB}"
385
634
 
635
+ # Resolve a runnable `safedeps` invocation for the block message so the
636
+ # self-heal loop works whether or not the CLI is on PATH. Prefer the PATH
637
+ # command (clean UX); otherwise name the absolute repo bin (quoted via %q so
638
+ # it survives spaces in $HOME). Keeps the gate self-contained — the install
639
+ # of a `~/.local/bin/safedeps` symlink is a convenience, never a requirement.
640
+ if command -v safedeps >/dev/null 2>&1; then
641
+ SAFEDEPS_INVOKE="safedeps"
642
+ else
643
+ printf -v SAFEDEPS_INVOKE '%q' "${SAFEDEPS_REPO_BIN}"
644
+ fi
645
+
386
646
  GUARD_BLOCKED_CMDS=()
387
647
  for entry in "${LEDGER_SPECS[@]}"; do
388
648
  pkg="${entry%%$'\t'*}"
@@ -390,7 +650,7 @@ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 && -f "${SAFEDEPS_LE
390
650
  [[ -z "${pkg}" || -z "${spec}" ]] && continue
391
651
  if ! safedeps_ledger_check "${LEDGER_ECOSYSTEM}" "${pkg}" "${spec}" 2>/dev/null \
392
652
  | jq -e '.approved == true' >/dev/null 2>&1; then
393
- GUARD_BLOCKED_CMDS+=("safedeps check ${LEDGER_ECOSYSTEM} ${pkg}@${spec}")
653
+ GUARD_BLOCKED_CMDS+=("${SAFEDEPS_INVOKE} check ${LEDGER_ECOSYSTEM} ${pkg}@${spec}")
394
654
  fi
395
655
  done
396
656
 
@@ -423,5 +683,15 @@ CURRENT_STATE=$(jq -n --arg sid "${SNAPSHOT_ID}" --arg pdir "${PROJECT_DIR}" --a
423
683
  '{snapshot_id: $sid, project_dir: $pdir, dir_hash: $dhash}')
424
684
  write_state_file "${GUARD_DIR}/current_state" "${CURRENT_STATE}"
425
685
 
686
+ if ! jq -e 'has("turn_id")' <<< "${INPUT}" >/dev/null 2>&1 && \
687
+ command_is_injectable_npm_install "${COMMAND}" && \
688
+ ! command_has_ignore_scripts_flag "${COMMAND}"; then
689
+ UPDATED_COMMAND="${COMMAND} --ignore-scripts"
690
+ mark_ignore_scripts_injected
691
+ jq -nc --arg command "${UPDATED_COMMAND}" \
692
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",updatedInput:{command:$command}}}'
693
+ exit 0
694
+ fi
695
+
426
696
  # Allow the command to proceed — PostToolUse will verify the result
427
697
  exit 0