@aldegad/safedeps 2.1.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aldegad/safedeps",
3
- "version": "2.1.1",
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": {
@@ -11,18 +11,20 @@
11
11
  // node scripts/install/install-safedeps-hooks.mjs --uninstall
12
12
  // node scripts/install/install-safedeps-hooks.mjs --link-bin (optional ~/.local/bin/safedeps)
13
13
 
14
- import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, symlinkSync, unlinkSync, readlinkSync } from "node:fs";
14
+ import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, symlinkSync, unlinkSync, readlinkSync, renameSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
- import { dirname, join, resolve } from "node:path";
16
+ import { basename, dirname, join, resolve } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
19
  const HERE = dirname(fileURLToPath(import.meta.url));
20
20
  const REPO_ROOT = resolve(HERE, "..", "..");
21
- const HOME = homedir();
21
+ const HOME = process.env.HOME || homedir();
22
22
 
23
23
  const SKILL_ID = "safedeps";
24
- const PRE_HOOK = join(REPO_ROOT, "scripts", "safedeps-pre-guard.sh");
25
- const POST_HOOK = join(REPO_ROOT, "scripts", "safedeps-post-verify.sh");
24
+ const PRE_HOOK_NAME = "safedeps-pre-guard.sh";
25
+ const POST_HOOK_NAME = "safedeps-post-verify.sh";
26
+ const REPO_PRE_HOOK = join(REPO_ROOT, "scripts", PRE_HOOK_NAME);
27
+ const REPO_POST_HOOK = join(REPO_ROOT, "scripts", POST_HOOK_NAME);
26
28
  const CLI_BIN = join(REPO_ROOT, "bin", "safedeps");
27
29
 
28
30
  const args = new Set(process.argv.slice(2));
@@ -71,7 +73,14 @@ function writeJsonWithBackup(path, value) {
71
73
  } else {
72
74
  mkdirSync(dirname(path), { recursive: true });
73
75
  }
74
- writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
76
+ const tmpPath = `${path}.tmp.${process.pid}.${Date.now()}`;
77
+ writeFileSync(tmpPath, JSON.stringify(value, null, 2) + "\n");
78
+ renameSync(tmpPath, path);
79
+ }
80
+
81
+ function engineHookCommand(engineRoot, hookName) {
82
+ const engineName = basename(engineRoot).replace(/^\./u, "");
83
+ return `~/.${engineName}/skills/${SKILL_ID}/scripts/${hookName}`;
75
84
  }
76
85
 
77
86
  function ensureHook(config, eventName, command) {
@@ -79,6 +88,13 @@ function ensureHook(config, eventName, command) {
79
88
  config.hooks[eventName] = config.hooks[eventName] ?? [];
80
89
  const buckets = config.hooks[eventName];
81
90
 
91
+ const already = buckets.some((bucket) =>
92
+ bucket?.matcher === "Bash" &&
93
+ Array.isArray(bucket?.hooks) &&
94
+ bucket.hooks.some((h) => h && h.type === "command" && h.command === command),
95
+ );
96
+ if (already) return false;
97
+
82
98
  let bashBucket = buckets.find((b) => b && b.matcher === "Bash");
83
99
  if (!bashBucket) {
84
100
  bashBucket = { matcher: "Bash", hooks: [] };
@@ -86,9 +102,6 @@ function ensureHook(config, eventName, command) {
86
102
  }
87
103
  bashBucket.hooks = bashBucket.hooks ?? [];
88
104
 
89
- const already = bashBucket.hooks.some((h) => h && h.type === "command" && h.command === command);
90
- if (already) return false;
91
-
92
105
  bashBucket.hooks.push({ type: "command", command });
93
106
  return true;
94
107
  }
@@ -106,25 +119,49 @@ function removeHook(config, eventName, command) {
106
119
  return changed;
107
120
  }
108
121
 
109
- function pruneLegacySafedepsHooks(config, eventName) {
122
+ function isSafedepsHookCommand(command, hookName) {
123
+ if (typeof command !== "string") return false;
124
+ const normalized = command.replace(/\\/gu, "/");
125
+ if (normalized.includes("npm-reorg-guard")) return true;
126
+ return normalized.includes("/safedeps/") && normalized.endsWith(`/scripts/${hookName}`);
127
+ }
128
+
129
+ function pruneNonCanonicalSafedepsHooks(config, eventName, canonicalCommand, hookName) {
110
130
  const buckets = config?.hooks?.[eventName];
111
131
  if (!Array.isArray(buckets)) return false;
112
132
  let changed = false;
133
+ let seenCanonical = false;
113
134
  for (const bucket of buckets) {
114
- if (!bucket || bucket.matcher !== "Bash" || !Array.isArray(bucket.hooks)) continue;
135
+ if (!bucket || !Array.isArray(bucket.hooks)) continue;
115
136
  const before = bucket.hooks.length;
116
137
  bucket.hooks = bucket.hooks.filter((h) => {
117
138
  const command = h?.command;
118
- if (typeof command !== "string") return true;
119
- const legacySafedeps = command.includes("npm-reorg-guard") || command.includes("/safedeps/");
120
- const legacyHookName = command.endsWith("/scripts/guard.sh") || command.endsWith("/scripts/verify.sh");
121
- return !(legacySafedeps && legacyHookName);
139
+ if (command === canonicalCommand) {
140
+ if (bucket.matcher !== "Bash") return false;
141
+ if (seenCanonical) return false;
142
+ seenCanonical = true;
143
+ return true;
144
+ }
145
+ return command === canonicalCommand || !isSafedepsHookCommand(command, hookName);
122
146
  });
123
147
  if (bucket.hooks.length !== before) changed = true;
124
148
  }
125
149
  return changed;
126
150
  }
127
151
 
152
+ function pruneAllSafedepsHooks(config, eventName, hookName) {
153
+ const buckets = config?.hooks?.[eventName];
154
+ if (!Array.isArray(buckets)) return false;
155
+ let changed = false;
156
+ for (const bucket of buckets) {
157
+ if (!bucket || !Array.isArray(bucket.hooks)) continue;
158
+ const before = bucket.hooks.length;
159
+ bucket.hooks = bucket.hooks.filter((h) => !isSafedepsHookCommand(h?.command, hookName));
160
+ if (bucket.hooks.length !== before) changed = true;
161
+ }
162
+ return changed;
163
+ }
164
+
128
165
  function installInEngine({ engineRoot, configPath, label }) {
129
166
  if (!existsSync(engineRoot)) {
130
167
  warn(`skip ${label} (${engineRoot} not present)`);
@@ -132,13 +169,15 @@ function installInEngine({ engineRoot, configPath, label }) {
132
169
  }
133
170
  const skillsRoot = join(engineRoot, "skills");
134
171
  const skillLink = join(skillsRoot, SKILL_ID);
172
+ const preCommand = engineHookCommand(engineRoot, PRE_HOOK_NAME);
173
+ const postCommand = engineHookCommand(engineRoot, POST_HOOK_NAME);
135
174
 
136
175
  if (UNINSTALL) {
137
176
  removeSymlink(skillLink);
138
177
  if (existsSync(configPath)) {
139
178
  const cfg = readJson(configPath);
140
- const pre = removeHook(cfg, "PreToolUse", PRE_HOOK);
141
- const post = removeHook(cfg, "PostToolUse", POST_HOOK);
179
+ const pre = removeHook(cfg, "PreToolUse", preCommand) || pruneAllSafedepsHooks(cfg, "PreToolUse", PRE_HOOK_NAME);
180
+ const post = removeHook(cfg, "PostToolUse", postCommand) || pruneAllSafedepsHooks(cfg, "PostToolUse", POST_HOOK_NAME);
142
181
  if (pre || post) {
143
182
  writeJsonWithBackup(configPath, cfg);
144
183
  log(`patched ${configPath} (removed safedeps hooks)`);
@@ -152,10 +191,10 @@ function installInEngine({ engineRoot, configPath, label }) {
152
191
  ensureSymlink(REPO_ROOT, skillLink);
153
192
 
154
193
  const cfg = readJson(configPath);
155
- const legacyPreRemoved = pruneLegacySafedepsHooks(cfg, "PreToolUse");
156
- const legacyPostRemoved = pruneLegacySafedepsHooks(cfg, "PostToolUse");
157
- const preAdded = ensureHook(cfg, "PreToolUse", PRE_HOOK);
158
- const postAdded = ensureHook(cfg, "PostToolUse", POST_HOOK);
194
+ const legacyPreRemoved = pruneNonCanonicalSafedepsHooks(cfg, "PreToolUse", preCommand, PRE_HOOK_NAME);
195
+ const legacyPostRemoved = pruneNonCanonicalSafedepsHooks(cfg, "PostToolUse", postCommand, POST_HOOK_NAME);
196
+ const preAdded = ensureHook(cfg, "PreToolUse", preCommand);
197
+ const postAdded = ensureHook(cfg, "PostToolUse", postCommand);
159
198
  if (legacyPreRemoved || legacyPostRemoved || preAdded || postAdded) {
160
199
  writeJsonWithBackup(configPath, cfg);
161
200
  log(`patched ${configPath} (pre=${preAdded ? "added" : "ok"}, post=${postAdded ? "added" : "ok"}, legacy=${legacyPreRemoved || legacyPostRemoved ? "removed" : "ok"})`);
@@ -181,8 +220,8 @@ function unlinkBin() {
181
220
  }
182
221
 
183
222
  function main() {
184
- if (!existsSync(PRE_HOOK) || !existsSync(POST_HOOK)) {
185
- throw new Error(`hook scripts not found at ${PRE_HOOK} / ${POST_HOOK}`);
223
+ if (!existsSync(REPO_PRE_HOOK) || !existsSync(REPO_POST_HOOK)) {
224
+ throw new Error(`hook scripts not found at ${REPO_PRE_HOOK} / ${REPO_POST_HOOK}`);
186
225
  }
187
226
 
188
227
  installInEngine({
@@ -203,6 +242,9 @@ function main() {
203
242
  log("uninstall done.");
204
243
  } else {
205
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).");
206
248
  }
207
249
  }
208
250
 
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT=""
5
+ STRICT=0
6
+ NO_RUN=0
7
+
8
+ usage() {
9
+ cat <<'EOF'
10
+ Usage: run-release-gates.sh [--root <repo>] [--strict] [--no-run]
11
+
12
+ Runs release-time security gates for the current repository tree.
13
+ EOF
14
+ }
15
+
16
+ while [ $# -gt 0 ]; do
17
+ case "$1" in
18
+ --root)
19
+ ROOT="${2:-}"
20
+ shift 2
21
+ ;;
22
+ --strict)
23
+ STRICT=1
24
+ shift
25
+ ;;
26
+ --no-run)
27
+ NO_RUN=1
28
+ shift
29
+ ;;
30
+ -h|--help)
31
+ usage
32
+ exit 0
33
+ ;;
34
+ *)
35
+ usage >&2
36
+ exit 64
37
+ ;;
38
+ esac
39
+ done
40
+
41
+ if [ -z "$ROOT" ]; then
42
+ ROOT="$(pwd)"
43
+ fi
44
+
45
+ if command -v realpath >/dev/null 2>&1; then
46
+ ROOT="$(realpath "$ROOT")"
47
+ else
48
+ ROOT="$(cd "$ROOT" && pwd)"
49
+ fi
50
+
51
+ if [ ! -d "$ROOT" ]; then
52
+ printf 'ERROR: repo root does not exist: %s\n' "$ROOT" >&2
53
+ exit 1
54
+ fi
55
+
56
+ cd "$ROOT"
57
+
58
+ FAILURES=0
59
+ WARNINGS=0
60
+ RAN=0
61
+
62
+ section() {
63
+ printf '\n== %s ==\n' "$1"
64
+ }
65
+
66
+ pass() {
67
+ printf 'PASS [%s] %s\n' "$1" "$2"
68
+ }
69
+
70
+ warn() {
71
+ WARNINGS=$((WARNINGS + 1))
72
+ printf 'WARN [%s] %s\n' "$1" "$2" >&2
73
+ }
74
+
75
+ fail() {
76
+ FAILURES=$((FAILURES + 1))
77
+ printf 'FAIL [%s] %s\n' "$1" "$2" >&2
78
+ }
79
+
80
+ strict_or_warn() {
81
+ if [ "$STRICT" -eq 1 ]; then
82
+ fail "$1" "$2"
83
+ else
84
+ warn "$1" "$2"
85
+ fi
86
+ }
87
+
88
+ run_cmd() {
89
+ local gate="$1"
90
+ local desc="$2"
91
+ shift 2
92
+
93
+ RAN=$((RAN + 1))
94
+ printf 'RUN [%s] %s\n' "$gate" "$desc"
95
+ printf 'CMD [%s] %s\n' "$gate" "$*"
96
+
97
+ if [ "$NO_RUN" -eq 1 ]; then
98
+ pass "$gate" "planned only (--no-run)"
99
+ return 0
100
+ fi
101
+
102
+ if "$@"; then
103
+ pass "$gate" "$desc"
104
+ else
105
+ fail "$gate" "$desc"
106
+ fi
107
+ }
108
+
109
+ has_file() {
110
+ [ -f "$1" ]
111
+ }
112
+
113
+ has_npm_script() {
114
+ local script_name="$1"
115
+ has_file package.json || return 1
116
+ command -v node >/dev/null 2>&1 || return 1
117
+ node -e '
118
+ const fs = require("node:fs");
119
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
120
+ process.exit(pkg.scripts && Object.prototype.hasOwnProperty.call(pkg.scripts, process.argv[1]) ? 0 : 1);
121
+ ' "$script_name"
122
+ }
123
+
124
+ run_npm_script_if_present() {
125
+ local script_name="$1"
126
+ local gate="$2"
127
+ if has_npm_script "$script_name"; then
128
+ run_cmd "$gate" "npm run $script_name" npm run "$script_name"
129
+ return 0
130
+ fi
131
+ return 1
132
+ }
133
+
134
+ detect_python_surface() {
135
+ find . -maxdepth 3 \
136
+ \( -name 'requirements*.txt' -o -name 'pyproject.toml' -o -name 'poetry.lock' -o -name 'uv.lock' -o -name 'Pipfile.lock' \) \
137
+ -not -path './node_modules/*' \
138
+ -not -path './.git/*' \
139
+ -print
140
+ }
141
+
142
+ detect_requirements_files() {
143
+ find . -maxdepth 3 \
144
+ -name 'requirements*.txt' \
145
+ -not -path './node_modules/*' \
146
+ -not -path './.git/*' \
147
+ -print | sort
148
+ }
149
+
150
+ hook_file_mentions_reorg_guard() {
151
+ local file="$1"
152
+ [ -f "$file" ] || return 1
153
+ grep -q 'safedeps' "$file"
154
+ }
155
+
156
+ safedeps_install_guard_present() {
157
+ [ -d "$HOME/.claude/skills/safedeps" ] && return 0
158
+ [ -d "$HOME/.codex/skills/safedeps" ] && return 0
159
+ hook_file_mentions_reorg_guard "$HOME/.claude/settings.json" && return 0
160
+ hook_file_mentions_reorg_guard "$HOME/.codex/hooks.json" && return 0
161
+ return 1
162
+ }
163
+
164
+ section "repo"
165
+ printf 'root: %s\n' "$ROOT"
166
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
167
+ pass repo "inside git worktree"
168
+ else
169
+ strict_or_warn repo "not inside a git worktree"
170
+ fi
171
+
172
+ if [ -f docs/security-release-gates.md ] || [ -f SECURITY.md ]; then
173
+ pass repo "release/security documentation present"
174
+ else
175
+ warn repo "no docs/security-release-gates.md or SECURITY.md"
176
+ fi
177
+
178
+ section "secrets"
179
+ if run_npm_script_if_present "security:hooks:check" "secrets"; then
180
+ :
181
+ fi
182
+ if run_npm_script_if_present "security:scan:worktree" "secrets"; then
183
+ :
184
+ elif [ -x scripts/security/run-gitleaks.sh ]; then
185
+ run_cmd secrets "repo gitleaks wrapper" bash scripts/security/run-gitleaks.sh --worktree
186
+ elif command -v gitleaks >/dev/null 2>&1 && { [ -f .gitleaks.toml ] || [ -f gitleaks.toml ]; }; then
187
+ config=".gitleaks.toml"
188
+ [ -f "$config" ] || config="gitleaks.toml"
189
+ run_cmd secrets "gitleaks dir scan" gitleaks dir --no-banner --redact --verbose --config "$config" .
190
+ else
191
+ strict_or_warn secrets "no gitleaks gate detected"
192
+ fi
193
+
194
+ section "node"
195
+ if has_file package.json; then
196
+ pass node "package.json detected"
197
+ if run_npm_script_if_present "security:audit" "node"; then
198
+ :
199
+ elif has_file package-lock.json || has_file npm-shrinkwrap.json; then
200
+ run_cmd node "npm audit --audit-level=moderate" npm audit --audit-level=moderate
201
+ else
202
+ strict_or_warn node "package.json exists but no npm lockfile/audit script was detected"
203
+ fi
204
+
205
+ if safedeps_install_guard_present; then
206
+ pass install-guard "safedeps appears installed/configured"
207
+ elif [ "$STRICT" -eq 1 ] || [ "${SECURITY_RELEASE_GATES_REQUIRE_INSTALL_GUARD:-0}" = "1" ]; then
208
+ fail install-guard "npm project has no detectable safedeps install-time guard"
209
+ else
210
+ warn install-guard "safedeps not detected; release gate can continue, install-time guard is separate"
211
+ fi
212
+ else
213
+ pass node "no package.json detected"
214
+ fi
215
+
216
+ section "python"
217
+ PYTHON_SURFACE="$(detect_python_surface || true)"
218
+ if [ -z "$PYTHON_SURFACE" ]; then
219
+ pass python "no Python dependency surface detected"
220
+ elif [ -n "${SECURITY_RELEASE_GATES_PYTHON_AUDIT_COMMAND:-}" ]; then
221
+ run_cmd python "custom Python audit command" bash -lc "$SECURITY_RELEASE_GATES_PYTHON_AUDIT_COMMAND"
222
+ elif command -v pip-audit >/dev/null 2>&1; then
223
+ REQUIREMENTS="$(detect_requirements_files || true)"
224
+ if [ -n "$REQUIREMENTS" ]; then
225
+ while IFS= read -r requirements_file; do
226
+ [ -n "$requirements_file" ] || continue
227
+ run_cmd python "pip-audit $requirements_file" pip-audit -r "$requirements_file"
228
+ done <<EOF_REQ
229
+ $REQUIREMENTS
230
+ EOF_REQ
231
+ else
232
+ strict_or_warn python "Python lock/project files detected, but no requirements*.txt or repo-provided Python audit command exists"
233
+ fi
234
+ else
235
+ strict_or_warn python "Python dependency files detected, but pip-audit is not installed and no custom audit command was provided"
236
+ fi
237
+
238
+ section "ci"
239
+ if find .github/workflows -maxdepth 1 -type f 2>/dev/null | xargs grep -E 'security:|gitleaks|pip-audit|npm audit' >/dev/null 2>&1; then
240
+ pass ci "workflow appears to run security gates"
241
+ else
242
+ warn ci "no obvious GitHub security gate workflow detected"
243
+ fi
244
+
245
+ section "summary"
246
+ printf 'gates_run=%s warnings=%s failures=%s strict=%s no_run=%s\n' "$RAN" "$WARNINGS" "$FAILURES" "$STRICT" "$NO_RUN"
247
+
248
+ if [ "$FAILURES" -gt 0 ]; then
249
+ exit 1
250
+ fi
251
+
252
+ exit 0