@aldegad/safedeps 2.4.0 → 2.5.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.
package/ARCHITECTURE.md CHANGED
@@ -43,7 +43,7 @@ safedeps owns security gates at two distinct moments, under one skill. It absorb
43
43
 
44
44
  The two lanes differ in timing and scope (one package's effect before/after install vs. the whole repo before a release). They live under one umbrella but stay separated by command namespace.
45
45
 
46
- **The secret-leak side of the release-time lane is per-repo and opt-in.** Its detection policy lives in the target repo, not in safedeps, so it does nothing until the repo provides a `.gitleaks` config and an active `.githooks/pre-commit`. `safedeps doctor` is the repo-entry diagnostic that closes that gap: it reports each piece of the secret-leak lane (`.gitleaks` policy, `pre-commit`, `core.hooksPath`, scanner availability) plus the global install-time gate, and exits non-zero when the per-repo lane has gaps. `safedeps doctor --fix` (= `safedeps hooks init` then `safedeps hooks install`) scaffolds a starter policy from `lib/gates/templates/` and activates the hooks. The scaffold is **non-destructive** — an existing repo-owned config is never overwritten — preserving the invariant that safedeps owns *execution*, not *policy*. The scaffolded `pre-commit` delegates to `safedeps scan secrets --staged` (a single canonical scanner path) and is fail-closed: an unresolvable `safedeps` or missing scanner blocks the commit rather than skipping silently.
46
+ **The secret-leak side of the release-time lane is per-repo and opt-in.** Its detection policy lives in the target repo, not in safedeps, so it does nothing until the repo provides a `.gitleaks` config and an active `.githooks/pre-commit`. `safedeps doctor` is the repo-entry diagnostic that closes that gap: it reports each piece of the secret-leak lane (`.gitleaks` policy, `pre-commit`, `core.hooksPath`, scanner availability) plus the global install-time gate, and exits non-zero when the per-repo lane has gaps. `safedeps doctor --fix` (= `safedeps hooks init` then `safedeps hooks install`) scaffolds a starter policy from `lib/gates/templates/` and activates the hooks. The scaffold is **non-destructive** — an existing repo-owned config is never overwritten — preserving the invariant that safedeps owns *execution*, not *policy*. The scaffolded `pre-commit` runs two checks. The secret scan (`safedeps scan secrets --staged`) runs on every commit and is fail-closed: an unresolvable `safedeps` or a missing scanner blocks rather than skipping silently. The npm dependency audit (`safedeps audit npm`) also runs on every commit in a repo with an npm lockfile — not only when the lockfile changes — so a CVE disclosed *after* a package was installed is caught at the next commit by re-querying the advisory DB. The audit separates the security verdict from an availability failure via meaningful exit codes (0 clean / 1 vulnerable / 2 could-not-run): a real finding **blocks** (fail-closed), while an unreachable advisory DB makes the hook **warn and allow the commit** — an explicit, observable availability failover (per the no-silent-fallback invariant: it is logged to the commit output and does not change canonical truth), with CI and the daily re-check re-covering what the offline commit could not verify.
47
47
 
48
48
  **The effect-primary model is npm-only.** `pip`, `cargo`, `go`, `gem`, `maven`, and `nuget` stay on the v2.1 command-gate + reorg model until their closure resolvers land; they are not described as having PostToolUse closure authority.
49
49
 
package/README.ko.md CHANGED
@@ -16,6 +16,19 @@
16
16
 
17
17
  ---
18
18
 
19
+ ## 배포 모델
20
+
21
+ safedeps 에는 두 배포 surface 가 있다:
22
+
23
+ 1. **Agent skill + hooks (canonical)** -- repo 자체가 skill folder 다. `SKILL.md`, hook script, provider/ledger library, install helper 가 한 디렉터리에 함께 있다.
24
+ 2. **npm package (CLI convenience)** -- `@aldegad/safedeps` 는 `safedeps` command 를 설치한다. npm 설치만으로 Claude Code 나 Codex 가 skill 을 자동 discover 하지는 않는다. npm 설치 후에도 hook/skill installer 를 실행하거나 skill folder 를 수동 등록해야 한다.
25
+
26
+ 전체 skill/hook source tree 를 canonical artifact 로 원하면 GitHub release 를 쓴다. versioned global CLI 가 주목적이면 npm 을 쓴다.
27
+
28
+ 용어: safedeps 는 Claude/Codex hook 과 local CLI 로 동작하는 agent security skill 이다. plugin manifest 로 감싸기 전까지는 Codex plugin bundle 이 아니다.
29
+
30
+ ---
31
+
19
32
  ## 두 lane
20
33
 
21
34
  `safedeps` 는 두 보안 lane 을 소유한다 (전체 설계: [`ARCHITECTURE.md`](./ARCHITECTURE.md) §1):
@@ -23,7 +36,7 @@
23
36
  - **install-time** (이 README 의 초점) — advisory check + approved-spec ledger + 빠른 PreToolUse guard + PostToolUse effect enforcement + post-install reorg. 패키지 단위, install 명령과 실제 lockfile effect 주변.
24
37
  - **release-time** — `safedeps gates run`, `safedeps scan secrets [--repo|--worktree|--staged]`, `safedeps audit npm`, `safedeps hooks install|check`. repo 트리 secret scan, 의존성 audit, repo-local git hook 설치/검사 (push/release 전). repo-specific policy(gitleaks config, privacy 경로)는 대상 repo 에 남고 safedeps 는 실행 owner. *(옛 `security-release-gates` 흡수.)*
25
38
 
26
- release-time lane 의 secret 누출 쪽은 **repo 별이고 opt-in** 이다. `safedeps doctor` 가 그 repo-entry 점검이다 — repo 의 `.gitleaks` policy, `.githooks/pre-commit`, 활성 `core.hooksPath`, scanner 가용성을 진단하고(전역 install-time gate 상태도 같이 보고), `safedeps doctor --fix` 가 시작 policy 를 scaffold(`safedeps hooks init`)하고 활성화(`safedeps hooks install`)한다. scaffold 는 비파괴적이라 repo 가 소유한 기존 `.gitleaks.toml` 은 덮어쓰지 않으며, pre-commit hook 은 `safedeps scan secrets --staged` 위임하고 fail-closed 다. [secret 누출 lane (repo 별)](#secret-누출-lane-repo-별) 참고.
39
+ release-time lane 의 secret 누출 쪽은 **repo 별이고 opt-in** 이다. `safedeps doctor` 가 그 repo-entry 점검이다 — repo 의 `.gitleaks` policy, `.githooks/pre-commit`, 활성 `core.hooksPath`, scanner 가용성을 진단하고(전역 install-time gate 상태도 같이 보고), `safedeps doctor --fix` 가 시작 policy 를 scaffold(`safedeps hooks init`)하고 활성화(`safedeps hooks install`)한다. scaffold 는 비파괴적이라 repo 가 소유한 기존 `.gitleaks.toml` 은 덮어쓰지 않으며, pre-commit hook 은 매 커밋 비밀키 스캔(`safedeps scan secrets --staged`)을 돌리고 npm repo 면 매 커밋 의존성 audit(`safedeps audit npm`)도 돌린다 — 실제 취약점은 차단(fail-closed)하고, 어드바이저리 DB 도달 불가 시에는 경고만 하고 커밋을 통과시킨다(관측 가능한 오프라인 failover). [secret 누출 lane (repo 별)](#secret-누출-lane-repo-별) 참고.
27
40
 
28
41
  ---
29
42
 
@@ -106,7 +119,11 @@ lane 구성 요소:
106
119
 
107
120
  - **`safedeps hooks init`** 가 시작용 `.gitleaks.toml`(private repo 면 `.gitleaks.private.toml`)과 `.githooks/pre-commit` 을 scaffold 한다. 기존 파일은 덮지 않고 유지 — policy 는 repo 가 소유한다.
108
121
  - **`safedeps hooks install`** 이 repo-local hook 을 활성화한다(`core.hooksPath = .githooks`).
109
- - **pre-commit hook 은 `safedeps scan secrets --staged` 에 위임**하고 **fail-closed** 다: scanner(로컬 `gitleaks` 또는 Docker)가 못 돌면 silent skip 이 아니라 커밋을 막는다. 의도된 우회는 사람이 소유하는 `git commit --no-verify` 뿐이다.
122
+ - **pre-commit hook 은 검사를 돌린다**:
123
+ - **비밀키 스캔**(`safedeps scan secrets --staged`)을 매 커밋, **fail-closed**. scanner(로컬 `gitleaks` 또는 Docker)가 못 돌면 silent skip 이 아니라 커밋을 막는다.
124
+ - **npm 의존성 audit**(`safedeps audit npm`)을 npm lockfile 이 있는 repo 면 **매 커밋**. 취약한 직접·*transitive* 의존성을 잡는다 — 패키지를 깐 *뒤에* 공개된 CVE("그땐 안전해 보였는데 지금 발견됨")까지 포함해서, 사람이 손으로 절대 못 보는 그것. lockfile 이 바뀔 때만이 아니라 매 커밋 돌리는 게 핵심이다: 어드바이저리 DB 를 다시 조회하니까 이미 깔린 의존성에 *새로* 뜬 CVE 가 바로 다음 커밋에 드러난다. 보안 판정과 가용성 실패는 구분된다 — 실제 취약점은 **차단**(fail-closed)하지만, 어드바이저리 DB 가 **도달 불가**(오프라인/레지스트리 오류)면 **경고만 하고 커밋을 통과**시킨다(관측 가능한 가용성 failover, silent skip 아님). 오프라인 커밋이 못 본 건 CI 와 데일리 re-check 가 다시 메운다.
125
+
126
+ 의도된 우회는 사람이 소유하는 `git commit --no-verify` 뿐이다.
110
127
 
111
128
  scaffold 된 `.gitleaks.toml` 은 **네가 손보는 시작점**이다: gitleaks 기본 ruleset 을 extend 하고, 값이 할당된 `.env` 커밋을 잡는 rule 을 더하며(`.env.example`/`.sample`/`.template` 변형은 allowlist), fixture 용 repo-owned `[allowlist]` 블록을 남겨둔다. safedeps 는 *실행* — `safedeps scan secrets` 로 gitleaks 구동 — 만 소유하고 policy 내용은 소유하지 않는다.
112
129
 
package/README.md CHANGED
@@ -19,6 +19,8 @@ Safedeps has two distribution surfaces:
19
19
 
20
20
  Use the GitHub release when you want the full skill/hook source tree as the canonical artifact. Use npm when you mainly want a versioned global CLI.
21
21
 
22
+ Terminology: safedeps is an agent security skill backed by Claude/Codex hooks and a local CLI. It is not a Codex plugin bundle unless it is later wrapped with a plugin manifest.
23
+
22
24
  ## Two Lanes
23
25
 
24
26
  `safedeps` owns two security lanes (full design in [`ARCHITECTURE.md`](./ARCHITECTURE.md) §1):
@@ -26,7 +28,7 @@ Use the GitHub release when you want the full skill/hook source tree as the cano
26
28
  - **Install-time** (the focus of this README) — advisory check + approved-spec ledger + fast PreToolUse guard + PostToolUse effect enforcement + post-install reorg. Per-package, around the install command and its actual lockfile effect.
27
29
  - **Release-time** — `safedeps gates run`, `safedeps scan secrets [--repo|--worktree|--staged]`, `safedeps audit npm`, `safedeps hooks install|check`. Repo-tree secret scan, dependency audit, and repo-local git hook install/check before push/release. Repo-specific policy (gitleaks config, privacy paths) stays in the target repo; safedeps owns execution. *(Absorbed the former `security-release-gates`.)*
28
30
 
29
- The secret-leak side of the release-time lane is **per-repo and opt-in**. `safedeps doctor` is its repo-entry check: it diagnoses the repo's `.gitleaks` policy, `.githooks/pre-commit`, the active `core.hooksPath`, and scanner availability (and reports the global install-time gate too), then `safedeps doctor --fix` scaffolds a starter policy (`safedeps hooks init`) and activates it (`safedeps hooks install`). The scaffold is non-destructive — an existing repo-owned `.gitleaks.toml` is never overwritten — and the pre-commit hook delegates to `safedeps scan secrets --staged`, fail-closed. See [Secret-Leak Lane (per-repo)](#secret-leak-lane-per-repo).
31
+ The secret-leak side of the release-time lane is **per-repo and opt-in**. `safedeps doctor` is its repo-entry check: it diagnoses the repo's `.gitleaks` policy, `.githooks/pre-commit`, the active `core.hooksPath`, and scanner availability (and reports the global install-time gate too), then `safedeps doctor --fix` scaffolds a starter policy (`safedeps hooks init`) and activates it (`safedeps hooks install`). The scaffold is non-destructive — an existing repo-owned `.gitleaks.toml` is never overwritten — and the pre-commit hook runs a secret scan (`safedeps scan secrets --staged`) plus, on every commit in an npm repo, a dependency audit (`safedeps audit npm`): a real finding blocks (fail-closed), while an unreachable advisory DB only warns and lets the commit through (observable offline failover). See [Secret-Leak Lane (per-repo)](#secret-leak-lane-per-repo).
30
32
 
31
33
  ## How It Works
32
34
 
@@ -175,7 +177,11 @@ What the lane is made of:
175
177
 
176
178
  - **`safedeps hooks init`** scaffolds a starter `.gitleaks.toml` (or `.gitleaks.private.toml` for a private repo) and a `.githooks/pre-commit`. Existing files are kept, never overwritten — the repo owns the policy.
177
179
  - **`safedeps hooks install`** activates the repo-local hooks (`core.hooksPath = .githooks`).
178
- - The **pre-commit hook delegates to `safedeps scan secrets --staged`** and is **fail-closed**: if the scanner (local `gitleaks` or Docker) cannot run, it blocks the commit instead of skipping silently. The only intentional bypass is `git commit --no-verify`, which the human owns.
180
+ - The **pre-commit hook runs two checks**:
181
+ - **Secret scan** (`safedeps scan secrets --staged`) on every commit, **fail-closed**. If the scanner (local `gitleaks` or Docker) cannot run, it blocks the commit instead of skipping silently.
182
+ - **npm dependency audit** (`safedeps audit npm`) on **every commit** in a repo that has an npm lockfile. This catches a vulnerable direct *or transitive* dependency — including a CVE that was published *after* you installed the package ("looked safe then, flagged now"), the kind of thing a human never reviews by hand. Running it every commit (not only when the lockfile changes) is the point: it re-queries the advisory DB so a newly-disclosed CVE on an already-installed dependency surfaces at the very next commit. The verdict and an availability failure are kept apart: a real finding **blocks** (fail-closed), but if the advisory DB is **unreachable** (offline / registry error) the hook **warns and lets the commit through** — an observable availability failover, never a silent skip. (CI and the daily re-check then re-cover what the offline commit could not verify.)
183
+
184
+ The only intentional bypass is `git commit --no-verify`, which the human owns.
179
185
 
180
186
  The scaffolded `.gitleaks.toml` is a **starter you tune**: it extends gitleaks' default ruleset, adds a rule for a committed `.env` with an assigned secret (the `.env.example`/`.sample`/`.template` variants are allowlisted), and leaves a repo-owned `[allowlist]` block for your fixtures. safedeps owns *execution* — running gitleaks via `safedeps scan secrets` — not the policy content.
181
187
 
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.5.0).
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,33 @@ 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
+
141
+ ---
142
+
143
+ ## v2.5 — pre-commit dependency audit (shipped)
144
+
145
+ Status: shipped as v2.5.0.
146
+
147
+ ### What changed
148
+
149
+ - **Pre-commit dependency audit** — the scaffolded `.githooks/pre-commit` now runs `safedeps audit npm` on **every commit** in a repo with an npm lockfile, alongside the secret scan. It catches a vulnerable direct or *transitive* dependency — including a CVE disclosed *after* the package was installed ("looked safe then, flagged now") — at the next commit, by re-querying the advisory DB instead of waiting for the daily re-check. Real usage drove it: a transitive `hono` advisory that Dependabot missed was caught exactly this way.
150
+ - **Meaningful `audit npm` exit codes** — `0` clean / `1` vulnerable / `2` could-not-run (no lockfile, npm/jq missing, advisory DB unreachable). This separates the **security verdict** from an **availability failure**; npm audit collapses both into exit 1 on its own.
151
+ - **Observable offline failover** — when the advisory DB is unreachable the hook **warns and allows** the commit (exit 2) rather than fail-closing, so a network outage never blocks an offline commit; a real finding (exit 1) still **blocks**. Per the no-silent-fallback invariant the failover is loud (printed to the commit output), and CI / the daily re-check re-cover what the offline commit could not verify.
152
+
153
+ ### Verification
154
+
155
+ - `audit npm` exit-code contract (clean=0 / vulnerable=1 / unreachable=2), deterministic via a fake npm
156
+ - pre-commit blocks a commit carrying a vulnerable dependency; warns + allows when the advisory DB is unreachable
157
+ - existing secret-lane + smoke + e2e regression suite remains green
158
+
136
159
  ---
137
160
 
138
161
  ## v3 (future)
package/SKILL.md CHANGED
@@ -10,7 +10,9 @@ hooks:
10
10
 
11
11
  # Safedeps
12
12
 
13
- Two gates, one skill. You (the agent) are the primary user drive both:
13
+ Two gates, one skill. Safedeps is an agent security skill backed by Claude/Codex hooks and a local CLI. It is not a Codex plugin bundle unless it is later wrapped with a plugin manifest.
14
+
15
+ You (the agent) are the primary user — drive both:
14
16
 
15
17
  - **Install-time gate** — clear every dependency install through an OSV-backed advisory check before it runs.
16
18
  - **Secret-leak gate** — stop a secret or a real `.env` from being committed (per-repo).
@@ -71,7 +73,7 @@ safedeps doctor --fix # scaffold the policy + activate the hooks (non-destruc
71
73
  2. Gaps? Run `safedeps doctor --fix`. It scaffolds `.gitleaks.toml` (or `.gitleaks.private.toml`) and `.githooks/pre-commit`, then activates them. Existing repo files are never overwritten.
72
74
  3. Tune the scaffolded `.gitleaks.toml` for the repo — allowlist fixtures, add rules. You own the policy; safedeps runs it (gitleaks via `safedeps scan secrets`).
73
75
 
74
- The pre-commit hook delegates to `safedeps scan secrets --staged` and is **fail-closed**: no scanner it blocks the commit. The only bypass is the human's `git commit --no-verify`.
76
+ The pre-commit hook runs two checks: a secret scan (`safedeps scan secrets --staged`) on every commit (fail-closed), and an npm dependency audit (`safedeps audit npm`) on every commit in an npm repo — so a CVE published *after* you installed a package is caught at the next commit, not weeks later. `audit npm` exits 0 clean / 1 vulnerable / 2 could-not-run; the hook **blocks** on a real finding (1) but **warns and allows** when the advisory DB is unreachable (2 — observable offline failover). No secret scanner → blocks. The only bypass is the human's `git commit --no-verify`.
75
77
 
76
78
  ---
77
79
 
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.5.0"
11
11
 
12
12
  # ---- repo / lib bootstrap ----------------------------------------------------
13
13
 
@@ -3,7 +3,17 @@ set -euo pipefail
3
3
 
4
4
  # safedeps audit npm — generic npm lockfile audit.
5
5
  # Absorbed from kuma-studio scripts/security/run-npm-audit.sh.
6
- # Missing lockfile stays fail-closed (no reproducible verdict without it).
6
+ #
7
+ # Exit codes are meaningful so a caller (e.g. the pre-commit hook) can tell a
8
+ # security verdict apart from an availability problem — npm audit collapses both
9
+ # into exit 1 on its own:
10
+ # 0 clean — no advisory at or above the audit level
11
+ # 1 vulnerable — at least one advisory at or above the level (BLOCK)
12
+ # 2 could not produce a verdict — no lockfile, npm/jq missing, or the npm
13
+ # advisory database is unreachable (offline/registry error). This is an
14
+ # AVAILABILITY failure, not a clean bill of health; the caller decides
15
+ # whether to fail-closed or warn-and-continue.
16
+ # 64 usage error
7
17
 
8
18
  REPO_ROOT=""
9
19
  AUDIT_LEVEL="${SAFEDEPS_NPM_AUDIT_LEVEL:-${KUMA_NPM_AUDIT_LEVEL:-moderate}}"
@@ -26,11 +36,53 @@ if [ -z "$REPO_ROOT" ]; then REPO_ROOT="$(pwd)"; fi
26
36
  REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
27
37
  cd "$REPO_ROOT"
28
38
 
29
- if [ ! -f package-lock.json ]; then
30
- cat >&2 <<'EOF'
31
- ERROR: package-lock.json is missing, so npm audit cannot produce a reproducible dependency verdict.
32
- EOF
33
- exit 1
39
+ if [ ! -f package-lock.json ] && [ ! -f npm-shrinkwrap.json ]; then
40
+ printf 'safedeps audit: no package-lock.json/npm-shrinkwrap.json — cannot produce a reproducible verdict.\n' >&2
41
+ exit 2
42
+ fi
43
+
44
+ if ! command -v npm >/dev/null 2>&1; then
45
+ printf 'safedeps audit: npm not found — cannot produce a dependency verdict.\n' >&2
46
+ exit 2
34
47
  fi
35
48
 
36
- exec npm audit --audit-level="$AUDIT_LEVEL"
49
+ # Without jq we cannot reliably separate an offline failure from a real finding,
50
+ # so degrade to a plain, fail-closed audit: any non-zero exit blocks (safe).
51
+ if ! command -v jq >/dev/null 2>&1; then
52
+ npm audit --audit-level="$AUDIT_LEVEL"
53
+ exit $?
54
+ fi
55
+
56
+ # One JSON run. npm audit shares exit 1 between "vulnerable" and "could not run",
57
+ # so we read the verdict from the payload, not the exit code.
58
+ audit_json="$(npm audit --json 2>/dev/null || true)"
59
+
60
+ # Unreachable advisory DB / setup error → npm emits a top-level {"error":...},
61
+ # or no/invalid JSON. Availability failure, not a verdict.
62
+ if [ -z "$audit_json" ] \
63
+ || ! printf '%s' "$audit_json" | jq -e . >/dev/null 2>&1 \
64
+ || printf '%s' "$audit_json" | jq -e 'has("error")' >/dev/null 2>&1; then
65
+ printf 'safedeps audit: could not reach the npm advisory database (offline or registry error).\n' >&2
66
+ exit 2
67
+ fi
68
+
69
+ # Count advisories at or above the configured level.
70
+ n_at_level="$(printf '%s' "$audit_json" | jq --arg lvl "$AUDIT_LEVEL" '
71
+ (.metadata.vulnerabilities // {}) as $v
72
+ | ["low","moderate","high","critical"] as $order
73
+ | (($order | index($lvl)) // 1) as $min
74
+ | reduce $order[$min:][] as $s (0; . + ($v[$s] // 0))
75
+ ')"
76
+
77
+ if [ "${n_at_level:-0}" -gt 0 ]; then
78
+ printf '%s' "$audit_json" | jq -r '
79
+ (.metadata.vulnerabilities // {}) as $v
80
+ | " severities: " +
81
+ ([ "critical","high","moderate","low" ]
82
+ | map(select(($v[.] // 0) > 0) | "\($v[.]) \(.)") | join(", "))
83
+ ' >&2
84
+ printf '%s' "$audit_json" | jq -r '(.vulnerabilities // {}) | keys[] | " - " + .' 2>/dev/null | head -20 >&2 || true
85
+ printf 'safedeps audit: %s advisory(ies) at or above "%s" — blocking. Run `npm audit` for the full report.\n' "$n_at_level" "$AUDIT_LEVEL" >&2
86
+ exit 1
87
+ fi
88
+ exit 0
@@ -1,11 +1,17 @@
1
1
  #!/bin/bash
2
- # .githooks/pre-commit — safedeps secret-leak gate (scaffolded by `safedeps hooks init`).
2
+ # .githooks/pre-commit — safedeps repo gate (scaffolded by `safedeps hooks init`).
3
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`.
4
+ # Two checks:
5
+ # 1. Secret scan (always, fail-closed) blocks a commit when gitleaks finds a
6
+ # secret in the STAGED changes, so a key or a real .env never enters
7
+ # history. The detection POLICY lives in this repo's .gitleaks.toml
8
+ # (public) / .gitleaks.private.toml (private); safedeps owns execution.
9
+ # 2. Dependency audit (npm) — on every commit when this repo has an npm
10
+ # lockfile. Catches a vulnerable direct or *transitive* dependency,
11
+ # including a CVE published AFTER you installed it ("looked safe then,
12
+ # flagged now"). A real finding blocks (fail-closed); only the advisory DB
13
+ # being unreachable (offline) warns and lets the commit through — an
14
+ # observable availability failover, never a silent skip.
9
15
  #
10
16
  # Intentional bypass (use sparingly — you own the risk): git commit --no-verify
11
17
  set -euo pipefail
@@ -43,7 +49,30 @@ EOF
43
49
  exit 1
44
50
  fi
45
51
 
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}"
52
+ # 1. Secret scan (always). A non-zero exit means either a secret was found OR
53
+ # no scanner (gitleaks/docker) is available — both fail-closed, block commit.
54
+ "${sd}" scan secrets --staged --root "${repo_root}"
55
+
56
+ # 2. Dependency audit (npm) — every commit when this repo has an npm lockfile.
57
+ # `safedeps audit npm` exit codes: 0 clean / 1 vulnerable / 2 could-not-run.
58
+ # - vulnerable (1) → BLOCK (fail-closed on the security verdict).
59
+ # - unreachable (2) → WARN and ALLOW (availability failover, observable):
60
+ # an offline commit is never silently dropped, and CI / the daily
61
+ # re-check still cover what this commit could not verify.
62
+ if [ -f "${repo_root}/package-lock.json" ] || [ -f "${repo_root}/npm-shrinkwrap.json" ]; then
63
+ if "${sd}" audit npm --root "${repo_root}"; then
64
+ : # clean — no advisory at or above the audit level.
65
+ else
66
+ audit_rc=$?
67
+ if [ "${audit_rc}" -eq 2 ]; then
68
+ {
69
+ printf '\n'
70
+ printf 'safedeps pre-commit: dependency audit could not reach the advisory DB.\n'
71
+ printf ' -> commit ALLOWED without a fresh dependency verdict (offline failover).\n'
72
+ printf ' -> re-run `safedeps audit npm` once online; CI / the daily re-check also cover this.\n'
73
+ } >&2
74
+ else
75
+ exit "${audit_rc}" # vulnerable (or unknown failure) -> fail-closed.
76
+ fi
77
+ fi
78
+ fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aldegad/safedeps",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
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": {
@@ -219,6 +219,51 @@ function unlinkBin() {
219
219
  removeSymlink(join(HOME, ".local", "bin", "safedeps"));
220
220
  }
221
221
 
222
+ function safedepsOnPath() {
223
+ const dirs = (process.env.PATH || "").split(":").filter(Boolean);
224
+ return dirs.some((d) => {
225
+ try { return existsSync(join(d, "safedeps")); } catch { return false; }
226
+ });
227
+ }
228
+
229
+ // The dependency-install gate is global and now active. The rest of the
230
+ // surface (secret-leak lane, dep audit) is per-repo and opt-in — its policy
231
+ // lives in each repo. Nudge with a recommended setup; never auto-write.
232
+ function printRecommendedSetup() {
233
+ const line = "─".repeat(58);
234
+ const out = [];
235
+ out.push("");
236
+ out.push("Recommended setup");
237
+ out.push(line);
238
+ out.push(" 1. Dependency-install gate ........ ✓ active now (global, all repos)");
239
+ out.push(" Every agent install is checked; for npm the installed");
240
+ out.push(" closure is reorged (rolled back) if it diverges.");
241
+ out.push("");
242
+ out.push(" The rest is per-repo and opt-in. Run these INSIDE a repo:");
243
+ out.push("");
244
+ out.push(" 2. Pre-commit gate ............... recommended");
245
+ out.push(" safedeps doctor # diagnose this repo (read-only)");
246
+ out.push(" safedeps doctor --fix # scaffold .gitleaks.toml + pre-commit, then activate");
247
+ out.push(" → every commit: blocks a secret / real .env (fail-closed).");
248
+ out.push(" → every commit (npm repos): audits deps for vulnerable transitives;");
249
+ out.push(" a real finding blocks, an offline advisory DB only warns + allows.");
250
+ out.push("");
251
+ out.push(" 3. Release / CI gate ............. optional");
252
+ out.push(" safedeps gates run # secret scan + npm dep audit + hook/CI check");
253
+ out.push(" → full-repo sweep for CI / pre-release: scans the whole tree (not just");
254
+ out.push(" the staged diff) and verifies the hooks themselves are installed.");
255
+ out.push("");
256
+ out.push(" Docs: README → \"Two Lanes\"");
257
+ if (!safedepsOnPath()) {
258
+ out.push("");
259
+ out.push(" Note: `safedeps` is not on your PATH. To run the commands above:");
260
+ out.push(" - re-run this installer with --link-bin (adds ~/.local/bin/safedeps), or");
261
+ out.push(" - use the full path: ~/.claude/skills/safedeps/bin/safedeps");
262
+ }
263
+ out.push("");
264
+ console.log(out.join("\n"));
265
+ }
266
+
222
267
  function main() {
223
268
  if (!existsSync(REPO_PRE_HOOK) || !existsSync(REPO_POST_HOOK)) {
224
269
  throw new Error(`hook scripts not found at ${REPO_PRE_HOOK} / ${REPO_POST_HOOK}`);
@@ -242,9 +287,7 @@ function main() {
242
287
  log("uninstall done.");
243
288
  } else {
244
289
  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).");
290
+ printRecommendedSetup();
248
291
  }
249
292
  }
250
293
 
@@ -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"
@@ -328,4 +328,64 @@ else
328
328
  printf 'ok - pre-commit gate behavior SKIPPED (needs gitleaks + openssl)\n'
329
329
  fi
330
330
 
331
+ # --- Dependency audit gate (npm) — v2.5.0 -----------------------------------
332
+ # A fake `npm` makes the crucial distinction deterministic and offline: a
333
+ # vulnerable verdict (block) must never be confused with an unreachable advisory
334
+ # DB (warn + allow). If those two collapsed, an offline failover would silently
335
+ # let real vulnerabilities through.
336
+ fakebin="${tmp_root}/fakebin"
337
+ mkdir -p "${fakebin}"
338
+ cat > "${fakebin}/npm" <<'FAKE'
339
+ #!/bin/bash
340
+ [ "${1:-}" = "audit" ] || exit 0
341
+ case "${FAKE_NPM_MODE:-clean}" in
342
+ clean) printf '%s\n' '{"auditReportVersion":2,"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0,"total":0}}}'; exit 0 ;;
343
+ vuln) printf '%s\n' '{"auditReportVersion":2,"vulnerabilities":{"hono":{"name":"hono","severity":"moderate","via":[{"title":"JWT"}]}},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":4,"high":0,"critical":0,"total":4}}}'; exit 1 ;;
344
+ offline) printf '%s\n' '{"error":{"code":"ENOTFOUND","summary":"registry unreachable"}}'; exit 1 ;;
345
+ esac
346
+ FAKE
347
+ chmod +x "${fakebin}/npm"
348
+
349
+ if command -v jq >/dev/null 2>&1; then
350
+ audit_repo="${tmp_root}/audit-repo"
351
+ mkdir -p "${audit_repo}"
352
+ printf '{"name":"a","lockfileVersion":3}\n' > "${audit_repo}/package-lock.json"
353
+ run_audit() {
354
+ PATH="${fakebin}:${PATH}" FAKE_NPM_MODE="$1" \
355
+ "${ROOT_DIR}/bin/safedeps" audit npm --root "${audit_repo}" >/dev/null 2>&1
356
+ }
357
+ run_audit clean && rc=0 || rc=$?; [ "${rc}" = "0" ] || fail "audit exit 0 on a clean lockfile (got ${rc})"
358
+ run_audit vuln && rc=0 || rc=$?; [ "${rc}" = "1" ] || fail "audit exit 1 on a vulnerable lockfile (got ${rc})"
359
+ run_audit offline && rc=0 || rc=$?; [ "${rc}" = "2" ] || fail "audit exit 2 when the advisory DB is unreachable (got ${rc})"
360
+ pass "audit npm exit-code contract: clean=0 / vulnerable=1 / unreachable=2"
361
+ else
362
+ printf 'ok - audit exit-code contract SKIPPED (needs jq)\n'
363
+ fi
364
+
365
+ if command -v gitleaks >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then
366
+ dep_repo="${tmp_root}/dep-repo"
367
+ mkdir -p "${dep_repo}"
368
+ git -C "${dep_repo}" init -q
369
+ git -C "${dep_repo}" config user.email t@safedeps.test
370
+ git -C "${dep_repo}" config user.name safedeps-e2e
371
+ HOME="${tmp_root}/doc-home" "${ROOT_DIR}/bin/safedeps" doctor --fix --root "${dep_repo}" >/dev/null
372
+ printf '{"name":"a","lockfileVersion":3}\n' > "${dep_repo}/package-lock.json"
373
+ git -C "${dep_repo}" add package-lock.json
374
+
375
+ # Threat: a vulnerable dependency must BLOCK the commit (fail-closed verdict).
376
+ if PATH="${fakebin}:${PATH}" FAKE_NPM_MODE=vuln SAFEDEPS_BIN="${ROOT_DIR}/bin/safedeps" \
377
+ git -C "${dep_repo}" commit -q -m "vuln" 2>/dev/null; then
378
+ fail "pre-commit blocks a commit carrying a vulnerable dependency"
379
+ fi
380
+
381
+ # Availability failover: an unreachable advisory DB must WARN and ALLOW.
382
+ offline_out="$(PATH="${fakebin}:${PATH}" FAKE_NPM_MODE=offline SAFEDEPS_BIN="${ROOT_DIR}/bin/safedeps" \
383
+ git -C "${dep_repo}" commit -m "offline" 2>&1)" \
384
+ || fail "pre-commit allows the commit when the advisory DB is unreachable (offline failover)"
385
+ grep -q "offline failover" <<< "${offline_out}" || fail "offline failover prints an observable warning"
386
+ pass "pre-commit dep gate: blocks on vuln, warns+allows when offline"
387
+ else
388
+ printf 'ok - pre-commit dep gate SKIPPED (needs gitleaks + jq)\n'
389
+ fi
390
+
331
391
  printf 'e2e passed\n'
@@ -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"