@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 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.0).
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 is logged as an observable allow-with-warning, never a silent skip
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
@@ -7,7 +7,7 @@
7
7
 
8
8
  set -euo pipefail
9
9
 
10
- SAFEDEPS_VERSION="2.4.0"
10
+ SAFEDEPS_VERSION="2.4.1"
11
11
 
12
12
  # ---- repo / lib bootstrap ----------------------------------------------------
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aldegad/safedeps",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
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": {
@@ -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
- # Check if we have a pending snapshot to verify (V-004: atomic state file)
384
- if [[ ! -f "${GUARD_DIR}/current_state" ]]; then
385
- # Legacy fallback for in-flight upgrades
386
- if [[ ! -f "${GUARD_DIR}/current_snapshot_id" ]]; then
387
- exit 0
388
- fi
389
- SNAPSHOT_ID=$(cat "${GUARD_DIR}/current_snapshot_id")
390
- PROJECT_DIR=$(cat "${GUARD_DIR}/current_project_dir" 2>/dev/null || pwd)
391
- rm -f "${GUARD_DIR}/current_snapshot_id" "${GUARD_DIR}/current_project_dir"
392
- else
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 current state atomically for PostToolUse (V-004: single file prevents TOCTOU)
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
- write_state_file "${GUARD_DIR}/current_state" "${CURRENT_STATE}"
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}" && \
@@ -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"
@@ -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/current_state")
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/current_state")
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/current_state")
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"