@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.
@@ -0,0 +1,212 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # safedeps doctor — repo security posture check.
5
+ #
6
+ # Diagnoses whether the per-repo secret-leak lane is set up (.gitleaks policy +
7
+ # .githooks/pre-commit + active core.hooksPath + an available scanner) and
8
+ # reports the global dependency-install gate too. Read-only by default; `--fix`
9
+ # scaffolds the missing pieces (hooks init) and activates them (hooks install).
10
+ #
11
+ # Exit codes: 0 = no gaps in the secret-leak lane, 1 = gaps remain.
12
+
13
+ GATES_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ # shellcheck source=./repo-profile.sh
15
+ source "$GATES_LIB_DIR/repo-profile.sh"
16
+
17
+ REPO_ROOT=""
18
+ FIX=0
19
+ JSON_MODE="${SAFEDEPS_JSON_MODE:-0}"
20
+
21
+ usage() {
22
+ printf 'Usage: safedeps doctor [--root <repo>] [--fix] [--json]\n' >&2
23
+ }
24
+
25
+ while [ $# -gt 0 ]; do
26
+ case "$1" in
27
+ --root) REPO_ROOT="${2:?--root needs a path}"; shift 2 ;;
28
+ --fix) FIX=1; shift ;;
29
+ --json) JSON_MODE=1; shift ;;
30
+ -h|--help) usage; exit 0 ;;
31
+ *) usage; exit 64 ;;
32
+ esac
33
+ done
34
+
35
+ if [ -z "$REPO_ROOT" ]; then REPO_ROOT="$(pwd)"; fi
36
+ REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
37
+
38
+ # --- check collection ---------------------------------------------------------
39
+ # Each check appends a row: <lane>\t<status>\t<label>\t<remedy>
40
+ # status ∈ ok | gap | na
41
+ CHECK_ROWS=()
42
+ GAPS=0
43
+
44
+ add_check() {
45
+ local lane="$1" status="$2" label="$3" remedy="${4:-}"
46
+ CHECK_ROWS+=("${lane}"$'\t'"${status}"$'\t'"${label}"$'\t'"${remedy}")
47
+ # The exit code reflects the per-repo secret-leak lane only. The global
48
+ # dependency-install gate is reported (✗) but is a per-machine concern, so it
49
+ # does not gate this repo's posture result.
50
+ if [ "$status" = "gap" ] && [ "$lane" = "secret" ]; then GAPS=$((GAPS + 1)); fi
51
+ }
52
+
53
+ scanner_available() {
54
+ if command -v gitleaks >/dev/null 2>&1; then printf 'gitleaks'; return 0; fi
55
+ if command -v docker >/dev/null 2>&1; then printf 'docker'; return 0; fi
56
+ return 1
57
+ }
58
+
59
+ dependency_gate_root() {
60
+ # The dependency-install gate is installed globally as a skill symlink for
61
+ # whichever engine(s) are present.
62
+ local found=""
63
+ [ -e "$HOME/.claude/skills/safedeps" ] && found="${found:+${found}, }~/.claude/skills/safedeps"
64
+ [ -e "$HOME/.codex/skills/safedeps" ] && found="${found:+${found}, }~/.codex/skills/safedeps"
65
+ printf '%s' "$found"
66
+ }
67
+
68
+ run_checks() {
69
+ CHECK_ROWS=()
70
+ GAPS=0
71
+
72
+ local is_git=0
73
+ if git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then is_git=1; fi
74
+
75
+ local profile="(n/a)"
76
+ if [ "$is_git" = 1 ]; then
77
+ add_check secret ok "git worktree"
78
+ profile="$(safedeps_repo_profile "$REPO_ROOT")"
79
+
80
+ local config_path config_label
81
+ config_path="$(safedeps_gitleaks_config "$REPO_ROOT" "$profile")"
82
+ config_label="gitleaks config ($(basename "$config_path"))"
83
+ if [ -f "$config_path" ]; then
84
+ add_check secret ok "$config_label"
85
+ else
86
+ add_check secret gap "$config_label" "safedeps hooks init --root \"$REPO_ROOT\""
87
+ fi
88
+
89
+ local hook_file="$REPO_ROOT/.githooks/pre-commit"
90
+ if [ -x "$hook_file" ]; then
91
+ add_check secret ok ".githooks/pre-commit (executable)"
92
+ elif [ -f "$hook_file" ]; then
93
+ add_check secret gap ".githooks/pre-commit (not executable)" "chmod +x \"$hook_file\""
94
+ else
95
+ add_check secret gap ".githooks/pre-commit (present)" "safedeps hooks init --root \"$REPO_ROOT\""
96
+ fi
97
+
98
+ local hooks_path
99
+ hooks_path="$(git -C "$REPO_ROOT" config --get core.hooksPath || true)"
100
+ if [ "$hooks_path" = ".githooks" ]; then
101
+ add_check secret ok "git hooks active (core.hooksPath=.githooks)"
102
+ else
103
+ add_check secret gap "git hooks active (core.hooksPath=${hooks_path:-<unset>})" "safedeps hooks install --root \"$REPO_ROOT\""
104
+ fi
105
+ else
106
+ add_check secret na "git worktree (secret lane needs git)"
107
+ fi
108
+
109
+ local scanner
110
+ if scanner="$(scanner_available)"; then
111
+ add_check secret ok "secret scanner available (${scanner})"
112
+ else
113
+ add_check secret gap "secret scanner available (gitleaks or docker)" "brew install gitleaks"
114
+ fi
115
+
116
+ local gate_root
117
+ gate_root="$(dependency_gate_root)"
118
+ if [ -n "$gate_root" ]; then
119
+ add_check deps ok "dependency-install gate installed (${gate_root})"
120
+ else
121
+ add_check deps gap "dependency-install gate installed" "node scripts/install/install-safedeps-hooks.mjs"
122
+ fi
123
+
124
+ DOCTOR_PROFILE="$profile"
125
+ }
126
+
127
+ emit_human() {
128
+ local sym
129
+ printf 'safedeps doctor — repo security posture\n'
130
+ printf 'repo: %s\n' "$REPO_ROOT"
131
+ printf 'profile: %s\n\n' "$DOCTOR_PROFILE"
132
+
133
+ printf 'Secret-leak lane (per-repo)\n'
134
+ local row lane status label remedy
135
+ for row in "${CHECK_ROWS[@]}"; do
136
+ IFS=$'\t' read -r lane status label remedy <<< "$row"
137
+ [ "$lane" = "secret" ] || continue
138
+ case "$status" in
139
+ ok) sym='✓' ;;
140
+ gap) sym='✗' ;;
141
+ *) sym='–' ;;
142
+ esac
143
+ if [ "$status" = "gap" ] && [ -n "$remedy" ]; then
144
+ printf ' %s %-44s → %s\n' "$sym" "$label" "$remedy"
145
+ else
146
+ printf ' %s %s\n' "$sym" "$label"
147
+ fi
148
+ done
149
+
150
+ printf '\nDependency-install gate (global, all repos)\n'
151
+ for row in "${CHECK_ROWS[@]}"; do
152
+ IFS=$'\t' read -r lane status label remedy <<< "$row"
153
+ [ "$lane" = "deps" ] || continue
154
+ case "$status" in
155
+ ok) sym='✓' ;;
156
+ gap) sym='✗' ;;
157
+ *) sym='–' ;;
158
+ esac
159
+ if [ "$status" = "gap" ] && [ -n "$remedy" ]; then
160
+ printf ' %s %-44s → %s\n' "$sym" "$label" "$remedy"
161
+ else
162
+ printf ' %s %s\n' "$sym" "$label"
163
+ fi
164
+ done
165
+
166
+ printf '\n'
167
+ if [ "$GAPS" -eq 0 ]; then
168
+ printf 'No gaps in the secret-leak lane. The agent is on guard.\n'
169
+ else
170
+ printf '%d gap(s) in the secret-leak lane.\n' "$GAPS"
171
+ printf 'Fix all at once: safedeps doctor --fix --root "%s"\n' "$REPO_ROOT"
172
+ fi
173
+ }
174
+
175
+ emit_json() {
176
+ local rows_json="[]" row lane status label remedy
177
+ for row in "${CHECK_ROWS[@]}"; do
178
+ IFS=$'\t' read -r lane status label remedy <<< "$row"
179
+ rows_json="$(jq -c \
180
+ --arg lane "$lane" --arg status "$status" --arg label "$label" --arg remedy "$remedy" \
181
+ '. + [{lane:$lane, status:$status, label:$label, remedy:($remedy|select(length>0))}]' \
182
+ <<< "$rows_json")"
183
+ done
184
+ jq -nc \
185
+ --arg repo "$REPO_ROOT" \
186
+ --arg profile "$DOCTOR_PROFILE" \
187
+ --argjson gaps "$GAPS" \
188
+ --argjson checks "$rows_json" \
189
+ '{command:"doctor", repo:$repo, profile:$profile, gaps:$gaps, ok:($gaps==0), checks:$checks}'
190
+ }
191
+
192
+ # --- fix pass -----------------------------------------------------------------
193
+ if [ "$FIX" -eq 1 ]; then
194
+ if ! git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
195
+ printf 'ERROR: --fix needs a git worktree (the secret lane is git-scoped): %s\n' "$REPO_ROOT" >&2
196
+ exit 1
197
+ fi
198
+ [ "$JSON_MODE" -eq 1 ] || printf 'safedeps doctor --fix: scaffolding + activating the secret-leak lane...\n\n'
199
+ bash "$GATES_LIB_DIR/hooks.sh" init --root "$REPO_ROOT"
200
+ bash "$GATES_LIB_DIR/hooks.sh" install --root "$REPO_ROOT"
201
+ [ "$JSON_MODE" -eq 1 ] || printf '\n'
202
+ fi
203
+
204
+ run_checks
205
+
206
+ if [ "$JSON_MODE" -eq 1 ]; then
207
+ emit_json
208
+ else
209
+ emit_human
210
+ fi
211
+
212
+ [ "$GAPS" -eq 0 ]
@@ -16,12 +16,12 @@ HOOKS_PATH=".githooks"
16
16
  AUTO=0
17
17
 
18
18
  usage() {
19
- printf 'Usage: safedeps hooks <install|check> [--root <repo>] [--hooks-path <dir>] [--auto]\n' >&2
19
+ printf 'Usage: safedeps hooks <install|check|init> [--root <repo>] [--hooks-path <dir>] [--auto]\n' >&2
20
20
  }
21
21
 
22
22
  while [ $# -gt 0 ]; do
23
23
  case "$1" in
24
- install|check) SUB="$1"; shift ;;
24
+ install|check|init) SUB="$1"; shift ;;
25
25
  --root) REPO_ROOT="${2:?--root needs a path}"; shift 2 ;;
26
26
  --hooks-path) HOOKS_PATH="${2:?--hooks-path needs a dir}"; shift 2 ;;
27
27
  --auto) AUTO=1; shift ;;
@@ -46,6 +46,44 @@ fi
46
46
  HOOK_FILE="$REPO_ROOT/$HOOKS_PATH/pre-commit"
47
47
 
48
48
  case "$SUB" in
49
+ init)
50
+ # Scaffold the repo's secret-leak policy from starter templates. Non-destructive:
51
+ # an existing file is reported and skipped, so repo-owned edits survive a re-run.
52
+ # `init` only drops files; `install` activates them (core.hooksPath).
53
+ TEMPLATES_DIR="$GATES_LIB_DIR/templates"
54
+ PROFILE="$(safedeps_repo_profile "$REPO_ROOT")"
55
+ created=0
56
+
57
+ scaffold() {
58
+ local dest="$1" tmpl="$2" mode="$3"
59
+ if [ -e "$dest" ]; then
60
+ printf 'safedeps hooks init: exists, skipped: %s\n' "$dest"
61
+ return 0
62
+ fi
63
+ if [ ! -f "$tmpl" ]; then
64
+ printf 'ERROR: template missing: %s\n' "$tmpl" >&2
65
+ exit 1
66
+ fi
67
+ mkdir -p "$(dirname "$dest")"
68
+ cp "$tmpl" "$dest"
69
+ chmod "$mode" "$dest"
70
+ printf 'safedeps hooks init: created %s\n' "$dest"
71
+ created=$((created + 1))
72
+ }
73
+
74
+ if [ "$PROFILE" = "private" ]; then
75
+ scaffold "$REPO_ROOT/.gitleaks.private.toml" "$TEMPLATES_DIR/gitleaks.private.toml.tmpl" 0644
76
+ else
77
+ scaffold "$REPO_ROOT/.gitleaks.toml" "$TEMPLATES_DIR/gitleaks.toml.tmpl" 0644
78
+ fi
79
+ scaffold "$REPO_ROOT/$HOOKS_PATH/pre-commit" "$TEMPLATES_DIR/pre-commit.tmpl" 0755
80
+
81
+ printf 'safedeps hooks init: profile=%s, %d file(s) created.\n' "$PROFILE" "$created"
82
+ if [ "$created" -gt 0 ]; then
83
+ printf ' Review the scaffolded .gitleaks policy, then activate:\n'
84
+ printf ' safedeps hooks install --root "%s"\n' "$REPO_ROOT"
85
+ fi
86
+ ;;
49
87
  install)
50
88
  if [ ! -f "$HOOK_FILE" ]; then
51
89
  printf 'ERROR: hook file not found: %s\n' "$HOOK_FILE" >&2
@@ -0,0 +1,45 @@
1
+ # .gitleaks.private.toml — safedeps starter secret-scan policy (PRIVATE profile).
2
+ #
3
+ # Resolved when the repo's origin slug or directory leaf ends in "-private",
4
+ # or when SAFEDEPS_REPO_PROFILE=private. Use this for the stricter policy a
5
+ # private repo wants on top of the public defaults.
6
+ #
7
+ # safedeps OWNS execution; THIS FILE is the repo's POLICY — you own it.
8
+ # `safedeps hooks init` scaffolds this once and will NOT overwrite your edits.
9
+
10
+ title = "safedeps secret-scan policy (private)"
11
+
12
+ # Inherit gitleaks' built-in ruleset. To also layer the repo's public policy,
13
+ # point `path` at it instead of (or in addition to) useDefault.
14
+ [extend]
15
+ useDefault = true
16
+ # path = ".gitleaks.toml"
17
+
18
+ # --- Extra rules layered on top of the defaults -------------------------------
19
+
20
+ [[rules]]
21
+ id = "safedeps-dotenv-file"
22
+ description = "Committed .env file with an assigned secret value"
23
+ path = '''(^|/)\.env(\.[\w.-]+)?$'''
24
+ regex = '''(?i)(secret|token|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret)\s*[:=]\s*\S{6,}'''
25
+ [rules.allowlist]
26
+ paths = [
27
+ '''(^|/)\.env\.example$''',
28
+ '''(^|/)\.env\.sample$''',
29
+ '''(^|/)\.env\.template$''',
30
+ ]
31
+
32
+ # Private repos often also flag internal identifiers (infra hosts, account ids).
33
+ # Add your own, e.g.:
34
+ # [[rules]]
35
+ # id = "safedeps-internal-host"
36
+ # description = "Internal hostname / infra endpoint"
37
+ # regex = '''(?i)\b[\w.-]+\.internal\.example\.com\b'''
38
+
39
+ # --- Repo-owned allowlist — CUSTOMIZE FOR THIS REPO ---------------------------
40
+ [allowlist]
41
+ description = "Repo-specific allowlist (tune me)"
42
+ paths = [
43
+ '''(^|/)\.gitleaks\.toml$''',
44
+ '''(^|/)\.gitleaks\.private\.toml$''',
45
+ ]
@@ -0,0 +1,43 @@
1
+ # .gitleaks.toml — safedeps starter secret-scan policy (public repo profile).
2
+ #
3
+ # safedeps OWNS execution (it runs gitleaks via `safedeps scan secrets`).
4
+ # THIS FILE is the repo's POLICY — you own it. Tune the [allowlist] below for
5
+ # this repo. `safedeps hooks init` scaffolds this file once and will NOT
6
+ # overwrite it on a re-run, so your edits are safe.
7
+ #
8
+ # gitleaks config reference: https://github.com/gitleaks/gitleaks#configuration
9
+
10
+ title = "safedeps secret-scan policy"
11
+
12
+ # Inherit gitleaks' built-in ruleset (AWS / GCP / private keys / OAuth tokens /
13
+ # generic high-entropy secrets, etc.). This is the bulk of the coverage.
14
+ [extend]
15
+ useDefault = true
16
+
17
+ # --- Extra rules layered on top of the defaults -------------------------------
18
+
19
+ # Flag a committed real dotenv file with an assigned secret. The .example /
20
+ # .sample / .template variants are allowlisted below so docs stay committable.
21
+ [[rules]]
22
+ id = "safedeps-dotenv-file"
23
+ description = "Committed .env file with an assigned secret value"
24
+ path = '''(^|/)\.env(\.[\w.-]+)?$'''
25
+ regex = '''(?i)(secret|token|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret)\s*[:=]\s*\S{6,}'''
26
+ [rules.allowlist]
27
+ paths = [
28
+ '''(^|/)\.env\.example$''',
29
+ '''(^|/)\.env\.sample$''',
30
+ '''(^|/)\.env\.template$''',
31
+ ]
32
+
33
+ # --- Repo-owned allowlist — CUSTOMIZE FOR THIS REPO ---------------------------
34
+ [allowlist]
35
+ description = "Repo-specific allowlist (tune me)"
36
+ paths = [
37
+ '''(^|/)\.gitleaks\.toml$''',
38
+ '''(^|/)\.gitleaks\.private\.toml$''',
39
+ # Add test fixtures / docs that legitimately contain secret-shaped strings:
40
+ # '''(^|/)test/fixtures/''',
41
+ ]
42
+ # regexes = ['''EXAMPLE_PLACEHOLDER_[A-Z0-9]+''']
43
+ # stopwords = ["example", "dummy", "placeholder"]
@@ -0,0 +1,49 @@
1
+ #!/bin/bash
2
+ # .githooks/pre-commit — safedeps secret-leak gate (scaffolded by `safedeps hooks init`).
3
+ #
4
+ # Blocks a commit when gitleaks finds a secret in the STAGED changes, so a key
5
+ # or a real .env never enters the repo's history. The detection POLICY lives in
6
+ # this repo's .gitleaks.toml (public) / .gitleaks.private.toml (private);
7
+ # safedeps owns execution. This hook delegates to one canonical scanner path:
8
+ # `safedeps scan secrets --staged`.
9
+ #
10
+ # Intentional bypass (use sparingly — you own the risk): git commit --no-verify
11
+ set -euo pipefail
12
+
13
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
14
+
15
+ # Resolve the safedeps CLI: PATH first, then the known skill install locations.
16
+ resolve_safedeps() {
17
+ if command -v safedeps >/dev/null 2>&1; then printf 'safedeps'; return 0; fi
18
+ local candidate
19
+ for candidate in \
20
+ "${SAFEDEPS_BIN:-}" \
21
+ "${HOME}/.claude/skills/safedeps/bin/safedeps" \
22
+ "${HOME}/.codex/skills/safedeps/bin/safedeps"; do
23
+ if [ -n "${candidate}" ] && [ -x "${candidate}" ]; then
24
+ printf '%s' "${candidate}"
25
+ return 0
26
+ fi
27
+ done
28
+ return 1
29
+ }
30
+
31
+ if ! sd=$(resolve_safedeps); then
32
+ cat >&2 <<'EOF'
33
+ safedeps pre-commit: cannot locate the `safedeps` CLI.
34
+
35
+ The secret-scan gate is FAIL-CLOSED — the commit is blocked because the scanner
36
+ could not run (no silent skip). Fix one of:
37
+ - put `safedeps` on PATH, or
38
+ - export SAFEDEPS_BIN=/path/to/bin/safedeps, or
39
+ - install the skill: node scripts/install/install-safedeps-hooks.mjs
40
+
41
+ To bypass intentionally (you own the risk): git commit --no-verify
42
+ EOF
43
+ exit 1
44
+ fi
45
+
46
+ # Delegate to the single canonical scanner. A non-zero exit means either a
47
+ # secret was found OR no scanner (gitleaks/docker) is available — both
48
+ # fail-closed and block the commit.
49
+ exec "${sd}" scan secrets --staged --root "${repo_root}"
@@ -93,7 +93,10 @@ safedeps_hash_text() {
93
93
  safedeps_file_mtime() {
94
94
  local path="$1"
95
95
 
96
- stat -f %m "${path}" 2>/dev/null || stat -c %Y "${path}" 2>/dev/null
96
+ # GNU coreutils (Linux) uses `-c %Y`; BSD/macOS uses `-f %m`. GNU must run
97
+ # first: on Linux `stat -f` means --file-system and prints filesystem info to
98
+ # stdout, which would pollute the mtime and break the arithmetic downstream.
99
+ stat -c %Y "${path}" 2>/dev/null || stat -f %m "${path}" 2>/dev/null
97
100
  }
98
101
 
99
102
  safedeps_cache_is_fresh() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aldegad/safedeps",
3
- "version": "2.2.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": {
@@ -16,6 +16,7 @@
16
16
  "ARCHITECTURE.md",
17
17
  "ROADMAP.md",
18
18
  "SKILL.md",
19
+ "SECURITY.md",
19
20
  "LICENSE"
20
21
  ],
21
22
  "scripts": {
@@ -242,6 +242,9 @@ function main() {
242
242
  log("uninstall done.");
243
243
  } else {
244
244
  log("install done. New hook events fire on the next session start.");
245
+ // The dependency-install gate is global. The secret-leak lane is per-repo
246
+ // and stays opt-in (its policy lives in each repo). Nudge, do not auto-write.
247
+ log("secret-leak lane is per-repo — in a repo run: safedeps doctor (then `safedeps doctor --fix` to scaffold + activate).");
245
248
  }
246
249
  }
247
250
 
@@ -35,8 +35,16 @@ SAFEDEPS_MANIFEST_FILES=(
35
35
  umask 077
36
36
  mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
37
37
 
38
+ # Observable record when the effect gate cannot run (AGENTS.md: no silent fallback).
39
+ # The install already happened by PostToolUse, so we cannot block it; what we can
40
+ # guarantee is that an un-runnable gate is recorded as UNVERIFIED, never a silent pass.
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
+
38
45
  if ! command -v jq >/dev/null 2>&1; then
39
- echo "safedeps: jq is not installed; skipping verify hook." >&2
46
+ log_advisory "post-verify UNVERIFIED: jq missing could not verify the install closure. Install jq to restore the effect gate."
47
+ echo "safedeps: jq is not installed — the post-install effect gate could not run; this install is UNVERIFIED (logged to advisory.log)." >&2
40
48
  exit 0
41
49
  fi
42
50
 
@@ -55,8 +63,10 @@ acquire_state_lock() {
55
63
  # Detect stale locks left by SIGKILL/OOM (V-005)
56
64
  if [[ -d "${STATE_LOCK_DIR}" ]]; then
57
65
  local lock_mtime=""
58
- if lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null) || \
59
- lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null); then
66
+ # GNU (`-c %Y`, Linux) first, then BSD/macOS (`-f %m`): on Linux `stat -f`
67
+ # means --file-system and would not yield an mtime.
68
+ if lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null) || \
69
+ lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null); then
60
70
  local now
61
71
  now=$(date +%s)
62
72
  if [[ $(( now - lock_mtime )) -gt 60 ]]; then
@@ -68,8 +78,11 @@ acquire_state_lock() {
68
78
  fi
69
79
 
70
80
  attempts=$((attempts + 1))
71
- if [[ ${attempts} -ge 100 ]]; then
72
- echo "safedeps: could not acquire state lock; skipping verify hook." >&2
81
+ if [[ ${attempts} -ge ${SAFEDEPS_LOCK_MAX_ATTEMPTS:-100} ]]; then
82
+ # Install already ran; another safedeps run holds the lock. Record UNVERIFIED
83
+ # rather than silently passing — the other run, or a re-check, owns verification.
84
+ log_advisory "post-verify UNVERIFIED: state lock unavailable (another safedeps run active) — install not verified by this run."
85
+ echo "safedeps: could not acquire state lock; this install is UNVERIFIED by this run (logged to advisory.log)." >&2
73
86
  exit 0
74
87
  fi
75
88
  sleep 0.1
@@ -107,6 +120,21 @@ compute_dir_hash() {
107
120
  fi
108
121
  }
109
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
+
110
138
  hash_file() {
111
139
  local file_path="$1"
112
140
 
@@ -363,25 +391,56 @@ if [[ "${TOOL_NAME}" != "Bash" ]]; then
363
391
  exit 0
364
392
  fi
365
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
+
366
406
  STATE_LOCK_HELD=true
367
407
  acquire_state_lock
368
408
  trap '[ "${STATE_LOCK_HELD:-}" = "true" ] && release_state_lock; STATE_LOCK_HELD=false' EXIT
369
409
 
370
- # Check if we have a pending snapshot to verify (V-004: atomic state file)
371
- if [[ ! -f "${GUARD_DIR}/current_state" ]]; then
372
- # Legacy fallback for in-flight upgrades
373
- if [[ ! -f "${GUARD_DIR}/current_snapshot_id" ]]; then
374
- exit 0
375
- fi
376
- SNAPSHOT_ID=$(cat "${GUARD_DIR}/current_snapshot_id")
377
- PROJECT_DIR=$(cat "${GUARD_DIR}/current_project_dir" 2>/dev/null || pwd)
378
- rm -f "${GUARD_DIR}/current_snapshot_id" "${GUARD_DIR}/current_project_dir"
379
- 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
380
427
  CURRENT_STATE=$(cat "${GUARD_DIR}/current_state")
381
428
  SNAPSHOT_ID=$(echo "${CURRENT_STATE}" | jq -r '.snapshot_id // empty')
382
429
  PROJECT_DIR=$(echo "${CURRENT_STATE}" | jq -r '.project_dir // empty')
383
430
  DIR_HASH=$(echo "${CURRENT_STATE}" | jq -r '.dir_hash // empty')
384
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
385
444
  fi
386
445
 
387
446
  if [[ -z "${SNAPSHOT_ID}" ]]; then