@aldegad/safedeps 2.4.0 → 2.4.1
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/ROADMAP.md +7 -2
- package/bin/safedeps +1 -1
- package/package.json +1 -1
- package/scripts/safedeps-post-verify.sh +56 -10
- package/scripts/safedeps-pre-guard.sh +34 -2
- package/scripts/test/e2e.sh +3 -3
- package/scripts/test/smoke.sh +27 -4
package/ROADMAP.md
CHANGED
|
@@ -56,7 +56,7 @@ The internal engine keeps the v1 `reorg-guard` assets.
|
|
|
56
56
|
|
|
57
57
|
### Release notes
|
|
58
58
|
|
|
59
|
-
- The npm package version in `package.json` is the single source of truth. `bin/safedeps` `SAFEDEPS_VERSION` tracks it and the smoke test reads `package.json` to compare (current: v2.4.
|
|
59
|
+
- The npm package version in `package.json` is the single source of truth. `bin/safedeps` `SAFEDEPS_VERSION` tracks it and the smoke test reads `package.json` to compare (current: v2.4.1).
|
|
60
60
|
- `npm test` runs the release smoke suite; the full fixture E2E lives under `v2.1-tests`.
|
|
61
61
|
- The daily re-check uses no LLM tokens. It is opt-in: a macOS `launchd` user agent runs `safedeps re-check --json` daily, installed atomically by `install-safedeps-recheck-agent.mjs`. It writes `~/.safedeps/recheck.log` and `~/.safedeps/recheck-alerts.jsonl` and raises a macOS notification on a new CVE/KEV/revoke/provider-skip. Network is used only for OSV / CISA / GHSA queries.
|
|
62
62
|
|
|
@@ -129,10 +129,15 @@ Status: shipped as v2.4.0.
|
|
|
129
129
|
### Verification
|
|
130
130
|
|
|
131
131
|
- lock-unavailable install denies fail-closed and logs to `advisory.log`
|
|
132
|
-
- jq-missing
|
|
132
|
+
- jq-missing denies a likely install (best-effort fail-closed) and logs it; only non-install commands fall through
|
|
133
|
+
- a missing ledger library denies fail-closed instead of falling through to allow
|
|
133
134
|
- ShellCheck (`--severity=error`) is clean across all shell sources
|
|
134
135
|
- existing smoke + e2e regression suite remains green on both Linux and macOS
|
|
135
136
|
|
|
137
|
+
### v2.4.1 — concurrent-install race fix (#5)
|
|
138
|
+
|
|
139
|
+
The pending state PreToolUse hands to PostToolUse was a single global `current_state` file, so two installs overlapping in one project could clobber each other and the effect gate could verify the wrong install (or skip one). Pending state is now keyed **per install** — `dir_hash` + a hash of the command with the inert-install rewrite normalized out — so PreToolUse and PostToolUse of the same install agree on a key while concurrent installs stay isolated. A concurrency harness (two installs → two pending files; a post consumes only its own) guards it.
|
|
140
|
+
|
|
136
141
|
---
|
|
137
142
|
|
|
138
143
|
## v3 (future)
|
package/bin/safedeps
CHANGED
package/package.json
CHANGED
|
@@ -120,6 +120,21 @@ compute_dir_hash() {
|
|
|
120
120
|
fi
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
# Per-install pending-state key (issue #5) — must match the PreToolUse derivation:
|
|
124
|
+
# dir hash + a hash of the command with the inert-install rewrite normalized out.
|
|
125
|
+
compute_pending_key() {
|
|
126
|
+
local dir_hash="$1" command="$2" norm cmd_hash
|
|
127
|
+
norm=$(printf '%s' "${command}" | sed -E 's/[[:space:]]+--ignore-scripts([[:space:]]|$)/ /g; s/[[:space:]]+/ /g; s/^ //; s/ $//')
|
|
128
|
+
if command -v md5sum >/dev/null 2>&1; then
|
|
129
|
+
cmd_hash=$(printf '%s' "${norm}" | md5sum | cut -d' ' -f1)
|
|
130
|
+
elif command -v md5 >/dev/null 2>&1; then
|
|
131
|
+
cmd_hash=$(md5 -q -s "${norm}")
|
|
132
|
+
else
|
|
133
|
+
cmd_hash=$(printf '%s' "${norm}" | cksum | cut -d' ' -f1)
|
|
134
|
+
fi
|
|
135
|
+
printf '%s_%s' "${dir_hash}" "${cmd_hash}"
|
|
136
|
+
}
|
|
137
|
+
|
|
123
138
|
hash_file() {
|
|
124
139
|
local file_path="$1"
|
|
125
140
|
|
|
@@ -376,25 +391,56 @@ if [[ "${TOOL_NAME}" != "Bash" ]]; then
|
|
|
376
391
|
exit 0
|
|
377
392
|
fi
|
|
378
393
|
|
|
394
|
+
# The command + cwd identify which pending install this PostToolUse belongs to
|
|
395
|
+
# (issue #5), so concurrent installs do not consume each other's state.
|
|
396
|
+
COMMAND=$(echo "${INPUT}" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
397
|
+
POST_CWD=$(echo "${INPUT}" | jq -r '.cwd // empty' 2>/dev/null)
|
|
398
|
+
[[ -z "${POST_CWD}" ]] && POST_CWD=$(pwd)
|
|
399
|
+
if command -v realpath >/dev/null 2>&1; then
|
|
400
|
+
POST_CWD=$(realpath "${POST_CWD}" 2>/dev/null || echo "${POST_CWD}")
|
|
401
|
+
elif command -v readlink >/dev/null 2>&1; then
|
|
402
|
+
POST_CWD=$(readlink -f "${POST_CWD}" 2>/dev/null || echo "${POST_CWD}")
|
|
403
|
+
fi
|
|
404
|
+
POST_DIR_HASH=$(compute_dir_hash "${POST_CWD}")
|
|
405
|
+
|
|
379
406
|
STATE_LOCK_HELD=true
|
|
380
407
|
acquire_state_lock
|
|
381
408
|
trap '[ "${STATE_LOCK_HELD:-}" = "true" ] && release_state_lock; STATE_LOCK_HELD=false' EXIT
|
|
382
409
|
|
|
383
|
-
#
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
410
|
+
# Resolve THIS install's pending state by its per-install key (issue #5). The
|
|
411
|
+
# filename also carries a snapshot id, so identical concurrent commands produce
|
|
412
|
+
# several files; consume exactly one (they verify the same closure), leaving the
|
|
413
|
+
# rest for their own post hooks. Fall back to the legacy global files for in-flight
|
|
414
|
+
# upgrades from a pre-#5 PreToolUse.
|
|
415
|
+
PENDING_PREFIX="${GUARD_DIR}/pending/$(compute_pending_key "${POST_DIR_HASH}" "${COMMAND}")__"
|
|
416
|
+
PENDING_FILE=""
|
|
417
|
+
for pending_candidate in "${PENDING_PREFIX}"*.json; do
|
|
418
|
+
[[ -f "${pending_candidate}" ]] && { PENDING_FILE="${pending_candidate}"; break; }
|
|
419
|
+
done
|
|
420
|
+
if [[ -n "${PENDING_FILE}" ]]; then
|
|
421
|
+
CURRENT_STATE=$(cat "${PENDING_FILE}")
|
|
422
|
+
SNAPSHOT_ID=$(echo "${CURRENT_STATE}" | jq -r '.snapshot_id // empty')
|
|
423
|
+
PROJECT_DIR=$(echo "${CURRENT_STATE}" | jq -r '.project_dir // empty')
|
|
424
|
+
DIR_HASH=$(echo "${CURRENT_STATE}" | jq -r '.dir_hash // empty')
|
|
425
|
+
rm -f "${PENDING_FILE}"
|
|
426
|
+
elif [[ -f "${GUARD_DIR}/current_state" ]]; then
|
|
393
427
|
CURRENT_STATE=$(cat "${GUARD_DIR}/current_state")
|
|
394
428
|
SNAPSHOT_ID=$(echo "${CURRENT_STATE}" | jq -r '.snapshot_id // empty')
|
|
395
429
|
PROJECT_DIR=$(echo "${CURRENT_STATE}" | jq -r '.project_dir // empty')
|
|
396
430
|
DIR_HASH=$(echo "${CURRENT_STATE}" | jq -r '.dir_hash // empty')
|
|
397
431
|
rm -f "${GUARD_DIR}/current_state"
|
|
432
|
+
elif [[ -f "${GUARD_DIR}/current_snapshot_id" ]]; then
|
|
433
|
+
SNAPSHOT_ID=$(cat "${GUARD_DIR}/current_snapshot_id")
|
|
434
|
+
PROJECT_DIR=$(cat "${GUARD_DIR}/current_project_dir" 2>/dev/null || pwd)
|
|
435
|
+
rm -f "${GUARD_DIR}/current_snapshot_id" "${GUARD_DIR}/current_project_dir"
|
|
436
|
+
else
|
|
437
|
+
# No pending state for this command. If it nonetheless looks like a dependency
|
|
438
|
+
# install, the effect gate could not verify it — record UNVERIFIED (observable)
|
|
439
|
+
# rather than disappearing silently (e.g. a payload with no `cwd`).
|
|
440
|
+
if printf '%s' "${COMMAND}" | grep -qiE '(npm|pnpm|yarn|bun)([^"]*)(install|add|dlx)|[^a-z]npx[[:space:]]|pip[0-9]*[[:space:]]+install|cargo[[:space:]]+(add|install)|go[[:space:]]+(get|install)|gem[[:space:]]+install|bundle[[:space:]]+add|poetry[[:space:]]+add|uv[[:space:]]+(add|pip)|pipenv[[:space:]]+install|mvn([^"]*)dependency:get|dotnet[[:space:]]+add[[:space:]]+package'; then
|
|
441
|
+
log_advisory "post-verify UNVERIFIED: no pending state for an install-looking command (missing cwd or pre hook never ran)."
|
|
442
|
+
fi
|
|
443
|
+
exit 0
|
|
398
444
|
fi
|
|
399
445
|
|
|
400
446
|
if [[ -z "${SNAPSHOT_ID}" ]]; then
|
|
@@ -123,6 +123,24 @@ compute_dir_hash() {
|
|
|
123
123
|
fi
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
# Per-install pending-state key (issue #5): dir hash + a hash of the command with
|
|
127
|
+
# the inert-install rewrite normalized out, so PreToolUse (original command) and
|
|
128
|
+
# PostToolUse (possibly `--ignore-scripts`-appended) of the SAME install resolve to
|
|
129
|
+
# the same key. This keeps concurrent installs in one project on separate pending
|
|
130
|
+
# files instead of clobbering a single global one.
|
|
131
|
+
compute_pending_key() {
|
|
132
|
+
local dir_hash="$1" command="$2" norm cmd_hash
|
|
133
|
+
norm=$(printf '%s' "${command}" | sed -E 's/[[:space:]]+--ignore-scripts([[:space:]]|$)/ /g; s/[[:space:]]+/ /g; s/^ //; s/ $//')
|
|
134
|
+
if command -v md5sum >/dev/null 2>&1; then
|
|
135
|
+
cmd_hash=$(printf '%s' "${norm}" | md5sum | cut -d' ' -f1)
|
|
136
|
+
elif command -v md5 >/dev/null 2>&1; then
|
|
137
|
+
cmd_hash=$(md5 -q -s "${norm}")
|
|
138
|
+
else
|
|
139
|
+
cmd_hash=$(printf '%s' "${norm}" | cksum | cut -d' ' -f1)
|
|
140
|
+
fi
|
|
141
|
+
printf '%s_%s' "${dir_hash}" "${cmd_hash}"
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
command_is_dependency_install() {
|
|
127
145
|
local command="$1"
|
|
128
146
|
local scan_command
|
|
@@ -678,10 +696,24 @@ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 ]]; then
|
|
|
678
696
|
fi
|
|
679
697
|
fi
|
|
680
698
|
|
|
681
|
-
# Write
|
|
699
|
+
# Write per-install pending state for PostToolUse, keyed by (dir_hash, normalized
|
|
700
|
+
# command) so concurrent installs in the same project keep separate state instead
|
|
701
|
+
# of clobbering one global file (issue #5). The single-file write is still atomic
|
|
702
|
+
# (write_state_file) to prevent TOCTOU within one install.
|
|
703
|
+
PENDING_DIR="${GUARD_DIR}/pending"
|
|
704
|
+
mkdir -p "${PENDING_DIR}"
|
|
705
|
+
# GC pending entries whose PostToolUse never fired (crash/no-op). 24h is well past
|
|
706
|
+
# any real install, so this never deletes an in-flight one (a 60-min window could
|
|
707
|
+
# have reaped a slow native build that was still running).
|
|
708
|
+
find "${PENDING_DIR}" -name '*.json' -type f -mmin +1440 -delete 2>/dev/null || true
|
|
709
|
+
# Key = (dir, normalized command); the snapshot id suffix makes the filename unique
|
|
710
|
+
# per install, so even two identical concurrent commands keep separate state.
|
|
711
|
+
PENDING_KEY=$(compute_pending_key "${DIR_HASH}" "${COMMAND}")
|
|
682
712
|
CURRENT_STATE=$(jq -n --arg sid "${SNAPSHOT_ID}" --arg pdir "${PROJECT_DIR}" --arg dhash "${DIR_HASH}" \
|
|
683
713
|
'{snapshot_id: $sid, project_dir: $pdir, dir_hash: $dhash}')
|
|
684
|
-
|
|
714
|
+
# $$ (this pre hook's PID) guarantees a unique filename even for two installs in
|
|
715
|
+
# the same second (SNAPSHOT_ID has only 1s resolution).
|
|
716
|
+
write_state_file "${PENDING_DIR}/${PENDING_KEY}__${SNAPSHOT_ID}_$$.json" "${CURRENT_STATE}"
|
|
685
717
|
|
|
686
718
|
if ! jq -e 'has("turn_id")' <<< "${INPUT}" >/dev/null 2>&1 && \
|
|
687
719
|
command_is_injectable_npm_install "${COMMAND}" && \
|
package/scripts/test/e2e.sh
CHANGED
|
@@ -146,7 +146,7 @@ cat > "${effect_project}/package-lock.json" <<'EOF'
|
|
|
146
146
|
EOF
|
|
147
147
|
effect_clean_post=$(
|
|
148
148
|
scripts/safedeps-post-verify.sh <<EOF
|
|
149
|
-
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"}}
|
|
149
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${effect_project}"}
|
|
150
150
|
EOF
|
|
151
151
|
)
|
|
152
152
|
[[ -z "${effect_clean_post}" ]] || fail "post hook passes approved full closure"
|
|
@@ -183,7 +183,7 @@ EOF
|
|
|
183
183
|
chmod +x "${stub_bin}/npm"
|
|
184
184
|
inert_post=$(
|
|
185
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"}}
|
|
186
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0 --ignore-scripts"},"cwd":"${inert_project}"}
|
|
187
187
|
EOF
|
|
188
188
|
)
|
|
189
189
|
[[ -z "${inert_post}" ]] || fail "post hook keeps verified inert rebuild success quiet"
|
|
@@ -220,7 +220,7 @@ cat > "${missing_project}/package-lock.json" <<'EOF'
|
|
|
220
220
|
EOF
|
|
221
221
|
missing_post=$(
|
|
222
222
|
scripts/safedeps-post-verify.sh <<EOF
|
|
223
|
-
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"}}
|
|
223
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${missing_project}"}
|
|
224
224
|
EOF
|
|
225
225
|
)
|
|
226
226
|
grep -q '의심스러운 패키지 변경 감지' <<< "${missing_post}" || fail "post hook reorgs unapproved transitive package"
|
package/scripts/test/smoke.sh
CHANGED
|
@@ -103,7 +103,7 @@ allow_output=$(
|
|
|
103
103
|
)
|
|
104
104
|
[[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${allow_output}")" == "allow" ]] || fail "hook emits Claude allow decision for approved install"
|
|
105
105
|
[[ "$(jq -r '.hookSpecificOutput.updatedInput.command' <<< "${allow_output}")" == "npm install left-pad@1.3.0 --ignore-scripts" ]] || fail "hook injects --ignore-scripts for Claude npm install"
|
|
106
|
-
allow_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-allow/
|
|
106
|
+
allow_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-allow/pending/"*.json)
|
|
107
107
|
jq -e '.ignore_scripts_injected == true' "${tmp_root}/safe-hook-allow/snapshots/${allow_sid}_meta.json" >/dev/null || fail "hook records injected meta flag"
|
|
108
108
|
pass "hook injects --ignore-scripts for Claude approved install"
|
|
109
109
|
|
|
@@ -113,7 +113,7 @@ codex_allow_output=$(
|
|
|
113
113
|
run_codex_hook_command "${tmp_root}/home-hook-codex" "${tmp_root}/safe-hook-codex" "npm install left-pad@1.3.0"
|
|
114
114
|
)
|
|
115
115
|
[[ -z "${codex_allow_output}" ]] || fail "hook keeps Codex approved install as plain allow"
|
|
116
|
-
codex_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-codex/
|
|
116
|
+
codex_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-codex/pending/"*.json)
|
|
117
117
|
jq -e '.ignore_scripts_injected == false' "${tmp_root}/safe-hook-codex/snapshots/${codex_sid}_meta.json" >/dev/null || fail "hook does not record injected meta flag for Codex"
|
|
118
118
|
pass "hook keeps Codex approved install as plain allow"
|
|
119
119
|
|
|
@@ -129,7 +129,7 @@ ignore_scripts_output=$(
|
|
|
129
129
|
run_hook_command "${tmp_root}/home-hook-ignore-scripts" "${tmp_root}/safe-hook-ignore-scripts" "npm install left-pad@1.3.0 --ignore-scripts"
|
|
130
130
|
)
|
|
131
131
|
[[ -z "${ignore_scripts_output}" ]] || fail "hook does not duplicate --ignore-scripts"
|
|
132
|
-
ignore_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-ignore-scripts/
|
|
132
|
+
ignore_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-ignore-scripts/pending/"*.json)
|
|
133
133
|
jq -e '.ignore_scripts_injected == false' "${tmp_root}/safe-hook-ignore-scripts/snapshots/${ignore_sid}_meta.json" >/dev/null || fail "hook does not record injected meta flag when flag already exists"
|
|
134
134
|
pass "hook does not duplicate --ignore-scripts"
|
|
135
135
|
|
|
@@ -224,6 +224,29 @@ fc_noledger=$(
|
|
|
224
224
|
grep -q 'ledger library missing' "${fc_safe}/advisory.log" || fail "pre-guard logs the missing-ledger deny to advisory.log"
|
|
225
225
|
pass "pre-guard fails closed when the ledger library is missing (observable)"
|
|
226
226
|
|
|
227
|
+
# Concurrency (issue #5): two installs of the SAME command in one project must
|
|
228
|
+
# keep separate pending state — the per-install snapshot+PID suffix isolates them,
|
|
229
|
+
# not just the command hash — and a post hook must consume exactly one.
|
|
230
|
+
conc_safe="${tmp_root}/safe-concurrency"
|
|
231
|
+
mkdir -p "${conc_safe}"
|
|
232
|
+
SAFEDEPS_HOME="${conc_safe}" lib/ledger/ledger.sh approve npm conc-a 1.0.0 1.0.0 smoke >/dev/null
|
|
233
|
+
run_hook_command "${tmp_root}/home-conc" "${conc_safe}" "npm install conc-a@1.0.0" >/dev/null
|
|
234
|
+
run_hook_command "${tmp_root}/home-conc" "${conc_safe}" "npm install conc-a@1.0.0" >/dev/null
|
|
235
|
+
conc_pending=$(find "${conc_safe}/pending" -name '*.json' -type f | wc -l | tr -d ' ')
|
|
236
|
+
[[ "${conc_pending}" == "2" ]] || fail "two identical concurrent installs keep two separate pending files (got ${conc_pending}, want 2)"
|
|
237
|
+
jq -nc --arg c "npm install conc-a@1.0.0" --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
|
|
238
|
+
HOME="${tmp_root}/home-conc" SAFEDEPS_HOME="${conc_safe}" scripts/safedeps-post-verify.sh >/dev/null 2>&1 || true
|
|
239
|
+
conc_left=$(find "${conc_safe}/pending" -name '*.json' -type f | wc -l | tr -d ' ')
|
|
240
|
+
[[ "${conc_left}" == "1" ]] || fail "post hook consumes exactly one identical-command install's pending state (left ${conc_left}, want 1)"
|
|
241
|
+
pass "concurrent installs (even identical commands) keep isolated pending state (issue #5)"
|
|
242
|
+
|
|
243
|
+
# A dependency-install PostToolUse with no pending state (e.g. a payload missing
|
|
244
|
+
# cwd) is recorded UNVERIFIED, not dropped silently (issue #5 review finding 3).
|
|
245
|
+
jq -nc '{tool_name:"Bash",tool_input:{command:"npm install orphan@1.0.0"}}' |
|
|
246
|
+
HOME="${tmp_root}/home-conc" SAFEDEPS_HOME="${conc_safe}" scripts/safedeps-post-verify.sh >/dev/null 2>&1 || true
|
|
247
|
+
grep -q 'UNVERIFIED: no pending state for an install-looking command' "${conc_safe}/advisory.log" || fail "post hook records an install with no pending state as UNVERIFIED"
|
|
248
|
+
pass "post hook records an install-looking command with no pending state as UNVERIFIED"
|
|
249
|
+
|
|
227
250
|
tamper_safe="${tmp_root}/safe-tamper"
|
|
228
251
|
tamper_home="${tmp_root}/home-tamper"
|
|
229
252
|
SAFEDEPS_HOME="${tamper_safe}" lib/ledger/ledger.sh approve npm ledger-tamper 1.0.0 1.0.0 smoke >/dev/null
|
|
@@ -236,7 +259,7 @@ cat > "${project_dir}/node_modules/ledger-tamper/package.json" <<'EOF'
|
|
|
236
259
|
{"name":"ledger-tamper","version":"1.0.0","scripts":{"postinstall":"node -e \"require('fs').writeFileSync(process.env.HOME + '/.safedeps/approved-specs/evil.json', '{}')\""}}
|
|
237
260
|
EOF
|
|
238
261
|
tamper_post=$(
|
|
239
|
-
jq -nc '{tool_name:"Bash",tool_input:{command:"npm install ledger-tamper@1.0.0"}}' |
|
|
262
|
+
jq -nc --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:"npm install ledger-tamper@1.0.0"},cwd:$cwd}' |
|
|
240
263
|
HOME="${tamper_home}" SAFEDEPS_HOME="${tamper_safe}" scripts/safedeps-post-verify.sh
|
|
241
264
|
)
|
|
242
265
|
grep -q '의심스러운 패키지 변경 감지' <<< "${tamper_post}" || fail "post hook reorgs safedeps ledger tamper script"
|