@aldegad/safedeps 2.2.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.
@@ -36,8 +36,26 @@ SAFEDEPS_MANIFEST_FILES=(
36
36
  umask 077
37
37
  mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
38
38
 
39
+ # Observable record of any gate bypass / unavailability (AGENTS.md: no silent fallback —
40
+ # every bypass must be observable and logged).
41
+ log_advisory() {
42
+ printf '%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "${GUARD_DIR}/advisory.log" 2>/dev/null || true
43
+ }
44
+
39
45
  if ! command -v jq >/dev/null 2>&1; then
40
- echo "safedeps: jq is not installed; skipping guard hook." >&2
46
+ # jq is required to parse the hook payload. Without it we cannot read the exact
47
+ # command, so do a best-effort fail-closed: read the raw payload and, if it
48
+ # looks like a dependency install, DENY (an install we cannot verify must not
49
+ # proceed). Non-install commands are allowed — jq absence must not block `ls`.
50
+ # Either branch is recorded in advisory.log; never a silent skip.
51
+ raw_input=$(cat)
52
+ log_advisory "pre-guard: jq missing — gate cannot parse the payload."
53
+ if printf '%s' "${raw_input}" | grep -qiE '(npm|pnpm|yarn|bun)([^"]*)(install|add|dlx)|[^a-z]npx[[:space:]]|pip[0-9]*[[:space:]]+install|poetry[[:space:]]+add|uv[[:space:]]+(add|pip[[:space:]]+install)|pipenv[[:space:]]+install|cargo[[:space:]]+(add|install)|go[[:space:]]+(get|install)|gem[[:space:]]+install|bundle[[:space:]]+add|mvn([^"]*)dependency:get|dotnet[[:space:]]+add[[:space:]]+package'; then
54
+ log_advisory "pre-guard DENY: jq missing on a likely dependency-install command — fail-closed."
55
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"safedeps: jq is required to gate dependency installs and is not installed — install blocked fail-closed. Install jq, then retry."}}\n'
56
+ exit 0
57
+ fi
58
+ echo "safedeps: jq is not installed — install gate disabled (non-install commands still allowed); logged to advisory.log." >&2
41
59
  exit 0
42
60
  fi
43
61
 
@@ -48,8 +66,10 @@ acquire_state_lock() {
48
66
  # Detect stale locks left by SIGKILL/OOM (V-005)
49
67
  if [[ -d "${STATE_LOCK_DIR}" ]]; then
50
68
  local lock_mtime=""
51
- if lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null) || \
52
- lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null); then
69
+ # GNU (`-c %Y`, Linux) first, then BSD/macOS (`-f %m`): on Linux `stat -f`
70
+ # means --file-system and would not yield an mtime.
71
+ if lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null) || \
72
+ lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null); then
53
73
  local now
54
74
  now=$(date +%s)
55
75
  if [[ $(( now - lock_mtime )) -gt 60 ]]; then
@@ -61,8 +81,11 @@ acquire_state_lock() {
61
81
  fi
62
82
 
63
83
  attempts=$((attempts + 1))
64
- if [[ ${attempts} -ge 100 ]]; then
65
- echo "safedeps: could not acquire state lock; skipping guard hook." >&2
84
+ if [[ ${attempts} -ge ${SAFEDEPS_LOCK_MAX_ATTEMPTS:-100} ]]; then
85
+ # acquire_state_lock is only reached for install candidates, so failing to
86
+ # serialize/snapshot means this install cannot be gated — fail CLOSED (deny).
87
+ log_advisory "pre-guard DENY: state lock unavailable for an install command — fail-closed."
88
+ jq -nc '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"safedeps: could not acquire the state lock (another safedeps run may be active). Install blocked fail-closed — retry in a moment."}}'
66
89
  exit 0
67
90
  fi
68
91
  sleep 0.1
@@ -100,6 +123,24 @@ compute_dir_hash() {
100
123
  fi
101
124
  }
102
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
+
103
144
  command_is_dependency_install() {
104
145
  local command="$1"
105
146
  local scan_command
@@ -438,7 +479,7 @@ fi
438
479
  # Conservative: only block when at least one pkg@spec token is parseable. Bare
439
480
  # `npm install` (lockfile install) falls through to the v1 reorg checks.
440
481
 
441
- SAFEDEPS_LEDGER_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh"
482
+ SAFEDEPS_LEDGER_LIB="${SAFEDEPS_LEDGER_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh}"
442
483
  SAFEDEPS_REPO_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/safedeps"
443
484
 
444
485
  guard_detect_ecosystem() {
@@ -596,7 +637,16 @@ while IFS= read -r ledger_spec_line; do
596
637
  LEDGER_SPECS+=("${ledger_spec_line}")
597
638
  done < <(guard_extract_specs "${COMMAND}")
598
639
 
599
- if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 && -f "${SAFEDEPS_LEDGER_LIB}" ]]; then
640
+ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 ]]; then
641
+ if [[ ! -f "${SAFEDEPS_LEDGER_LIB}" ]]; then
642
+ # The ledger library is the gate for direct install specs. If it is missing
643
+ # (broken install / moved repo) the gate cannot run — fail CLOSED, observably,
644
+ # instead of falling through to allow.
645
+ log_advisory "pre-guard DENY: ledger library missing (${SAFEDEPS_LEDGER_LIB}) — cannot enforce ${LEDGER_ECOSYSTEM} install, fail-closed."
646
+ jq -nc --arg eco "${LEDGER_ECOSYSTEM}" \
647
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("safedeps: the ledger library is missing, so the " + $eco + " install gate cannot run — install blocked fail-closed. Reinstall safedeps: node scripts/install/install-safedeps-hooks.mjs")}}'
648
+ exit 0
649
+ fi
600
650
  # shellcheck source=../lib/ledger/ledger.sh
601
651
  source "${SAFEDEPS_LEDGER_LIB}"
602
652
 
@@ -646,10 +696,24 @@ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 && -f "${SAFEDEPS_LE
646
696
  fi
647
697
  fi
648
698
 
649
- # 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}")
650
712
  CURRENT_STATE=$(jq -n --arg sid "${SNAPSHOT_ID}" --arg pdir "${PROJECT_DIR}" --arg dhash "${DIR_HASH}" \
651
713
  '{snapshot_id: $sid, project_dir: $pdir, dir_hash: $dhash}')
652
- 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}"
653
717
 
654
718
  if ! jq -e 'has("turn_id")' <<< "${INPUT}" >/dev/null 2>&1 && \
655
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"
@@ -280,4 +280,52 @@ if jq -e '[.. | strings] | any(contains("npm-reorg-guard"))' "${installer_home}/
280
280
  fi
281
281
  pass "installer legacy hook cleanup"
282
282
 
283
+ # --- Secret-leak lane: pre-commit gate must DENY a secret, PASS clean/example -
284
+ # The real bypass harness for the secret lane. Needs a scanner (gitleaks or
285
+ # docker) and openssl for a synthetic high-entropy secret; skip explicitly
286
+ # (not silently) when either is missing.
287
+ secret_repo="${tmp_root}/secret-repo"
288
+ mkdir -p "${secret_repo}"
289
+ git -C "${secret_repo}" init -q
290
+ git -C "${secret_repo}" config user.email t@safedeps.test
291
+ git -C "${secret_repo}" config user.name safedeps-e2e
292
+
293
+ # doctor flags gaps on the bare repo, then --fix scaffolds + activates the lane.
294
+ if HOME="${tmp_root}/doc-home" "${ROOT_DIR}/bin/safedeps" doctor --root "${secret_repo}" >/dev/null 2>&1; then
295
+ fail "doctor flags gaps on an unconfigured repo"
296
+ fi
297
+ HOME="${tmp_root}/doc-home" "${ROOT_DIR}/bin/safedeps" doctor --fix --root "${secret_repo}" >/dev/null
298
+ [[ -f "${secret_repo}/.gitleaks.toml" ]] || fail "doctor --fix scaffolds .gitleaks.toml"
299
+ [[ -x "${secret_repo}/.githooks/pre-commit" ]] || fail "doctor --fix scaffolds executable pre-commit"
300
+ [[ "$(git -C "${secret_repo}" config --get core.hooksPath)" == ".githooks" ]] || fail "doctor --fix activates core.hooksPath"
301
+ pass "doctor --fix scaffolds + activates the secret lane"
302
+
303
+ # The scaffolded pre-commit resolves `safedeps` via PATH, then SAFEDEPS_BIN, then
304
+ # the skill install paths. In CI none of those exist, so point it at this repo's
305
+ # binary; the git commit subprocess inherits the env and the hook resolves it.
306
+ export SAFEDEPS_BIN="${ROOT_DIR}/bin/safedeps"
307
+
308
+ if command -v gitleaks >/dev/null 2>&1 && command -v openssl >/dev/null 2>&1; then
309
+ # Regression: a clean file commits cleanly.
310
+ echo "hello" > "${secret_repo}/readme.txt"
311
+ git -C "${secret_repo}" add readme.txt
312
+ git -C "${secret_repo}" commit -q -m "clean" || fail "pre-commit allows a clean commit"
313
+
314
+ # Threat: a literal .env with an assigned (synthetic) secret must be blocked.
315
+ printf 'API_KEY=%s\n' "$(openssl rand -hex 20)" > "${secret_repo}/.env"
316
+ git -C "${secret_repo}" add .env
317
+ if git -C "${secret_repo}" commit -q -m "leak" 2>/dev/null; then
318
+ fail "pre-commit blocks a committed .env secret"
319
+ fi
320
+ git -C "${secret_repo}" reset -q HEAD .env >/dev/null 2>&1 || true
321
+
322
+ # Regression: the .env.example placeholder is allowlisted and commits.
323
+ printf 'API_KEY=your_api_key_here\n' > "${secret_repo}/.env.example"
324
+ git -C "${secret_repo}" add .env.example
325
+ git -C "${secret_repo}" commit -q -m "example" || fail "pre-commit allows the .env.example placeholder"
326
+ pass "pre-commit gate denies a secret, passes clean and example commits"
327
+ else
328
+ printf 'ok - pre-commit gate behavior SKIPPED (needs gitleaks + openssl)\n'
329
+ fi
330
+
283
331
  printf 'e2e passed\n'
@@ -31,6 +31,7 @@ bash -n lib/gates/repo-profile.sh
31
31
  bash -n lib/gates/scan.sh
32
32
  bash -n lib/gates/audit.sh
33
33
  bash -n lib/gates/hooks.sh
34
+ bash -n lib/gates/doctor.sh
34
35
  pass "bash syntax"
35
36
 
36
37
  node --check scripts/install/install-safedeps-hooks.mjs >/dev/null
@@ -58,6 +59,13 @@ provider_created=$(
58
59
  [[ "${provider_created}" == "${provider_tmp%/}/safedeps-providers."* ]] || fail "provider tmp helper uses requested TMPDIR"
59
60
  pass "provider temp dir"
60
61
 
62
+ # Portability guard: safedeps_file_mtime must return a bare integer on both BSD
63
+ # (macOS, `stat -f`) and GNU (Linux, `stat -c`). A wrong-order stat leaks
64
+ # filesystem info into the value and breaks the cache-freshness arithmetic.
65
+ mtime_val=$(bash -c 'source lib/providers/providers.sh; f=$(mktemp); safedeps_file_mtime "$f"; rm -f "$f"')
66
+ [[ "${mtime_val}" =~ ^[0-9]+$ ]] || fail "safedeps_file_mtime returns a bare integer (got: ${mtime_val})"
67
+ pass "file mtime is a portable integer"
68
+
61
69
  project_dir="${tmp_root}/project"
62
70
  mkdir -p "${project_dir}"
63
71
  printf '{"dependencies":{}}\n' > "${project_dir}/package.json"
@@ -95,7 +103,7 @@ allow_output=$(
95
103
  )
96
104
  [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${allow_output}")" == "allow" ]] || fail "hook emits Claude allow decision for approved install"
97
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"
98
- 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)
99
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"
100
108
  pass "hook injects --ignore-scripts for Claude approved install"
101
109
 
@@ -105,7 +113,7 @@ codex_allow_output=$(
105
113
  run_codex_hook_command "${tmp_root}/home-hook-codex" "${tmp_root}/safe-hook-codex" "npm install left-pad@1.3.0"
106
114
  )
107
115
  [[ -z "${codex_allow_output}" ]] || fail "hook keeps Codex approved install as plain allow"
108
- 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)
109
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"
110
118
  pass "hook keeps Codex approved install as plain allow"
111
119
 
@@ -121,7 +129,7 @@ ignore_scripts_output=$(
121
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"
122
130
  )
123
131
  [[ -z "${ignore_scripts_output}" ]] || fail "hook does not duplicate --ignore-scripts"
124
- 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)
125
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"
126
134
  pass "hook does not duplicate --ignore-scripts"
127
135
 
@@ -171,6 +179,74 @@ for bypass_cmd in "${bypass_cases[@]}"; do
171
179
  done
172
180
  pass "hook denies install bypass forms"
173
181
 
182
+ # Fail-closed gate: when the gate cannot run it must NOT silently pass, and the
183
+ # outcome must be observable in the advisory log (AGENTS.md: no silent fallback).
184
+ fc_safe="${tmp_root}/safe-failclosed"
185
+ fc_home="${tmp_root}/home-failclosed"
186
+ mkdir -p "${fc_safe}"
187
+ # (a) lock unavailable on an install command → DENY (fail-closed), logged.
188
+ mkdir -p "${fc_safe}/state.lock"
189
+ fc_deny=$(
190
+ jq -nc --arg c "npm install evil@1.0.0" --arg cwd "${project_dir}" \
191
+ '{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
192
+ HOME="${fc_home}" SAFEDEPS_HOME="${fc_safe}" SAFEDEPS_LOCK_MAX_ATTEMPTS=2 scripts/safedeps-pre-guard.sh
193
+ )
194
+ rmdir "${fc_safe}/state.lock" 2>/dev/null || true
195
+ [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${fc_deny}")" == "deny" ]] || fail "pre-guard fails closed (deny) when the state lock is unavailable for an install"
196
+ grep -q 'pre-guard DENY' "${fc_safe}/advisory.log" || fail "pre-guard logs the fail-closed deny to advisory.log"
197
+ pass "pre-guard fails closed on lock contention (observable)"
198
+
199
+ # (b) jq missing → best-effort fail-closed: a likely install DENIES, a non-install
200
+ # is allowed, both recorded in advisory.log (never a silent skip).
201
+ fc_nojq=$(mktemp -d "${tmp_root}/nojq.XXXXXX")
202
+ for fc_tool in bash mkdir date printf cat grep; do
203
+ ln -sf "$(command -v "${fc_tool}")" "${fc_nojq}/${fc_tool}" 2>/dev/null || true
204
+ done
205
+ fc_nojq_deny=$(
206
+ jq -nc --arg c "npm install x@1" --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
207
+ HOME="${fc_home}" SAFEDEPS_HOME="${fc_safe}" PATH="${fc_nojq}" scripts/safedeps-pre-guard.sh 2>/dev/null
208
+ )
209
+ [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${fc_nojq_deny}")" == "deny" ]] || fail "pre-guard denies a likely install when jq is missing (best-effort fail-closed)"
210
+ grep -q 'DENY: jq missing' "${fc_safe}/advisory.log" || fail "pre-guard logs the jq-missing install deny to advisory.log"
211
+ fc_nojq_allow=$(
212
+ jq -nc --arg c "ls -la" --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
213
+ HOME="${fc_home}" SAFEDEPS_HOME="${fc_safe}" PATH="${fc_nojq}" scripts/safedeps-pre-guard.sh 2>/dev/null
214
+ )
215
+ [[ "$(jq -r '.hookSpecificOutput.permissionDecision // "allow"' <<< "${fc_nojq_allow}" 2>/dev/null || echo allow)" != "deny" ]] || fail "pre-guard allows a non-install command when jq is missing"
216
+ pass "pre-guard fails closed on jq-missing installs, allows non-installs (observable)"
217
+
218
+ # (c) ledger library missing → DENY (fail-closed), logged — not a silent fall-through allow.
219
+ fc_noledger=$(
220
+ jq -nc --arg c "npm install x@1" --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
221
+ HOME="${fc_home}" SAFEDEPS_HOME="${fc_safe}" SAFEDEPS_LEDGER_LIB="${tmp_root}/does-not-exist.sh" scripts/safedeps-pre-guard.sh 2>/dev/null
222
+ )
223
+ [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${fc_noledger}")" == "deny" ]] || fail "pre-guard denies an install when the ledger library is missing (fail-closed)"
224
+ grep -q 'ledger library missing' "${fc_safe}/advisory.log" || fail "pre-guard logs the missing-ledger deny to advisory.log"
225
+ pass "pre-guard fails closed when the ledger library is missing (observable)"
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
+
174
250
  tamper_safe="${tmp_root}/safe-tamper"
175
251
  tamper_home="${tmp_root}/home-tamper"
176
252
  SAFEDEPS_HOME="${tamper_safe}" lib/ledger/ledger.sh approve npm ledger-tamper 1.0.0 1.0.0 smoke >/dev/null
@@ -183,7 +259,7 @@ cat > "${project_dir}/node_modules/ledger-tamper/package.json" <<'EOF'
183
259
  {"name":"ledger-tamper","version":"1.0.0","scripts":{"postinstall":"node -e \"require('fs').writeFileSync(process.env.HOME + '/.safedeps/approved-specs/evil.json', '{}')\""}}
184
260
  EOF
185
261
  tamper_post=$(
186
- 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}' |
187
263
  HOME="${tamper_home}" SAFEDEPS_HOME="${tamper_safe}" scripts/safedeps-post-verify.sh
188
264
  )
189
265
  grep -q '의심스러운 패키지 변경 감지' <<< "${tamper_post}" || fail "post hook reorgs safedeps ledger tamper script"
@@ -203,12 +279,36 @@ pass "re-check alert wrapper"
203
279
  # Release-time lane (absorbed from security-release-gates): commands must be
204
280
  # registered and resolve their gate scripts.
205
281
  gates_help=$(HOME="${tmp_root}/home-gates" SAFEDEPS_HOME="${tmp_root}/safe-gates" ./bin/safedeps help)
206
- for gate_cmd in "gates" "scan secrets" "audit" "hooks"; do
282
+ for gate_cmd in "gates" "scan secrets" "audit" "hooks" "doctor"; do
207
283
  grep -q "${gate_cmd}" <<< "${gates_help}" || fail "release-time command listed in help: ${gate_cmd}"
208
284
  done
209
- for gate_script in scripts/release-gates.sh lib/gates/repo-profile.sh lib/gates/scan.sh lib/gates/audit.sh lib/gates/hooks.sh; do
285
+ for gate_script in scripts/release-gates.sh lib/gates/repo-profile.sh lib/gates/scan.sh lib/gates/audit.sh lib/gates/hooks.sh lib/gates/doctor.sh; do
210
286
  [[ -f "${gate_script}" ]] || fail "release-time gate script present: ${gate_script}"
211
287
  done
288
+ for tmpl in gitleaks.toml.tmpl gitleaks.private.toml.tmpl pre-commit.tmpl; do
289
+ [[ -f "lib/gates/templates/${tmpl}" ]] || fail "secret-lane template present: ${tmpl}"
290
+ done
212
291
  pass "release-time gate commands registered"
213
292
 
293
+ # Secret-leak lane: doctor diagnoses, hooks init scaffolds, hooks install
294
+ # activates. No scanner (gitleaks/docker) needed for these structural checks.
295
+ doctor_repo=$(mktemp -d "${tmp_root}/secret-repo.XXXXXX")
296
+ git -C "${doctor_repo}" init -q
297
+ # doctor exits 1 when gaps exist; capture the JSON without tripping set -e.
298
+ doctor_json=$(HOME="${tmp_root}/home-doctor" ./bin/safedeps --json doctor --root "${doctor_repo}" || true)
299
+ [[ "$(jq -r '.command' <<< "${doctor_json}")" == "doctor" ]] || fail "doctor --json command field"
300
+ [[ "$(jq -r '.ok' <<< "${doctor_json}")" == "false" ]] || fail "doctor reports gaps on a bare repo"
301
+ secret_gaps=$(jq -r '[.checks[] | select(.lane == "secret" and .status == "gap")] | length' <<< "${doctor_json}")
302
+ [[ "${secret_gaps}" -ge 3 ]] || fail "doctor lists at least 3 secret-lane gaps (got ${secret_gaps})"
303
+ HOME="${tmp_root}/home-doctor" ./bin/safedeps hooks init --root "${doctor_repo}" >/dev/null
304
+ [[ -f "${doctor_repo}/.gitleaks.toml" ]] || fail "hooks init scaffolds .gitleaks.toml"
305
+ [[ -x "${doctor_repo}/.githooks/pre-commit" ]] || fail "hooks init scaffolds an executable pre-commit"
306
+ grep -q 'scan secrets --staged' "${doctor_repo}/.githooks/pre-commit" || fail "pre-commit delegates to safedeps scan"
307
+ printf '\n# repo-owned edit marker\n' >> "${doctor_repo}/.gitleaks.toml"
308
+ HOME="${tmp_root}/home-doctor" ./bin/safedeps hooks init --root "${doctor_repo}" >/dev/null
309
+ grep -q 'repo-owned edit marker' "${doctor_repo}/.gitleaks.toml" || fail "hooks init is non-destructive (keeps repo edits)"
310
+ HOME="${tmp_root}/home-doctor" ./bin/safedeps hooks install --root "${doctor_repo}" >/dev/null
311
+ [[ "$(git -C "${doctor_repo}" config --get core.hooksPath)" == ".githooks" ]] || fail "hooks install activates core.hooksPath"
312
+ pass "doctor + hooks init/install wire the secret lane (non-destructive)"
313
+
214
314
  printf 'smoke passed\n'