@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.
- package/ARCHITECTURE.md +268 -462
- package/README.ko.md +34 -12
- package/README.md +65 -38
- package/ROADMAP.md +82 -87
- package/SKILL.md +13 -7
- package/bin/safedeps +385 -52
- package/lib/gates/audit.sh +36 -0
- package/lib/gates/hooks.sh +93 -0
- package/lib/gates/repo-profile.sh +60 -0
- package/lib/gates/scan.sh +94 -0
- package/lib/ledger/ledger.sh +94 -16
- package/lib/npm/closure.sh +115 -0
- package/lib/providers/providers.sh +244 -25
- package/package.json +1 -1
- package/scripts/install/install-safedeps-hooks.mjs +62 -23
- package/scripts/release-gates.sh +252 -0
- package/scripts/safedeps-post-verify.sh +167 -10
- package/scripts/safedeps-pre-guard.sh +270 -32
- package/scripts/test/e2e.sh +180 -4
- package/scripts/test/fixture-provider.mjs +21 -0
- package/scripts/test/smoke.sh +135 -10
|
@@ -10,6 +10,7 @@ SAFEDEPS_ADVISORY_LOG="${SAFEDEPS_ADVISORY_LOG:-${SAFEDEPS_HOME}/advisory.log}"
|
|
|
10
10
|
SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS="${SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS:-86400}"
|
|
11
11
|
|
|
12
12
|
SAFEDEPS_OSV_API_URL="${SAFEDEPS_OSV_API_URL:-https://api.osv.dev/v1/query}"
|
|
13
|
+
SAFEDEPS_OSV_BATCH_API_URL="${SAFEDEPS_OSV_BATCH_API_URL:-https://api.osv.dev/v1/querybatch}"
|
|
13
14
|
SAFEDEPS_KEV_CATALOG_URL="${SAFEDEPS_KEV_CATALOG_URL:-https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json}"
|
|
14
15
|
SAFEDEPS_GHSA_API_URL="${SAFEDEPS_GHSA_API_URL:-https://api.github.com/advisories}"
|
|
15
16
|
|
|
@@ -51,6 +52,27 @@ safedeps_provider_mktemp_dir() {
|
|
|
51
52
|
mktemp -d "${tmp_root%/}/safedeps-providers.XXXXXX"
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
safedeps_cache_response_temp() {
|
|
56
|
+
local target_path="$1"
|
|
57
|
+
local target_dir
|
|
58
|
+
local target_base
|
|
59
|
+
|
|
60
|
+
target_dir=$(dirname "${target_path}")
|
|
61
|
+
target_base=$(basename "${target_path}")
|
|
62
|
+
mkdir -p "${target_dir}" || return 1
|
|
63
|
+
mktemp "${target_dir}/.${target_base}.XXXXXX"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
safedeps_url_host() {
|
|
67
|
+
local url="$1"
|
|
68
|
+
local host
|
|
69
|
+
|
|
70
|
+
host="${url#*://}"
|
|
71
|
+
host="${host%%/*}"
|
|
72
|
+
host="${host%%:*}"
|
|
73
|
+
printf '%s' "${host}"
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
safedeps_now_iso() {
|
|
55
77
|
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
|
56
78
|
}
|
|
@@ -167,7 +189,7 @@ safedeps_osv_query() {
|
|
|
167
189
|
--arg version "${version}" \
|
|
168
190
|
'{version: $version, package: {name: $package, ecosystem: $ecosystem}}')
|
|
169
191
|
|
|
170
|
-
response_file
|
|
192
|
+
response_file=$(safedeps_cache_response_temp "${cache_path}") || return 1
|
|
171
193
|
http_status=$(curl -fsS \
|
|
172
194
|
--max-time 15 \
|
|
173
195
|
-H 'Content-Type: application/json' \
|
|
@@ -177,7 +199,7 @@ safedeps_osv_query() {
|
|
|
177
199
|
"${SAFEDEPS_OSV_API_URL}" 2>/dev/null || true)
|
|
178
200
|
|
|
179
201
|
if [[ "${http_status}" == "200" ]] && jq -e 'type == "object"' "${response_file}" >/dev/null 2>&1; then
|
|
180
|
-
mv "${response_file}" "${cache_path}"
|
|
202
|
+
mv -f "${response_file}" "${cache_path}"
|
|
181
203
|
safedeps_provider_log "INFO" "OSV live query ok ecosystem=${osv_ecosystem} package=${package_name} version=${version}"
|
|
182
204
|
cat "${cache_path}"
|
|
183
205
|
return 0
|
|
@@ -192,6 +214,134 @@ safedeps_osv_query() {
|
|
|
192
214
|
return 1
|
|
193
215
|
}
|
|
194
216
|
|
|
217
|
+
safedeps_osv_query_batch() {
|
|
218
|
+
local ecosystem="$1"
|
|
219
|
+
local closure_file="$2"
|
|
220
|
+
local osv_ecosystem
|
|
221
|
+
local temp_dir
|
|
222
|
+
local all_items_file
|
|
223
|
+
local miss_items_file
|
|
224
|
+
local payload_file
|
|
225
|
+
local response_file
|
|
226
|
+
local results_file
|
|
227
|
+
local http_status
|
|
228
|
+
local index=0
|
|
229
|
+
local package_name
|
|
230
|
+
local version
|
|
231
|
+
local direct
|
|
232
|
+
|
|
233
|
+
safedeps_require_json_tools || return 1
|
|
234
|
+
safedeps_providers_init
|
|
235
|
+
[[ -f "${closure_file}" ]] || return 1
|
|
236
|
+
|
|
237
|
+
osv_ecosystem=$(safedeps_osv_ecosystem "${ecosystem}")
|
|
238
|
+
temp_dir=$(safedeps_provider_mktemp_dir) || return 1
|
|
239
|
+
all_items_file="${temp_dir}/items.jsonl"
|
|
240
|
+
miss_items_file="${temp_dir}/misses.jsonl"
|
|
241
|
+
payload_file="${temp_dir}/payload.json"
|
|
242
|
+
response_file="${temp_dir}/response.json"
|
|
243
|
+
results_file="${temp_dir}/results.json"
|
|
244
|
+
: > "${all_items_file}"
|
|
245
|
+
: > "${miss_items_file}"
|
|
246
|
+
|
|
247
|
+
while IFS=$'\t' read -r package_name version direct; do
|
|
248
|
+
[[ -n "${package_name}" && -n "${version}" ]] || continue
|
|
249
|
+
local cache_key
|
|
250
|
+
local cache_path
|
|
251
|
+
cache_key=$(safedeps_cache_key "osv" "${osv_ecosystem}" "${package_name}" "${version}")
|
|
252
|
+
cache_path="${SAFEDEPS_CACHE_DIR}/osv/${cache_key}.json"
|
|
253
|
+
|
|
254
|
+
if safedeps_cache_is_fresh "${cache_path}"; then
|
|
255
|
+
safedeps_provider_log "INFO" "OSV batch cache hit ecosystem=${osv_ecosystem} package=${package_name} version=${version}"
|
|
256
|
+
jq -cn \
|
|
257
|
+
--argjson index "${index}" \
|
|
258
|
+
--arg ecosystem "${ecosystem}" \
|
|
259
|
+
--arg package "${package_name}" \
|
|
260
|
+
--arg version "${version}" \
|
|
261
|
+
--argjson direct "${direct}" \
|
|
262
|
+
--slurpfile osv "${cache_path}" \
|
|
263
|
+
'{index:$index, ecosystem:$ecosystem, package:$package, version:$version, direct:$direct, osv:($osv[0] // {vulns:[]})}' >> "${all_items_file}"
|
|
264
|
+
else
|
|
265
|
+
jq -cn \
|
|
266
|
+
--argjson index "${index}" \
|
|
267
|
+
--arg ecosystem "${ecosystem}" \
|
|
268
|
+
--arg package "${package_name}" \
|
|
269
|
+
--arg version "${version}" \
|
|
270
|
+
--argjson direct "${direct}" \
|
|
271
|
+
--arg cache_path "${cache_path}" \
|
|
272
|
+
'{index:$index, ecosystem:$ecosystem, package:$package, version:$version, direct:$direct, cache_path:$cache_path}' >> "${miss_items_file}"
|
|
273
|
+
fi
|
|
274
|
+
index=$((index + 1))
|
|
275
|
+
done < <(jq -r '.[] | [.package, (.version | tostring), ((.direct // false) | tostring)] | @tsv' "${closure_file}")
|
|
276
|
+
|
|
277
|
+
if [[ -s "${miss_items_file}" ]]; then
|
|
278
|
+
safedeps_require_http_client || {
|
|
279
|
+
safedeps_provider_log "ERROR" "OSV batch unavailable; cache miss ecosystem=${osv_ecosystem}"
|
|
280
|
+
rm -rf "${temp_dir}"
|
|
281
|
+
return 1
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
jq -cn --arg ecosystem "${osv_ecosystem}" --slurpfile misses "${miss_items_file}" '
|
|
285
|
+
{
|
|
286
|
+
queries: [
|
|
287
|
+
$misses[]
|
|
288
|
+
| {version: .version, package: {name: .package, ecosystem: $ecosystem}}
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
' > "${payload_file}"
|
|
292
|
+
|
|
293
|
+
http_status=$(curl -fsS \
|
|
294
|
+
--max-time 20 \
|
|
295
|
+
-H 'Content-Type: application/json' \
|
|
296
|
+
-o "${response_file}" \
|
|
297
|
+
-w '%{http_code}' \
|
|
298
|
+
-d @"${payload_file}" \
|
|
299
|
+
"${SAFEDEPS_OSV_BATCH_API_URL}" 2>/dev/null || true)
|
|
300
|
+
|
|
301
|
+
if [[ "${http_status}" != "200" ]] || ! jq -e '.results | type == "array"' "${response_file}" >/dev/null 2>&1; then
|
|
302
|
+
safedeps_provider_log "ERROR" "OSV batch query failed status=${http_status:-none}"
|
|
303
|
+
rm -rf "${temp_dir}"
|
|
304
|
+
return 1
|
|
305
|
+
fi
|
|
306
|
+
|
|
307
|
+
local miss_count
|
|
308
|
+
local result_count
|
|
309
|
+
miss_count=$(jq -s 'length' "${miss_items_file}")
|
|
310
|
+
result_count=$(jq '.results | length' "${response_file}")
|
|
311
|
+
if [[ "${miss_count}" != "${result_count}" ]]; then
|
|
312
|
+
safedeps_provider_log "ERROR" "OSV batch result count mismatch misses=${miss_count} results=${result_count}"
|
|
313
|
+
rm -rf "${temp_dir}"
|
|
314
|
+
return 1
|
|
315
|
+
fi
|
|
316
|
+
|
|
317
|
+
local miss_i=0
|
|
318
|
+
while IFS= read -r miss_item; do
|
|
319
|
+
local cache_path
|
|
320
|
+
local response_item_file
|
|
321
|
+
cache_path=$(jq -r '.cache_path' <<< "${miss_item}")
|
|
322
|
+
response_item_file=$(safedeps_cache_response_temp "${cache_path}") || {
|
|
323
|
+
rm -rf "${temp_dir}"
|
|
324
|
+
return 1
|
|
325
|
+
}
|
|
326
|
+
jq -c --argjson i "${miss_i}" '.results[$i] // {vulns: []}' "${response_file}" > "${response_item_file}"
|
|
327
|
+
if ! jq -e 'type == "object"' "${response_item_file}" >/dev/null 2>&1; then
|
|
328
|
+
rm -f "${response_item_file}"
|
|
329
|
+
rm -rf "${temp_dir}"
|
|
330
|
+
return 1
|
|
331
|
+
fi
|
|
332
|
+
mv -f "${response_item_file}" "${cache_path}"
|
|
333
|
+
safedeps_provider_log "INFO" "OSV batch live query ok ecosystem=${osv_ecosystem} package=$(jq -r '.package' <<< "${miss_item}") version=$(jq -r '.version' <<< "${miss_item}")"
|
|
334
|
+
jq -cn --argjson miss "${miss_item}" --slurpfile osv "${cache_path}" \
|
|
335
|
+
'$miss | del(.cache_path) | . + {osv: ($osv[0] // {vulns: []})}' >> "${all_items_file}"
|
|
336
|
+
miss_i=$((miss_i + 1))
|
|
337
|
+
done < "${miss_items_file}"
|
|
338
|
+
fi
|
|
339
|
+
|
|
340
|
+
jq -s 'sort_by(.index)' "${all_items_file}" > "${results_file}"
|
|
341
|
+
cat "${results_file}"
|
|
342
|
+
rm -rf "${temp_dir}"
|
|
343
|
+
}
|
|
344
|
+
|
|
195
345
|
safedeps_extract_cves_from_osv() {
|
|
196
346
|
local osv_json="$1"
|
|
197
347
|
|
|
@@ -230,11 +380,11 @@ safedeps_kev_refresh_catalog() {
|
|
|
230
380
|
return 1
|
|
231
381
|
fi
|
|
232
382
|
|
|
233
|
-
response_path
|
|
383
|
+
response_path=$(safedeps_cache_response_temp "${cache_path}") || return 1
|
|
234
384
|
http_status=$(curl -fsS --max-time 15 -o "${response_path}" -w '%{http_code}' "${SAFEDEPS_KEV_CATALOG_URL}" 2>/dev/null || true)
|
|
235
385
|
|
|
236
386
|
if [[ "${http_status}" == "200" ]] && jq -e '.vulnerabilities | type == "array"' "${response_path}" >/dev/null 2>&1; then
|
|
237
|
-
mv "${response_path}" "${cache_path}"
|
|
387
|
+
mv -f "${response_path}" "${cache_path}"
|
|
238
388
|
safedeps_provider_log "INFO" "CISA KEV catalog refresh ok"
|
|
239
389
|
printf '%s' "${cache_path}"
|
|
240
390
|
return 0
|
|
@@ -301,6 +451,8 @@ safedeps_ghsa_query() {
|
|
|
301
451
|
local cache_path
|
|
302
452
|
local response_file
|
|
303
453
|
local http_status
|
|
454
|
+
local ghsa_host
|
|
455
|
+
local curl_args
|
|
304
456
|
|
|
305
457
|
safedeps_require_json_tools || return 1
|
|
306
458
|
safedeps_providers_init
|
|
@@ -324,29 +476,29 @@ safedeps_ghsa_query() {
|
|
|
324
476
|
|
|
325
477
|
encoded_ecosystem=$(safedeps_json_uri_escape "${ghsa_ecosystem}")
|
|
326
478
|
encoded_package=$(safedeps_json_uri_escape "${package_name}")
|
|
327
|
-
response_file
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
-o "${response_file}" \
|
|
344
|
-
-w '%{http_code}' \
|
|
345
|
-
"${SAFEDEPS_GHSA_API_URL}?ecosystem=${encoded_ecosystem}&affects=${encoded_package}&per_page=100" 2>/dev/null || true)
|
|
479
|
+
response_file=$(safedeps_cache_response_temp "${cache_path}") || return 1
|
|
480
|
+
ghsa_host=$(safedeps_url_host "${SAFEDEPS_GHSA_API_URL}")
|
|
481
|
+
|
|
482
|
+
curl_args=(
|
|
483
|
+
-fsS
|
|
484
|
+
--max-time 15
|
|
485
|
+
-H 'Accept: application/vnd.github+json'
|
|
486
|
+
-H 'X-GitHub-Api-Version: 2022-11-28'
|
|
487
|
+
-o "${response_file}"
|
|
488
|
+
-w '%{http_code}'
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if [[ -n "${GITHUB_TOKEN:-}" && "${ghsa_host}" == "api.github.com" ]]; then
|
|
492
|
+
curl_args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
|
|
493
|
+
elif [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
|
494
|
+
safedeps_provider_log "WARN" "GHSA token withheld for non-GitHub host host=${ghsa_host}"
|
|
346
495
|
fi
|
|
347
496
|
|
|
497
|
+
http_status=$(curl "${curl_args[@]}" \
|
|
498
|
+
"${SAFEDEPS_GHSA_API_URL}?ecosystem=${encoded_ecosystem}&affects=${encoded_package}&per_page=100" 2>/dev/null || true)
|
|
499
|
+
|
|
348
500
|
if [[ "${http_status}" == "200" ]] && jq -e 'type == "array"' "${response_file}" >/dev/null 2>&1; then
|
|
349
|
-
mv "${response_file}" "${cache_path}"
|
|
501
|
+
mv -f "${response_file}" "${cache_path}"
|
|
350
502
|
safedeps_provider_log "INFO" "GHSA live query ok ecosystem=${ghsa_ecosystem} package=${package_name}"
|
|
351
503
|
jq -cn --arg queried_at "${queried_at}" --slurpfile advisories "${cache_path}" \
|
|
352
504
|
'{queried_at: $queried_at, status: "live", advisories: $advisories[0]}'
|
|
@@ -459,6 +611,66 @@ safedeps_providers_query() {
|
|
|
459
611
|
rm -rf "${temp_dir}"
|
|
460
612
|
}
|
|
461
613
|
|
|
614
|
+
safedeps_providers_query_batch() {
|
|
615
|
+
local ecosystem="$1"
|
|
616
|
+
local closure_file="$2"
|
|
617
|
+
local queried_at
|
|
618
|
+
local temp_dir
|
|
619
|
+
local osv_batch_file
|
|
620
|
+
local results_file
|
|
621
|
+
|
|
622
|
+
safedeps_require_json_tools || return 1
|
|
623
|
+
queried_at=$(safedeps_now_iso)
|
|
624
|
+
temp_dir=$(safedeps_provider_mktemp_dir) || return 1
|
|
625
|
+
osv_batch_file="${temp_dir}/osv-batch.json"
|
|
626
|
+
results_file="${temp_dir}/results.jsonl"
|
|
627
|
+
: > "${results_file}"
|
|
628
|
+
|
|
629
|
+
if ! safedeps_osv_query_batch "${ecosystem}" "${closure_file}" > "${osv_batch_file}"; then
|
|
630
|
+
rm -rf "${temp_dir}"
|
|
631
|
+
return 1
|
|
632
|
+
fi
|
|
633
|
+
|
|
634
|
+
while IFS= read -r item; do
|
|
635
|
+
local osv_file
|
|
636
|
+
local kev_json
|
|
637
|
+
local status
|
|
638
|
+
osv_file="${temp_dir}/osv-item.json"
|
|
639
|
+
jq -c '.osv' <<< "${item}" > "${osv_file}"
|
|
640
|
+
kev_json=$(safedeps_kev_overlay "${osv_file}" "${queried_at}")
|
|
641
|
+
status=$(jq -r --argjson kev "${kev_json}" '
|
|
642
|
+
if $kev.exploited then "hard_block"
|
|
643
|
+
elif ((.osv.vulns // []) | length) > 0 then "vulnerable"
|
|
644
|
+
else "clean"
|
|
645
|
+
end
|
|
646
|
+
' <<< "${item}")
|
|
647
|
+
jq -cn \
|
|
648
|
+
--argjson item "${item}" \
|
|
649
|
+
--arg queried_at "${queried_at}" \
|
|
650
|
+
--arg status "${status}" \
|
|
651
|
+
--argjson kev "${kev_json}" \
|
|
652
|
+
'{
|
|
653
|
+
index: $item.index,
|
|
654
|
+
ecosystem: $item.ecosystem,
|
|
655
|
+
package: $item.package,
|
|
656
|
+
version: $item.version,
|
|
657
|
+
direct: ($item.direct // false),
|
|
658
|
+
queried_at: $queried_at,
|
|
659
|
+
status: $status,
|
|
660
|
+
vulnerabilities: ($item.osv.vulns // []),
|
|
661
|
+
kev: $kev,
|
|
662
|
+
provider_status: {
|
|
663
|
+
osv: {status: "ok", canonical: true, batch: true},
|
|
664
|
+
kev: {status: ($kev.status // "ok"), overlay: true},
|
|
665
|
+
ghsa: {status: "skipped", enrichment: true, reason: "closure batch omits GHSA enrichment"}
|
|
666
|
+
}
|
|
667
|
+
}' >> "${results_file}"
|
|
668
|
+
done < <(jq -c '.[]' "${osv_batch_file}")
|
|
669
|
+
|
|
670
|
+
jq -s 'sort_by(.index)' "${results_file}"
|
|
671
|
+
rm -rf "${temp_dir}"
|
|
672
|
+
}
|
|
673
|
+
|
|
462
674
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
|
463
675
|
command_name="${1:-}"
|
|
464
676
|
shift || true
|
|
@@ -471,8 +683,15 @@ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
|
|
471
683
|
fi
|
|
472
684
|
safedeps_providers_query "$@"
|
|
473
685
|
;;
|
|
686
|
+
query-batch)
|
|
687
|
+
if [[ "$#" -ne 2 ]]; then
|
|
688
|
+
printf 'usage: %s query-batch <ecosystem> <closure-json-file>\n' "$0" >&2
|
|
689
|
+
exit 2
|
|
690
|
+
fi
|
|
691
|
+
safedeps_providers_query_batch "$@"
|
|
692
|
+
;;
|
|
474
693
|
*)
|
|
475
|
-
printf 'usage: %s query
|
|
694
|
+
printf 'usage: %s {query|query-batch} ...\n' "$0" >&2
|
|
476
695
|
exit 2
|
|
477
696
|
;;
|
|
478
697
|
esac
|
package/package.json
CHANGED
|
@@ -11,18 +11,20 @@
|
|
|
11
11
|
// node scripts/install/install-safedeps-hooks.mjs --uninstall
|
|
12
12
|
// node scripts/install/install-safedeps-hooks.mjs --link-bin (optional ~/.local/bin/safedeps)
|
|
13
13
|
|
|
14
|
-
import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, symlinkSync, unlinkSync, readlinkSync } from "node:fs";
|
|
14
|
+
import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, symlinkSync, unlinkSync, readlinkSync, renameSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
|
|
19
19
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
20
|
const REPO_ROOT = resolve(HERE, "..", "..");
|
|
21
|
-
const HOME = homedir();
|
|
21
|
+
const HOME = process.env.HOME || homedir();
|
|
22
22
|
|
|
23
23
|
const SKILL_ID = "safedeps";
|
|
24
|
-
const
|
|
25
|
-
const
|
|
24
|
+
const PRE_HOOK_NAME = "safedeps-pre-guard.sh";
|
|
25
|
+
const POST_HOOK_NAME = "safedeps-post-verify.sh";
|
|
26
|
+
const REPO_PRE_HOOK = join(REPO_ROOT, "scripts", PRE_HOOK_NAME);
|
|
27
|
+
const REPO_POST_HOOK = join(REPO_ROOT, "scripts", POST_HOOK_NAME);
|
|
26
28
|
const CLI_BIN = join(REPO_ROOT, "bin", "safedeps");
|
|
27
29
|
|
|
28
30
|
const args = new Set(process.argv.slice(2));
|
|
@@ -71,7 +73,14 @@ function writeJsonWithBackup(path, value) {
|
|
|
71
73
|
} else {
|
|
72
74
|
mkdirSync(dirname(path), { recursive: true });
|
|
73
75
|
}
|
|
74
|
-
|
|
76
|
+
const tmpPath = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
77
|
+
writeFileSync(tmpPath, JSON.stringify(value, null, 2) + "\n");
|
|
78
|
+
renameSync(tmpPath, path);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function engineHookCommand(engineRoot, hookName) {
|
|
82
|
+
const engineName = basename(engineRoot).replace(/^\./u, "");
|
|
83
|
+
return `~/.${engineName}/skills/${SKILL_ID}/scripts/${hookName}`;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
function ensureHook(config, eventName, command) {
|
|
@@ -79,6 +88,13 @@ function ensureHook(config, eventName, command) {
|
|
|
79
88
|
config.hooks[eventName] = config.hooks[eventName] ?? [];
|
|
80
89
|
const buckets = config.hooks[eventName];
|
|
81
90
|
|
|
91
|
+
const already = buckets.some((bucket) =>
|
|
92
|
+
bucket?.matcher === "Bash" &&
|
|
93
|
+
Array.isArray(bucket?.hooks) &&
|
|
94
|
+
bucket.hooks.some((h) => h && h.type === "command" && h.command === command),
|
|
95
|
+
);
|
|
96
|
+
if (already) return false;
|
|
97
|
+
|
|
82
98
|
let bashBucket = buckets.find((b) => b && b.matcher === "Bash");
|
|
83
99
|
if (!bashBucket) {
|
|
84
100
|
bashBucket = { matcher: "Bash", hooks: [] };
|
|
@@ -86,9 +102,6 @@ function ensureHook(config, eventName, command) {
|
|
|
86
102
|
}
|
|
87
103
|
bashBucket.hooks = bashBucket.hooks ?? [];
|
|
88
104
|
|
|
89
|
-
const already = bashBucket.hooks.some((h) => h && h.type === "command" && h.command === command);
|
|
90
|
-
if (already) return false;
|
|
91
|
-
|
|
92
105
|
bashBucket.hooks.push({ type: "command", command });
|
|
93
106
|
return true;
|
|
94
107
|
}
|
|
@@ -106,25 +119,49 @@ function removeHook(config, eventName, command) {
|
|
|
106
119
|
return changed;
|
|
107
120
|
}
|
|
108
121
|
|
|
109
|
-
function
|
|
122
|
+
function isSafedepsHookCommand(command, hookName) {
|
|
123
|
+
if (typeof command !== "string") return false;
|
|
124
|
+
const normalized = command.replace(/\\/gu, "/");
|
|
125
|
+
if (normalized.includes("npm-reorg-guard")) return true;
|
|
126
|
+
return normalized.includes("/safedeps/") && normalized.endsWith(`/scripts/${hookName}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function pruneNonCanonicalSafedepsHooks(config, eventName, canonicalCommand, hookName) {
|
|
110
130
|
const buckets = config?.hooks?.[eventName];
|
|
111
131
|
if (!Array.isArray(buckets)) return false;
|
|
112
132
|
let changed = false;
|
|
133
|
+
let seenCanonical = false;
|
|
113
134
|
for (const bucket of buckets) {
|
|
114
|
-
if (!bucket ||
|
|
135
|
+
if (!bucket || !Array.isArray(bucket.hooks)) continue;
|
|
115
136
|
const before = bucket.hooks.length;
|
|
116
137
|
bucket.hooks = bucket.hooks.filter((h) => {
|
|
117
138
|
const command = h?.command;
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
139
|
+
if (command === canonicalCommand) {
|
|
140
|
+
if (bucket.matcher !== "Bash") return false;
|
|
141
|
+
if (seenCanonical) return false;
|
|
142
|
+
seenCanonical = true;
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return command === canonicalCommand || !isSafedepsHookCommand(command, hookName);
|
|
122
146
|
});
|
|
123
147
|
if (bucket.hooks.length !== before) changed = true;
|
|
124
148
|
}
|
|
125
149
|
return changed;
|
|
126
150
|
}
|
|
127
151
|
|
|
152
|
+
function pruneAllSafedepsHooks(config, eventName, hookName) {
|
|
153
|
+
const buckets = config?.hooks?.[eventName];
|
|
154
|
+
if (!Array.isArray(buckets)) return false;
|
|
155
|
+
let changed = false;
|
|
156
|
+
for (const bucket of buckets) {
|
|
157
|
+
if (!bucket || !Array.isArray(bucket.hooks)) continue;
|
|
158
|
+
const before = bucket.hooks.length;
|
|
159
|
+
bucket.hooks = bucket.hooks.filter((h) => !isSafedepsHookCommand(h?.command, hookName));
|
|
160
|
+
if (bucket.hooks.length !== before) changed = true;
|
|
161
|
+
}
|
|
162
|
+
return changed;
|
|
163
|
+
}
|
|
164
|
+
|
|
128
165
|
function installInEngine({ engineRoot, configPath, label }) {
|
|
129
166
|
if (!existsSync(engineRoot)) {
|
|
130
167
|
warn(`skip ${label} (${engineRoot} not present)`);
|
|
@@ -132,13 +169,15 @@ function installInEngine({ engineRoot, configPath, label }) {
|
|
|
132
169
|
}
|
|
133
170
|
const skillsRoot = join(engineRoot, "skills");
|
|
134
171
|
const skillLink = join(skillsRoot, SKILL_ID);
|
|
172
|
+
const preCommand = engineHookCommand(engineRoot, PRE_HOOK_NAME);
|
|
173
|
+
const postCommand = engineHookCommand(engineRoot, POST_HOOK_NAME);
|
|
135
174
|
|
|
136
175
|
if (UNINSTALL) {
|
|
137
176
|
removeSymlink(skillLink);
|
|
138
177
|
if (existsSync(configPath)) {
|
|
139
178
|
const cfg = readJson(configPath);
|
|
140
|
-
const pre = removeHook(cfg, "PreToolUse",
|
|
141
|
-
const post = removeHook(cfg, "PostToolUse",
|
|
179
|
+
const pre = removeHook(cfg, "PreToolUse", preCommand) || pruneAllSafedepsHooks(cfg, "PreToolUse", PRE_HOOK_NAME);
|
|
180
|
+
const post = removeHook(cfg, "PostToolUse", postCommand) || pruneAllSafedepsHooks(cfg, "PostToolUse", POST_HOOK_NAME);
|
|
142
181
|
if (pre || post) {
|
|
143
182
|
writeJsonWithBackup(configPath, cfg);
|
|
144
183
|
log(`patched ${configPath} (removed safedeps hooks)`);
|
|
@@ -152,10 +191,10 @@ function installInEngine({ engineRoot, configPath, label }) {
|
|
|
152
191
|
ensureSymlink(REPO_ROOT, skillLink);
|
|
153
192
|
|
|
154
193
|
const cfg = readJson(configPath);
|
|
155
|
-
const legacyPreRemoved =
|
|
156
|
-
const legacyPostRemoved =
|
|
157
|
-
const preAdded = ensureHook(cfg, "PreToolUse",
|
|
158
|
-
const postAdded = ensureHook(cfg, "PostToolUse",
|
|
194
|
+
const legacyPreRemoved = pruneNonCanonicalSafedepsHooks(cfg, "PreToolUse", preCommand, PRE_HOOK_NAME);
|
|
195
|
+
const legacyPostRemoved = pruneNonCanonicalSafedepsHooks(cfg, "PostToolUse", postCommand, POST_HOOK_NAME);
|
|
196
|
+
const preAdded = ensureHook(cfg, "PreToolUse", preCommand);
|
|
197
|
+
const postAdded = ensureHook(cfg, "PostToolUse", postCommand);
|
|
159
198
|
if (legacyPreRemoved || legacyPostRemoved || preAdded || postAdded) {
|
|
160
199
|
writeJsonWithBackup(configPath, cfg);
|
|
161
200
|
log(`patched ${configPath} (pre=${preAdded ? "added" : "ok"}, post=${postAdded ? "added" : "ok"}, legacy=${legacyPreRemoved || legacyPostRemoved ? "removed" : "ok"})`);
|
|
@@ -181,8 +220,8 @@ function unlinkBin() {
|
|
|
181
220
|
}
|
|
182
221
|
|
|
183
222
|
function main() {
|
|
184
|
-
if (!existsSync(
|
|
185
|
-
throw new Error(`hook scripts not found at ${
|
|
223
|
+
if (!existsSync(REPO_PRE_HOOK) || !existsSync(REPO_POST_HOOK)) {
|
|
224
|
+
throw new Error(`hook scripts not found at ${REPO_PRE_HOOK} / ${REPO_POST_HOOK}`);
|
|
186
225
|
}
|
|
187
226
|
|
|
188
227
|
installInEngine({
|