@aldegad/safedeps 2.1.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.
@@ -0,0 +1,584 @@
1
+ #!/usr/bin/env bash
2
+ # safedeps: PostToolUse hook
3
+ # Verifies dependency file changes after install commands and performs reorg (rollback) if suspicious
4
+
5
+ set -euo pipefail
6
+
7
+ GUARD_DIR="${SAFEDEPS_HOME:-${HOME}/.safedeps}"
8
+ SNAPSHOT_DIR="${GUARD_DIR}/snapshots"
9
+ STATE_LOCK_DIR="${GUARD_DIR}/state.lock"
10
+
11
+ SAFEDEPS_LOCK_FILES=(
12
+ "package-lock.json"
13
+ "pnpm-lock.yaml"
14
+ "yarn.lock"
15
+ "poetry.lock"
16
+ "uv.lock"
17
+ "Pipfile.lock"
18
+ "requirements.txt"
19
+ "Cargo.lock"
20
+ "go.sum"
21
+ "Gemfile.lock"
22
+ "packages.lock.json"
23
+ )
24
+
25
+ SAFEDEPS_MANIFEST_FILES=(
26
+ "package.json"
27
+ "pyproject.toml"
28
+ "Pipfile"
29
+ "Cargo.toml"
30
+ "go.mod"
31
+ "Gemfile"
32
+ "pom.xml"
33
+ )
34
+
35
+ umask 077
36
+ mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
37
+
38
+ if ! command -v jq >/dev/null 2>&1; then
39
+ echo "safedeps: jq is not installed; skipping verify hook." >&2
40
+ exit 0
41
+ fi
42
+
43
+ acquire_state_lock() {
44
+ local attempts=0
45
+
46
+ while ! mkdir "${STATE_LOCK_DIR}" 2>/dev/null; do
47
+ # Detect stale locks left by SIGKILL/OOM (V-005)
48
+ if [[ -d "${STATE_LOCK_DIR}" ]]; then
49
+ local lock_mtime=""
50
+ if lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null) || \
51
+ lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null); then
52
+ local now
53
+ now=$(date +%s)
54
+ if [[ $(( now - lock_mtime )) -gt 60 ]]; then
55
+ echo "safedeps: removing stale lock ($(( now - lock_mtime ))s old)." >&2
56
+ rmdir "${STATE_LOCK_DIR}" 2>/dev/null || true
57
+ continue
58
+ fi
59
+ fi
60
+ fi
61
+
62
+ attempts=$((attempts + 1))
63
+ if [[ ${attempts} -ge 100 ]]; then
64
+ echo "safedeps: could not acquire state lock; skipping verify hook." >&2
65
+ exit 0
66
+ fi
67
+ sleep 0.1
68
+ done
69
+ }
70
+
71
+ release_state_lock() {
72
+ rmdir "${STATE_LOCK_DIR}" 2>/dev/null || true
73
+ }
74
+
75
+ write_state_file() {
76
+ local target_path="$1"
77
+ local value="$2"
78
+ local temp_path="${target_path}.$$"
79
+
80
+ printf '%s\n' "${value}" > "${temp_path}"
81
+ mv "${temp_path}" "${target_path}"
82
+ }
83
+
84
+ compute_dir_hash() {
85
+ local input_dir="$1"
86
+
87
+ if command -v md5sum >/dev/null 2>&1; then
88
+ printf '%s' "${input_dir}" | md5sum | cut -d' ' -f1
89
+ elif command -v md5 >/dev/null 2>&1; then
90
+ md5 -q -s "${input_dir}"
91
+ else
92
+ printf '%s' "${input_dir}" | cksum | cut -d' ' -f1
93
+ fi
94
+ }
95
+
96
+ hash_file() {
97
+ local file_path="$1"
98
+
99
+ if command -v shasum >/dev/null 2>&1; then
100
+ shasum -a 256 "${file_path}" | cut -d' ' -f1
101
+ elif command -v sha256sum >/dev/null 2>&1; then
102
+ sha256sum "${file_path}" | cut -d' ' -f1
103
+ else
104
+ echo ""
105
+ fi
106
+ }
107
+
108
+ files_differ() {
109
+ local left_path="$1"
110
+ local right_path="$2"
111
+ local left_hash
112
+ local right_hash
113
+
114
+ if [[ ! -f "${left_path}" ]] && [[ ! -f "${right_path}" ]]; then
115
+ return 1
116
+ fi
117
+
118
+ if [[ ! -f "${left_path}" ]] || [[ ! -f "${right_path}" ]]; then
119
+ return 0
120
+ fi
121
+
122
+ if command -v cmp >/dev/null 2>&1; then
123
+ ! cmp -s "${left_path}" "${right_path}"
124
+ return
125
+ fi
126
+
127
+ left_hash=$(hash_file "${left_path}")
128
+ right_hash=$(hash_file "${right_path}")
129
+
130
+ if [[ -n "${left_hash}" ]] && [[ -n "${right_hash}" ]]; then
131
+ [[ "${left_hash}" != "${right_hash}" ]]
132
+ return
133
+ fi
134
+
135
+ ! diff -q "${left_path}" "${right_path}" >/dev/null 2>&1
136
+ }
137
+
138
+ monitored_files() {
139
+ local monitored_list="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_monitored_files.list"
140
+ local file_name
141
+
142
+ if [[ -f "${monitored_list}" ]]; then
143
+ sort -u "${monitored_list}"
144
+ return
145
+ fi
146
+
147
+ for file_name in "${SAFEDEPS_LOCK_FILES[@]}" "${SAFEDEPS_MANIFEST_FILES[@]}"; do
148
+ printf '%s\n' "${file_name}"
149
+ done
150
+ }
151
+
152
+ restore_monitored_file() {
153
+ local file_name="$1"
154
+ local rollback_snapshot_id="$2"
155
+ local snapshot_file="${SNAPSHOT_DIR}/${rollback_snapshot_id}_${file_name}"
156
+ local missing_marker="${SNAPSHOT_DIR}/${rollback_snapshot_id}_${file_name}.missing"
157
+ local current_missing_marker="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_${file_name}.missing"
158
+ local current_file="${PROJECT_DIR}/${file_name}"
159
+
160
+ if [[ -f "${snapshot_file}" ]]; then
161
+ if files_differ "${snapshot_file}" "${current_file}"; then
162
+ cp "${snapshot_file}" "${current_file}"
163
+ ROLLED_BACK+=("${file_name}")
164
+ fi
165
+ return
166
+ fi
167
+
168
+ if { [[ -f "${missing_marker}" ]] || [[ -f "${current_missing_marker}" ]]; } && [[ -f "${current_file}" ]]; then
169
+ rm -f "${current_file}"
170
+ ROLLED_BACK+=("${file_name}")
171
+ fi
172
+ }
173
+
174
+ read_confirmed_snapshot() {
175
+ local confirmed_snapshot=""
176
+ local dir_hash="${1:-}"
177
+
178
+ acquire_state_lock
179
+ # Project-scoped confirmed file
180
+ if [[ -n "${dir_hash}" ]] && [[ -f "${GUARD_DIR}/confirmed_${dir_hash}" ]]; then
181
+ confirmed_snapshot=$(cat "${GUARD_DIR}/confirmed_${dir_hash}" 2>/dev/null || true)
182
+ elif [[ -f "${GUARD_DIR}/confirmed" ]]; then
183
+ # Legacy fallback
184
+ confirmed_snapshot=$(cat "${GUARD_DIR}/confirmed" 2>/dev/null || true)
185
+ fi
186
+ release_state_lock; STATE_LOCK_HELD=false
187
+
188
+ printf '%s' "${confirmed_snapshot}"
189
+ }
190
+
191
+ confirm_snapshot() {
192
+ local snapshot_id="$1"
193
+ local dir_hash="${2:-}"
194
+
195
+ acquire_state_lock; STATE_LOCK_HELD=true
196
+ if [[ -n "${dir_hash}" ]]; then
197
+ write_state_file "${GUARD_DIR}/confirmed_${dir_hash}" "${snapshot_id}"
198
+ else
199
+ write_state_file "${GUARD_DIR}/confirmed" "${snapshot_id}"
200
+ fi
201
+ release_state_lock; STATE_LOCK_HELD=false
202
+ }
203
+
204
+ collect_protected_snapshot_ids() {
205
+ local dir_hash="${1:-}"
206
+ local snapshot_id
207
+ local parent_snapshot_id
208
+ local meta_file
209
+ local seen=()
210
+
211
+ snapshot_id=$(read_confirmed_snapshot "${dir_hash}")
212
+
213
+ while [[ -n "${snapshot_id}" ]]; do
214
+ local already_seen="false"
215
+ local seen_id
216
+
217
+ for seen_id in "${seen[@]}"; do
218
+ if [[ "${seen_id}" == "${snapshot_id}" ]]; then
219
+ already_seen="true"
220
+ break
221
+ fi
222
+ done
223
+
224
+ if [[ "${already_seen}" == "true" ]]; then
225
+ break
226
+ fi
227
+
228
+ seen+=("${snapshot_id}")
229
+ printf '%s\n' "${snapshot_id}"
230
+
231
+ meta_file="${SNAPSHOT_DIR}/${snapshot_id}_meta.json"
232
+ if [[ ! -f "${meta_file}" ]]; then
233
+ break
234
+ fi
235
+
236
+ parent_snapshot_id=$(jq -r '.parent_snapshot_id // empty' "${meta_file}" 2>/dev/null || true)
237
+ snapshot_id="${parent_snapshot_id}"
238
+ done
239
+ }
240
+
241
+ snapshot_is_protected() {
242
+ local target_snapshot_id="$1"
243
+ shift
244
+
245
+ local protected_snapshot_id
246
+ for protected_snapshot_id in "$@"; do
247
+ if [[ "${protected_snapshot_id}" == "${target_snapshot_id}" ]]; then
248
+ return 0
249
+ fi
250
+ done
251
+
252
+ return 1
253
+ }
254
+
255
+ cleanup_old_snapshots() {
256
+ local protected_snapshot_ids=()
257
+ local protected_snapshot_id
258
+ local old_meta
259
+ local old_id
260
+ local removable_seen=0
261
+
262
+ while IFS= read -r protected_snapshot_id; do
263
+ if [[ -n "${protected_snapshot_id}" ]]; then
264
+ protected_snapshot_ids+=("${protected_snapshot_id}")
265
+ fi
266
+ done < <(collect_protected_snapshot_ids "${DIR_HASH:-}")
267
+
268
+ while IFS= read -r old_meta; do
269
+ old_id=$(jq -r '.snapshot_id // empty' "${old_meta}" 2>/dev/null || true)
270
+
271
+ if [[ -z "${old_id}" ]]; then
272
+ continue
273
+ fi
274
+
275
+ if [[ ${#protected_snapshot_ids[@]} -gt 0 ]] && snapshot_is_protected "${old_id}" "${protected_snapshot_ids[@]}"; then
276
+ continue
277
+ fi
278
+
279
+ removable_seen=$((removable_seen + 1))
280
+ if [[ ${removable_seen} -le 10 ]]; then
281
+ continue
282
+ fi
283
+
284
+ rm -f "${SNAPSHOT_DIR}/${old_id}"_*
285
+ done < <(ls -t "${SNAPSHOT_DIR}"/*_meta.json 2>/dev/null || true)
286
+ }
287
+
288
+ restore_node_modules() {
289
+ if ! command -v npm >/dev/null 2>&1; then
290
+ ROLLBACK_WARNINGS+=("npm is not installed; node_modules was not reinstalled")
291
+ return
292
+ fi
293
+
294
+ if [[ -f "${PROJECT_DIR}/package-lock.json" ]]; then
295
+ if (cd "${PROJECT_DIR}" && npm ci >/dev/null 2>&1); then
296
+ return
297
+ fi
298
+ ROLLBACK_WARNINGS+=("npm ci failed during rollback; retrying with npm install")
299
+ fi
300
+
301
+ if (cd "${PROJECT_DIR}" && rm -rf node_modules && npm install >/dev/null 2>&1); then
302
+ return
303
+ fi
304
+
305
+ ROLLBACK_WARNINGS+=("node_modules reinstall failed; review the project manually")
306
+ }
307
+
308
+ # Read tool input from stdin
309
+ INPUT=$(cat)
310
+
311
+ # Only process Bash tool results
312
+ TOOL_NAME=$(echo "${INPUT}" | jq -r '.tool_name // empty' 2>/dev/null)
313
+ if [[ "${TOOL_NAME}" != "Bash" ]]; then
314
+ exit 0
315
+ fi
316
+
317
+ STATE_LOCK_HELD=true
318
+ acquire_state_lock
319
+ trap '[ "${STATE_LOCK_HELD:-}" = "true" ] && release_state_lock; STATE_LOCK_HELD=false' EXIT
320
+
321
+ # Check if we have a pending snapshot to verify (V-004: atomic state file)
322
+ if [[ ! -f "${GUARD_DIR}/current_state" ]]; then
323
+ # Legacy fallback for in-flight upgrades
324
+ if [[ ! -f "${GUARD_DIR}/current_snapshot_id" ]]; then
325
+ exit 0
326
+ fi
327
+ SNAPSHOT_ID=$(cat "${GUARD_DIR}/current_snapshot_id")
328
+ PROJECT_DIR=$(cat "${GUARD_DIR}/current_project_dir" 2>/dev/null || pwd)
329
+ rm -f "${GUARD_DIR}/current_snapshot_id" "${GUARD_DIR}/current_project_dir"
330
+ else
331
+ CURRENT_STATE=$(cat "${GUARD_DIR}/current_state")
332
+ SNAPSHOT_ID=$(echo "${CURRENT_STATE}" | jq -r '.snapshot_id // empty')
333
+ PROJECT_DIR=$(echo "${CURRENT_STATE}" | jq -r '.project_dir // empty')
334
+ DIR_HASH=$(echo "${CURRENT_STATE}" | jq -r '.dir_hash // empty')
335
+ rm -f "${GUARD_DIR}/current_state"
336
+ fi
337
+
338
+ if [[ -z "${SNAPSHOT_ID}" ]]; then
339
+ exit 0
340
+ fi
341
+ if [[ -z "${PROJECT_DIR}" ]]; then
342
+ PROJECT_DIR=$(pwd)
343
+ fi
344
+ if [[ -z "${DIR_HASH:-}" ]]; then
345
+ DIR_HASH=$(compute_dir_hash "${PROJECT_DIR}")
346
+ fi
347
+ release_state_lock; STATE_LOCK_HELD=false
348
+
349
+ # Verify snapshot exists
350
+ META_FILE="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_meta.json"
351
+ if [[ ! -f "${META_FILE}" ]]; then
352
+ exit 0
353
+ fi
354
+
355
+ # --- Begin Reorg Verification ---
356
+
357
+ SUSPICIOUS=false
358
+ REASONS=()
359
+ ROLLBACK_WARNINGS=()
360
+
361
+ # Function: check for suspicious postinstall scripts in new/changed dependencies
362
+ check_postinstall_scripts() {
363
+ local pkg_json="${PROJECT_DIR}/package.json"
364
+ local changed_lock=false
365
+ local lock_file
366
+
367
+ if [[ ! -f "${pkg_json}" ]]; then
368
+ return
369
+ fi
370
+
371
+ for lock_file in "${SAFEDEPS_LOCK_FILES[@]}"; do
372
+ if files_differ "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_${lock_file}" "${PROJECT_DIR}/${lock_file}"; then
373
+ changed_lock=true
374
+ break
375
+ fi
376
+ done
377
+
378
+ if [[ "${changed_lock}" != "true" ]] && ! files_differ "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_package.json" "${pkg_json}"; then
379
+ return
380
+ fi
381
+
382
+ # Check node_modules for new packages with install scripts
383
+ if [[ -d "${PROJECT_DIR}/node_modules" ]]; then
384
+ # Find packages with postinstall/preinstall scripts
385
+ local script_packages
386
+ local old_pkg_listing="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_packages.list"
387
+ if [[ -f "${old_pkg_listing}" ]]; then
388
+ script_packages=$(find "${PROJECT_DIR}/node_modules" -maxdepth 3 -name "package.json" 2>/dev/null | sort | comm -13 "${old_pkg_listing}" - | head -50)
389
+ else
390
+ script_packages=$(find "${PROJECT_DIR}/node_modules" -maxdepth 3 -name "package.json" 2>/dev/null | head -50)
391
+ fi
392
+
393
+ while IFS= read -r pkg; do
394
+ [[ -z "${pkg}" ]] && continue
395
+ # Check for suspicious install hooks
396
+ local has_preinstall
397
+ local has_postinstall
398
+ local has_install
399
+ local pkg_name
400
+
401
+ has_preinstall=$(jq -r '.scripts.preinstall // empty' "${pkg}" 2>/dev/null)
402
+ has_postinstall=$(jq -r '.scripts.postinstall // empty' "${pkg}" 2>/dev/null)
403
+ has_install=$(jq -r '.scripts.install // empty' "${pkg}" 2>/dev/null)
404
+ pkg_name=$(jq -r '.name // "unknown"' "${pkg}" 2>/dev/null)
405
+
406
+ for script_content in "${has_preinstall}" "${has_postinstall}" "${has_install}"; do
407
+ if [[ -z "${script_content}" ]]; then
408
+ continue
409
+ fi
410
+
411
+ # Check for network calls in install scripts
412
+ if echo "${script_content}" | grep -qEi '(curl|wget|fetch|http|https|net\.|socket|dns)'; then
413
+ SUSPICIOUS=true
414
+ REASONS+=("Package '${pkg_name}' has install script with network access: ${script_content}")
415
+ fi
416
+
417
+ # Check for eval/exec in install scripts
418
+ if echo "${script_content}" | grep -qEi '(eval|exec|spawn|child_process|Function\()'; then
419
+ SUSPICIOUS=true
420
+ REASONS+=("Package '${pkg_name}' has install script with code execution: ${script_content}")
421
+ fi
422
+
423
+ # Check for filesystem access outside project
424
+ if echo "${script_content}" | grep -qEi '(\/etc\/|\/home\/|~\/|\$HOME|\.ssh|\.env|\.aws|credentials)'; then
425
+ SUSPICIOUS=true
426
+ REASONS+=("Package '${pkg_name}' has install script accessing sensitive paths")
427
+ fi
428
+
429
+ # Check for encoded/obfuscated content
430
+ if echo "${script_content}" | grep -qEi '(base64|atob|Buffer\.from|\\x[0-9a-f]{2}|\\u[0-9a-f]{4})'; then
431
+ SUSPICIOUS=true
432
+ REASONS+=("Package '${pkg_name}' has install script with obfuscated content")
433
+ fi
434
+ done
435
+ done <<< "${script_packages}"
436
+ fi
437
+ }
438
+
439
+ # Function: check lock file diff for suspicious changes
440
+ check_lockfile_diff() {
441
+ local lock_file
442
+
443
+ for lock_file in "${SAFEDEPS_LOCK_FILES[@]}"; do
444
+ local current="${PROJECT_DIR}/${lock_file}"
445
+ local snapshot="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_${lock_file}"
446
+
447
+ if [[ ! -f "${current}" ]] || [[ ! -f "${snapshot}" ]]; then
448
+ continue
449
+ fi
450
+
451
+ # Compare content directly so mtime manipulation cannot bypass verification.
452
+ if ! files_differ "${snapshot}" "${current}"; then
453
+ continue
454
+ fi
455
+
456
+ # Lock file changed — analyze the diff
457
+ if [[ "${lock_file}" == "package-lock.json" ]]; then
458
+ local suspicious_urls
459
+ local insecure_urls
460
+ local new_deps
461
+
462
+ # Check for resolved URLs pointing to non-standard registries
463
+ suspicious_urls=$(diff "${snapshot}" "${current}" 2>/dev/null | grep '^>' | grep '"resolved"' | grep -viE 'registry\.npmjs\.org|registry\.yarnpkg\.com' | head -5 || true)
464
+ if [[ -n "${suspicious_urls}" ]]; then
465
+ SUSPICIOUS=true
466
+ REASONS+=("Lock file contains resolved URLs from non-standard registries")
467
+ fi
468
+
469
+ # Check for git:// or http:// (non-https) resolved URLs
470
+ insecure_urls=$(diff "${snapshot}" "${current}" 2>/dev/null | grep '^>' | grep '"resolved"' | grep -iE '(git://|http://)' | head -5 || true)
471
+ if [[ -n "${insecure_urls}" ]]; then
472
+ SUSPICIOUS=true
473
+ REASONS+=("Lock file contains insecure (non-HTTPS) resolved URLs")
474
+ fi
475
+
476
+ # Check for a very large number of new dependencies (potential dependency confusion)
477
+ new_deps=$(diff "${snapshot}" "${current}" 2>/dev/null | grep '^>' | grep -c '"resolved"' || true)
478
+ new_deps="${new_deps:-0}"
479
+ if [[ ${new_deps} -gt 50 ]]; then
480
+ SUSPICIOUS=true
481
+ REASONS+=("Unusually large number of new dependencies added: ${new_deps}")
482
+ fi
483
+ fi
484
+ done
485
+ }
486
+
487
+ # Function: check for suspicious binaries
488
+ check_binaries() {
489
+ if [[ -d "${PROJECT_DIR}/node_modules/.bin" ]]; then
490
+ # Check for newly added binaries that are actual compiled binaries (not scripts)
491
+ local new_bins
492
+ local old_bin_listing="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_bins.list"
493
+ if [[ -f "${old_bin_listing}" ]]; then
494
+ new_bins=$(ls "${PROJECT_DIR}/node_modules/.bin/" 2>/dev/null | sort | comm -13 "${old_bin_listing}" - | head -20)
495
+ else
496
+ new_bins=$(ls "${PROJECT_DIR}/node_modules/.bin/" 2>/dev/null | head -20)
497
+ fi
498
+
499
+ for bin in ${new_bins}; do
500
+ # Check if it's a binary file (not a script) — use full path (V-010)
501
+ local bin_path="${PROJECT_DIR}/node_modules/.bin/${bin}"
502
+ if [[ -f "${bin_path}" ]] && file "${bin_path}" 2>/dev/null | grep -qiE '(executable|shared object|Mach-O|ELF)'; then
503
+ SUSPICIOUS=true
504
+ REASONS+=("Native binary '${bin}' found in node_modules/.bin")
505
+ fi
506
+ done
507
+ fi
508
+ }
509
+
510
+ # Run all checks
511
+ check_postinstall_scripts
512
+ check_lockfile_diff
513
+ check_binaries
514
+
515
+ # --- Reorg Decision ---
516
+
517
+ if [[ "${SUSPICIOUS}" == "true" ]]; then
518
+ # REORG: Rollback to last confirmed safe snapshot
519
+ ROLLBACK_SNAPSHOT_ID=$(read_confirmed_snapshot "${DIR_HASH}")
520
+ if [[ -z "${ROLLBACK_SNAPSHOT_ID}" ]] || [[ ! -f "${SNAPSHOT_DIR}/${ROLLBACK_SNAPSHOT_ID}_meta.json" ]]; then
521
+ ROLLBACK_SNAPSHOT_ID="${SNAPSHOT_ID}"
522
+ fi
523
+
524
+ ROLLED_BACK=()
525
+
526
+ while IFS= read -r monitored_file; do
527
+ [[ -z "${monitored_file}" ]] && continue
528
+ restore_monitored_file "${monitored_file}" "${ROLLBACK_SNAPSHOT_ID}"
529
+ done < <(monitored_files)
530
+
531
+ while IFS= read -r csproj_file; do
532
+ [[ -z "${csproj_file}" ]] && continue
533
+ restore_monitored_file "${csproj_file}" "${ROLLBACK_SNAPSHOT_ID}"
534
+ done < <(find "${PROJECT_DIR}" -maxdepth 1 -type f -name "*.csproj" -exec basename {} \; 2>/dev/null | sort)
535
+
536
+ while IFS= read -r snap_csproj; do
537
+ [[ -z "${snap_csproj}" ]] && continue
538
+ restore_monitored_file "${snap_csproj}" "${ROLLBACK_SNAPSHOT_ID}"
539
+ done < <(find "${SNAPSHOT_DIR}" -maxdepth 1 -type f -name "${ROLLBACK_SNAPSHOT_ID}_*.csproj" -exec basename {} \; 2>/dev/null | sed "s/^${ROLLBACK_SNAPSHOT_ID}_//" | sort)
540
+
541
+ while IFS= read -r missing_csproj; do
542
+ [[ -z "${missing_csproj}" ]] && continue
543
+ restore_monitored_file "${missing_csproj}" "${ROLLBACK_SNAPSHOT_ID}"
544
+ done < <(find "${SNAPSHOT_DIR}" -maxdepth 1 -type f -name "${ROLLBACK_SNAPSHOT_ID}_*.csproj.missing" -exec basename {} \; 2>/dev/null | sed "s/^${ROLLBACK_SNAPSHOT_ID}_//; s/\\.missing$//" | sort)
545
+
546
+ # Restore package.json if it was modified
547
+ rollback_package_json="${SNAPSHOT_DIR}/${ROLLBACK_SNAPSHOT_ID}_package.json"
548
+ current_package_json="${PROJECT_DIR}/package.json"
549
+ if [[ -f "${rollback_package_json}" ]] && files_differ "${rollback_package_json}" "${current_package_json}"; then
550
+ cp "${rollback_package_json}" "${current_package_json}"
551
+ ROLLED_BACK+=("package.json")
552
+ fi
553
+
554
+ restore_node_modules
555
+ cleanup_old_snapshots
556
+
557
+ REASON_STR=$(printf '%s; ' "${REASONS[@]}")
558
+ ROLLED_BACK_STR=$(printf '%s, ' "${ROLLED_BACK[@]}")
559
+ WARNING_STR=""
560
+ if [[ ${#ROLLBACK_WARNINGS[@]} -gt 0 ]]; then
561
+ WARNING_STR=$(printf '%s; ' "${ROLLBACK_WARNINGS[@]}")
562
+ fi
563
+
564
+ # Log the reorg event
565
+ cat >> "${GUARD_DIR}/reorg.log" << LOG_EOF
566
+ [$(date -u +"%Y-%m-%dT%H:%M:%SZ")] REORG executed
567
+ Snapshot: ${SNAPSHOT_ID}
568
+ Rollback snapshot: ${ROLLBACK_SNAPSHOT_ID}
569
+ Project: ${PROJECT_DIR}
570
+ Reasons: ${REASON_STR%%; }
571
+ Rolled back: ${ROLLED_BACK_STR%, }
572
+ Rollback warnings: ${WARNING_STR%%; }
573
+ LOG_EOF
574
+
575
+ cat << EOF
576
+ {"systemMessage": "safedeps: 의심스러운 패키지 변경 감지, 마지막으로 confirmed 된 안전 스냅샷으로 롤백했습니다.\n\n감지된 문제:\n${REASON_STR%%; }\n\n롤백 기준 스냅샷: ${ROLLBACK_SNAPSHOT_ID}\n롤백된 파일: ${ROLLED_BACK_STR%, }\n${WARNING_STR:+\n추가 경고:\n${WARNING_STR%%; }}\n\n상세 로그: ${GUARD_DIR}/reorg.log"}
577
+ EOF
578
+ exit 0
579
+ fi
580
+
581
+ confirm_snapshot "${SNAPSHOT_ID}" "${DIR_HASH}"
582
+ cleanup_old_snapshots
583
+
584
+ exit 0