@aldegad/safedeps 2.1.0 → 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.
@@ -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="${cache_path}.$$"
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="${cache_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="${cache_path}.$$"
328
-
329
- if [[ -n "${GITHUB_TOKEN:-}" ]]; then
330
- http_status=$(curl -fsS \
331
- --max-time 15 \
332
- -H 'Accept: application/vnd.github+json' \
333
- -H 'X-GitHub-Api-Version: 2022-11-28' \
334
- -H "Authorization: Bearer ${GITHUB_TOKEN}" \
335
- -o "${response_file}" \
336
- -w '%{http_code}' \
337
- "${SAFEDEPS_GHSA_API_URL}?ecosystem=${encoded_ecosystem}&affects=${encoded_package}&per_page=100" 2>/dev/null || true)
338
- else
339
- http_status=$(curl -fsS \
340
- --max-time 15 \
341
- -H 'Accept: application/vnd.github+json' \
342
- -H 'X-GitHub-Api-Version: 2022-11-28' \
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 <ecosystem> <package> <version>\n' "$0" >&2
694
+ printf 'usage: %s {query|query-batch} ...\n' "$0" >&2
476
695
  exit 2
477
696
  ;;
478
697
  esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aldegad/safedeps",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Dependency install safety gate with OSV-backed advisory checks, approved-spec ledger enforcement, and reorg rollback hooks",
5
5
  "main": "bin/safedeps",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "scripts/",
13
13
  "agents/",
14
14
  "README.md",
15
+ "README.ko.md",
15
16
  "ARCHITECTURE.md",
16
17
  "ROADMAP.md",
17
18
  "SKILL.md",
@@ -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 PRE_HOOK = join(REPO_ROOT, "scripts", "safedeps-pre-guard.sh");
25
- const POST_HOOK = join(REPO_ROOT, "scripts", "safedeps-post-verify.sh");
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
- writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
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 pruneLegacySafedepsHooks(config, eventName) {
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 || bucket.matcher !== "Bash" || !Array.isArray(bucket.hooks)) continue;
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 (typeof command !== "string") return true;
119
- const legacySafedeps = command.includes("npm-reorg-guard") || command.includes("/safedeps/");
120
- const legacyHookName = command.endsWith("/scripts/guard.sh") || command.endsWith("/scripts/verify.sh");
121
- return !(legacySafedeps && legacyHookName);
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", PRE_HOOK);
141
- const post = removeHook(cfg, "PostToolUse", POST_HOOK);
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 = pruneLegacySafedepsHooks(cfg, "PreToolUse");
156
- const legacyPostRemoved = pruneLegacySafedepsHooks(cfg, "PostToolUse");
157
- const preAdded = ensureHook(cfg, "PreToolUse", PRE_HOOK);
158
- const postAdded = ensureHook(cfg, "PostToolUse", POST_HOOK);
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(PRE_HOOK) || !existsSync(POST_HOOK)) {
185
- throw new Error(`hook scripts not found at ${PRE_HOOK} / ${POST_HOOK}`);
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({