@aldegad/safedeps 2.2.0 → 2.4.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.
@@ -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.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": {
@@ -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
@@ -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
@@ -438,7 +461,7 @@ fi
438
461
  # Conservative: only block when at least one pkg@spec token is parseable. Bare
439
462
  # `npm install` (lockfile install) falls through to the v1 reorg checks.
440
463
 
441
- SAFEDEPS_LEDGER_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh"
464
+ SAFEDEPS_LEDGER_LIB="${SAFEDEPS_LEDGER_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh}"
442
465
  SAFEDEPS_REPO_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/safedeps"
443
466
 
444
467
  guard_detect_ecosystem() {
@@ -596,7 +619,16 @@ while IFS= read -r ledger_spec_line; do
596
619
  LEDGER_SPECS+=("${ledger_spec_line}")
597
620
  done < <(guard_extract_specs "${COMMAND}")
598
621
 
599
- if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 && -f "${SAFEDEPS_LEDGER_LIB}" ]]; then
622
+ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 ]]; then
623
+ if [[ ! -f "${SAFEDEPS_LEDGER_LIB}" ]]; then
624
+ # The ledger library is the gate for direct install specs. If it is missing
625
+ # (broken install / moved repo) the gate cannot run — fail CLOSED, observably,
626
+ # instead of falling through to allow.
627
+ log_advisory "pre-guard DENY: ledger library missing (${SAFEDEPS_LEDGER_LIB}) — cannot enforce ${LEDGER_ECOSYSTEM} install, fail-closed."
628
+ jq -nc --arg eco "${LEDGER_ECOSYSTEM}" \
629
+ '{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")}}'
630
+ exit 0
631
+ fi
600
632
  # shellcheck source=../lib/ledger/ledger.sh
601
633
  source "${SAFEDEPS_LEDGER_LIB}"
602
634
 
@@ -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'