dw-kit 1.3.0 → 1.3.5

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/src/cli.mjs CHANGED
@@ -101,6 +101,33 @@ export function run(argv) {
101
101
  await dashboardCommand(opts);
102
102
  });
103
103
 
104
+ program
105
+ .command('security-scan')
106
+ .description('Scan project lockfile against advisory snapshot (OSV.dev). Supply-chain guard (ADR-0005, opt-in).')
107
+ .option('--quick', 'Offline mode — use existing snapshot only (default behavior)')
108
+ .option('--update-db', 'Fetch fresh advisory snapshot from OSV.dev before scanning')
109
+ .option('--pre-install', 'Scan package.json without lockfile (OSV name-only + namespace fixture)')
110
+ .option('--offline', 'Skip network in --pre-install mode (fixture-only)')
111
+ .option('--json', 'Output machine-readable JSON')
112
+ .option('--install-hook', 'Wire supply-chain-scan.sh into .claude/settings.json PostToolUse (idempotent)')
113
+ .option('--uninstall-hook', 'Remove supply-chain-scan.sh entry from .claude/settings.json')
114
+ .action(async (opts) => {
115
+ if (opts.installHook || opts.uninstallHook) {
116
+ const { installHookInProject, uninstallHookFromProject } = await import('./lib/sc-install.mjs');
117
+ const r = opts.uninstallHook ? uninstallHookFromProject() : installHookInProject();
118
+ if (!r.ok) {
119
+ console.error(chalk.red(`✗ ${r.error}`));
120
+ process.exit(1);
121
+ }
122
+ if (r.action === 'added') console.log(chalk.green(`✓ Hook wired into ${r.path}`));
123
+ else if (r.action === 'removed') console.log(chalk.green(`✓ Removed ${r.count} entry from ${r.path}`));
124
+ else console.log(chalk.dim(` (no-op: ${r.reason})`));
125
+ return;
126
+ }
127
+ const { securityScanCommand } = await import('./commands/security-scan.mjs');
128
+ await securityScanCommand(opts);
129
+ });
130
+
104
131
  program
105
132
  .command('claude-vn-fix')
106
133
  .description('Patch Claude CLI to fix Vietnamese IME (local, with backup/restore)')
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { header, ok, warn, err, info, log } from '../lib/ui.mjs';
5
5
  import { loadConfig, getToolkitVersions } from '../lib/config.mjs';
6
6
  import { detectPlatform, platformLabel } from '../lib/platform.mjs';
7
+ import { snapshotInfo } from '../lib/sc-sync.mjs';
7
8
 
8
9
  const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
9
10
 
@@ -150,6 +151,26 @@ export async function doctorCommand() {
150
151
  }
151
152
  }
152
153
 
154
+ info('Supply-Chain Guard (ADR-0005, opt-in)');
155
+ const sc = snapshotInfo(projectDir);
156
+ if (!sc.exists) {
157
+ log(' Advisory snapshot — not yet created');
158
+ log(' Run `dw security-scan --update-db` to fetch from OSV.dev (opt-in feature)');
159
+ } else {
160
+ if (!sc.schema_compatible) {
161
+ err(`Advisory snapshot schema mismatch — expected 1.0, got ${sc.schema_version || 'unknown'}`);
162
+ log(' Run `dw security-scan --update-db` to refresh');
163
+ issues++;
164
+ } else if (sc.stale) {
165
+ warn(`Advisory snapshot stale: ${sc.age_days.toFixed(1)} days old (>7d threshold)`);
166
+ log(` Source: ${sc.source} (${sc.ecosystem}), advisories=${sc.advisory_count}`);
167
+ log(' Run `dw security-scan --update-db` to refresh');
168
+ warnings++;
169
+ } else {
170
+ ok(`Advisory snapshot — ${sc.age_days.toFixed(1)}d old, ${sc.advisory_count} advisories (${sc.source})`);
171
+ }
172
+ }
173
+
153
174
  console.log();
154
175
  header('Diagnosis');
155
176
  if (issues === 0 && warnings === 0) {
@@ -5,6 +5,7 @@ import { banner, ok, warn, info, log, ask, choose } from '../lib/ui.mjs';
5
5
  import { buildConfig, writeConfig } from '../lib/config.mjs';
6
6
  import { copyDir, copyFile, ensureDir } from '../lib/copy.mjs';
7
7
  import { detectPlatform, platformLabel } from '../lib/platform.mjs';
8
+ import { ensureDwGitignore, ensureClaudeGitignore } from '../lib/gitignore.mjs';
8
9
 
9
10
  const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
10
11
 
@@ -82,7 +83,7 @@ export async function initCommand(opts) {
82
83
  ok(`Platform: ${platformLabel(adapter)}`);
83
84
 
84
85
  info('Setting up project...');
85
- await setupProject(projectDir, { projectName, depth, roles, language, adapter });
86
+ await setupProject(projectDir, { projectName, depth, roles, language, adapter, presetKey: opts.preset });
86
87
 
87
88
  printSummary({ projectName, depth, roles, language, adapter });
88
89
  }
@@ -135,7 +136,7 @@ function normalizeRolesForDepth(parsedRoles, depth) {
135
136
  return merged;
136
137
  }
137
138
 
138
- async function setupProject(projectDir, { projectName, depth, roles, language, adapter }) {
139
+ async function setupProject(projectDir, { projectName, depth, roles, language, adapter, presetKey }) {
139
140
  copyCoreDocs(projectDir);
140
141
  copyConfig(projectDir, { projectName, depth, roles, language });
141
142
  copyAdapterStructure(projectDir);
@@ -143,6 +144,7 @@ async function setupProject(projectDir, { projectName, depth, roles, language, a
143
144
  if (adapter === 'claude-cli') {
144
145
  copyClaudeFiles(projectDir);
145
146
  createMinimalCLAUDEmd(projectDir, projectName);
147
+ await maybeInstallSupplyChainHook(projectDir, presetKey);
146
148
  } else if (adapter === 'cursor') {
147
149
  copyCursorFiles(projectDir);
148
150
  copyGenericAdapter(projectDir);
@@ -152,6 +154,47 @@ async function setupProject(projectDir, { projectName, depth, roles, language, a
152
154
 
153
155
  createRuntimeDirs(projectDir);
154
156
  updateGitignore(projectDir);
157
+ writeScopedGitignores(projectDir, adapter);
158
+ }
159
+
160
+ function writeScopedGitignores(projectDir, adapter) {
161
+ try {
162
+ const dwR = ensureDwGitignore(projectDir);
163
+ if (dwR.action !== 'noop') ok(`.dw/.gitignore ${dwR.action}`);
164
+ if (adapter === 'claude-cli') {
165
+ const cR = ensureClaudeGitignore(projectDir);
166
+ if (cR.action !== 'noop') ok(`.claude/.gitignore ${cR.action}`);
167
+ }
168
+ } catch (e) {
169
+ warn(`Scoped gitignore: ${e.message}`);
170
+ }
171
+ }
172
+
173
+ async function maybeInstallSupplyChainHook(projectDir, presetKey) {
174
+ const preset = presetKey ? PRESETS[presetKey] : null;
175
+ const { installHookInProject, uninstallHookFromProject } = await import('../lib/sc-install.mjs');
176
+
177
+ if (preset && preset.hooksProfile === 'safety-only') {
178
+ // Solo preset — explicitly uninstall hook even if template provided it (TW5: opt-in OFF)
179
+ const result = uninstallHookFromProject(projectDir);
180
+ if (result.ok && result.action === 'removed') {
181
+ log(' Supply-chain guard: hook removed from settings (solo preset — opt-in OFF per ADR-0005 TW5)');
182
+ } else {
183
+ log(' Supply-chain guard: skipped (solo preset — opt-in OFF per ADR-0005 TW5)');
184
+ }
185
+ log(' Enable later: `dw security-scan --install-hook`');
186
+ return;
187
+ }
188
+
189
+ const result = installHookInProject(projectDir);
190
+ if (!result.ok) {
191
+ warn(`Supply-chain hook wiring skipped: ${result.error}`);
192
+ return;
193
+ }
194
+ if (result.action === 'added') {
195
+ ok('Supply-chain guard hook wired (ADR-0005 — opt-in flag enabled)');
196
+ log(' First scan: `dw security-scan --update-db`');
197
+ }
155
198
  }
156
199
 
157
200
  function copyCoreDocs(projectDir) {
@@ -1,5 +1,5 @@
1
1
  import { readEvents, summarize } from '../lib/telemetry.mjs';
2
- import { analyze, THRESHOLDS } from '../lib/cut-analysis.mjs';
2
+ import { analyze, analyzeTaskDocs, THRESHOLDS, TASK_DOC_THRESHOLDS } from '../lib/cut-analysis.mjs';
3
3
  import { banner, log, info, warn, ok, err } from '../lib/ui.mjs';
4
4
  import chalk from 'chalk';
5
5
 
@@ -162,4 +162,24 @@ function cutAnalysisReport(opts) {
162
162
  log(` Hook: avg_latency > ${THRESHOLDS.hook.maxAvgLatencyMs}ms OR fires/session > ${THRESHOLDS.hook.maxFiresPerSession}`);
163
163
  log('');
164
164
  info('Caveat: "devs" proxied by unique session hashes — undercounts real headcount.');
165
+
166
+ // Task doc health — invalidation trigger for 3→2 file consolidation (ADR-0001)
167
+ const td = analyzeTaskDocs(process.cwd());
168
+ if (td && td.totalTasks > 0) {
169
+ log('');
170
+ log(chalk.bold('Task Doc Health (ADR-0001 invalidation signal for 3→2 consolidation)'));
171
+ log(` Tasks total: ${td.totalTasks} (v2=${td.v2Count}, v1=${td.v1Count})`);
172
+ log(` tracking.md lines: avg=${td.avgTrackingLines} max=${td.maxTrackingLines}`);
173
+ log(` Tasks with ≥3 md files: ${td.extraFilesCount} (${td.extraFilesPct}%)`);
174
+ if (td.triggers.length === 0) {
175
+ ok('No task-doc invalidation triggers fired — 3→2 consolidation holding.');
176
+ } else {
177
+ for (const t of td.triggers) {
178
+ err(t);
179
+ }
180
+ }
181
+ log('');
182
+ info('Task doc thresholds:');
183
+ log(` avg_tracking_lines > ${TASK_DOC_THRESHOLDS.trackingLinesWarn} OR pct_tasks_with_3plus_files > ${TASK_DOC_THRESHOLDS.extraFilesPctWarn}%`);
184
+ }
165
185
  }
@@ -0,0 +1,427 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { banner, log, info, warn, ok, err } from '../lib/ui.mjs';
5
+ import chalk from 'chalk';
6
+ import {
7
+ loadSnapshot,
8
+ snapshotInfo,
9
+ syncSnapshotForProject,
10
+ isStale,
11
+ isSchemaCompatible,
12
+ fetchOsvByName,
13
+ } from '../lib/sc-sync.mjs';
14
+ import {
15
+ scanProject,
16
+ severityRank,
17
+ worstSeverity,
18
+ parsePackageJson,
19
+ findPackageJson,
20
+ matchPackageByName,
21
+ matchNamespaceFixture,
22
+ } from '../lib/sc-scanner.mjs';
23
+ import { logEvent } from '../lib/telemetry.mjs';
24
+
25
+ const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
26
+ const NAMESPACE_FIXTURE_REL = '.dw/security/ioc-namespaces.json';
27
+
28
+ export async function securityScanCommand(opts) {
29
+ const rootDir = process.cwd();
30
+ const mode = pickMode(opts);
31
+
32
+ if (mode === 'pre-install') {
33
+ return runPreInstallMode(rootDir, opts);
34
+ }
35
+
36
+ if (opts.json) {
37
+ return runJsonMode(rootDir, mode, opts);
38
+ }
39
+
40
+ banner('dw-kit Supply-Chain Scan');
41
+
42
+ if (mode === 'update-db' || opts.updateDb) {
43
+ info('Fetching fresh advisory snapshot from OSV.dev...');
44
+ try {
45
+ const start = Date.now();
46
+ const res = await syncSnapshotForProject(rootDir);
47
+ const elapsed = Date.now() - start;
48
+ ok(`Snapshot updated — ${res.advisoryCount} advisories for ${res.packageCount} packages (${elapsed}ms)`);
49
+ logEvent({ event: 'sc_guard', action: 'sync', advisories: res.advisoryCount, packages: res.packageCount, latency_ms: elapsed }, rootDir);
50
+ } catch (e) {
51
+ err(`Sync failed: ${e.message}`);
52
+ if (e.code === 'NO_LOCKFILE') warn('Run `npm install` first to create package-lock.json.');
53
+ process.exit(2);
54
+ }
55
+ if (mode === 'update-db') {
56
+ log('');
57
+ info('Snapshot ready. Run `dw security-scan` to scan project.');
58
+ return;
59
+ }
60
+ log('');
61
+ }
62
+
63
+ const info_ = snapshotInfo(rootDir);
64
+ if (!info_.exists) {
65
+ warn('No advisory snapshot found.');
66
+ log('Run `dw security-scan --update-db` to fetch from OSV.dev.');
67
+ log('Quick mode requires an existing snapshot.');
68
+ process.exit(1);
69
+ }
70
+
71
+ const ageDays = info_.age_days;
72
+ const ageLabel = ageDays === Infinity ? 'unknown' : `${ageDays.toFixed(1)} days old`;
73
+ log(chalk.bold('Snapshot'));
74
+ log(` Source : ${info_.source} (${info_.ecosystem})`);
75
+ log(` Fetched : ${info_.fetched_at || '?'} (${ageLabel})`);
76
+ log(` Advisories : ${info_.advisory_count} Packages scanned previously: ${info_.package_count}`);
77
+ if (!info_.schema_compatible) {
78
+ err(` Schema mismatch — expected 1.0, got ${info_.schema_version || 'unknown'}`);
79
+ log(' Run `dw security-scan --update-db` to refresh.');
80
+ process.exit(2);
81
+ }
82
+ if (info_.stale) {
83
+ warn(` Snapshot is stale (>7 days). Run \`dw security-scan --update-db\` to refresh.`);
84
+ }
85
+
86
+ log('');
87
+ log(chalk.bold('Scanning project lockfile...'));
88
+ const snap = loadSnapshot(rootDir);
89
+ const start = Date.now();
90
+ const result = scanProject(rootDir, snap);
91
+ const elapsed = Date.now() - start;
92
+
93
+ if (result.error === 'no_lockfile') {
94
+ err('No lockfile found (expected package-lock.json).');
95
+ process.exit(1);
96
+ }
97
+ if (result.error === 'no_snapshot') {
98
+ err('Snapshot data unavailable.');
99
+ process.exit(1);
100
+ }
101
+
102
+ log(` Lockfile : ${result.lockfile}`);
103
+ log(` Packages scanned : ${result.packages_scanned}`);
104
+ log(` Elapsed : ${elapsed}ms`);
105
+ log('');
106
+
107
+ if (result.matches.length === 0) {
108
+ ok('No advisory matches — clean.');
109
+ logEvent({ event: 'sc_guard', action: 'scan_run', matches: 0, packages: result.packages_scanned, outcome: 'clean', latency_ms: elapsed }, rootDir);
110
+ log('');
111
+ advisoryFooter();
112
+ return;
113
+ }
114
+
115
+ log(chalk.bold(`Matches (${result.matches.length})`));
116
+ const sorted = result.matches.slice().sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
117
+ for (const m of sorted) {
118
+ const tag = severityTag(m.severity);
119
+ log(` ${tag} ${m.package}@${m.version}`);
120
+ if (m.summary) log(chalk.dim(` ${m.summary}`));
121
+ log(chalk.dim(` advisory: ${m.advisory_id}`));
122
+ if (m.fix_versions.length) log(chalk.dim(` fix: ${m.fix_versions.join(', ')}`));
123
+ }
124
+ log('');
125
+
126
+ const worst = worstSeverity(result.matches);
127
+ const blockCount = result.matches.filter((m) => severityRank(m.severity) >= severityRank('high')).length;
128
+ const exitCode = blockCount > 0 ? 2 : 1;
129
+
130
+ logEvent(
131
+ {
132
+ event: 'sc_guard',
133
+ action: blockCount > 0 ? 'block' : 'allow',
134
+ matches: result.matches.length,
135
+ block_count: blockCount,
136
+ worst_severity: worst,
137
+ packages: result.packages_scanned,
138
+ latency_ms: elapsed,
139
+ snapshot_age_days: ageDays,
140
+ },
141
+ rootDir,
142
+ );
143
+
144
+ if (blockCount > 0) {
145
+ err(`${blockCount} HIGH+ severity match(es) — review before merging lockfile changes.`);
146
+ } else {
147
+ warn(`${result.matches.length} low/medium match(es) — review recommended.`);
148
+ }
149
+ log('');
150
+ advisoryFooter();
151
+ process.exit(exitCode);
152
+ }
153
+
154
+ function pickMode(opts) {
155
+ if (opts.preInstall) return 'pre-install';
156
+ if (opts.updateDb) return 'update-db';
157
+ return 'scan';
158
+ }
159
+
160
+ function loadNamespaceFixture(rootDir) {
161
+ // Prefer project-local fixture, fall back to toolkit-bundled
162
+ const candidates = [
163
+ join(rootDir, NAMESPACE_FIXTURE_REL),
164
+ join(TOOLKIT_ROOT, NAMESPACE_FIXTURE_REL),
165
+ ];
166
+ for (const p of candidates) {
167
+ if (existsSync(p)) {
168
+ try {
169
+ return { fixture: JSON.parse(readFileSync(p, 'utf-8')), path: p };
170
+ } catch {
171
+ // skip malformed
172
+ }
173
+ }
174
+ }
175
+ return { fixture: null, path: null };
176
+ }
177
+
178
+ async function runPreInstallMode(rootDir, opts) {
179
+ const useJson = !!opts.json;
180
+ const out = { mode: 'pre-install', ok: true, network_ok: false, packages: 0, osv_hits: [], namespace_hits: [] };
181
+
182
+ const pkgPath = findPackageJson(rootDir);
183
+ if (!pkgPath) {
184
+ if (useJson) {
185
+ out.ok = false;
186
+ out.error = { code: 'NO_PACKAGE_JSON', message: 'No package.json in current directory' };
187
+ process.stdout.write(JSON.stringify(out) + '\n');
188
+ process.exit(1);
189
+ }
190
+ err('No package.json in current directory.');
191
+ process.exit(1);
192
+ }
193
+
194
+ let packages;
195
+ try {
196
+ packages = parsePackageJson(pkgPath);
197
+ } catch (e) {
198
+ if (useJson) {
199
+ out.ok = false;
200
+ out.error = { code: 'PARSE_FAILED', message: e.message };
201
+ process.stdout.write(JSON.stringify(out) + '\n');
202
+ process.exit(1);
203
+ }
204
+ err(`Failed to parse package.json: ${e.message}`);
205
+ process.exit(1);
206
+ }
207
+
208
+ out.packages = packages.size;
209
+
210
+ if (!useJson) {
211
+ banner('dw-kit Pre-Install Scan');
212
+ log(chalk.bold('package.json'));
213
+ log(` Path : ${pkgPath}`);
214
+ log(` Declared : ${packages.size} packages (across deps/devDeps/peerDeps/optionalDeps)`);
215
+ log('');
216
+ }
217
+
218
+ // 1. Namespace fixture (offline, fast)
219
+ const fixtureBundle = loadNamespaceFixture(rootDir);
220
+ if (fixtureBundle.fixture) {
221
+ const hits = matchNamespaceFixture(packages, fixtureBundle.fixture);
222
+ out.namespace_hits = hits;
223
+ out.fixture_path = fixtureBundle.path;
224
+ if (!useJson) {
225
+ log(chalk.bold('Namespace IoC fixture'));
226
+ log(` Source : ${fixtureBundle.path}`);
227
+ log(` Entries : ${(fixtureBundle.fixture.namespaces || []).length} active namespace pattern(s)`);
228
+ if (hits.length === 0) {
229
+ ok(' No matches against active namespace patterns');
230
+ } else {
231
+ log('');
232
+ for (const h of hits) {
233
+ log(` ${severityTag(h.severity)} ${h.package} matches pattern "${h.namespace_pattern}"`);
234
+ if (h.reason) log(chalk.dim(` ${h.reason}`));
235
+ if (h.guidance) log(chalk.yellow(` guidance: ${h.guidance}`));
236
+ if (h.advisory_url) log(chalk.dim(` advisory: ${h.advisory_url}`));
237
+ }
238
+ }
239
+ log('');
240
+ }
241
+ }
242
+
243
+ // 2. OSV.dev name-only queries (network, optional)
244
+ if (!opts.offline) {
245
+ if (!useJson) log(chalk.bold('OSV.dev name-only query (per declared package)'));
246
+ const osvErrors = [];
247
+ let queried = 0;
248
+ for (const [name] of packages) {
249
+ try {
250
+ const result = await fetchOsvByName(name, 'npm', { timeoutMs: 5000 });
251
+ queried++;
252
+ const vulns = (result && result.vulns) || [];
253
+ if (vulns.length > 0) {
254
+ const hits = matchPackageByName(name, vulns);
255
+ for (const h of hits) {
256
+ out.osv_hits.push({ package: name, declared_range: packages.get(name), ...h });
257
+ }
258
+ }
259
+ } catch (e) {
260
+ osvErrors.push({ package: name, error: e.message });
261
+ }
262
+ }
263
+ out.network_ok = queried > 0;
264
+ out.osv_queried = queried;
265
+ out.osv_errors = osvErrors;
266
+
267
+ if (!useJson) {
268
+ log(` Queried : ${queried}/${packages.size} packages (${osvErrors.length} errors)`);
269
+ if (out.osv_hits.length === 0) {
270
+ ok(' No active advisories for declared packages');
271
+ } else {
272
+ const grouped = groupBy(out.osv_hits, 'package');
273
+ log('');
274
+ for (const [pkg, hits] of Object.entries(grouped)) {
275
+ const worst = hits.reduce(
276
+ (acc, h) => (severityRank(h.severity) > severityRank(acc) ? h.severity : acc),
277
+ 'unknown',
278
+ );
279
+ log(` ${severityTag(worst)} ${pkg}@${packages.get(pkg)} — ${hits.length} active advisory(s)`);
280
+ for (const h of hits.slice(0, 3)) {
281
+ log(chalk.dim(` ${h.advisory_id}: ${h.summary || '(no summary)'}`));
282
+ if (h.fix_versions && h.fix_versions.length) {
283
+ log(chalk.dim(` fix: ${h.fix_versions.slice(0, 3).join(', ')}`));
284
+ }
285
+ }
286
+ if (hits.length > 3) log(chalk.dim(` ... and ${hits.length - 3} more`));
287
+ }
288
+ }
289
+ log('');
290
+ }
291
+ } else if (!useJson) {
292
+ log(chalk.dim(' OSV.dev query: skipped (--offline)'));
293
+ log('');
294
+ }
295
+
296
+ // Summarize
297
+ const namespaceCrit = out.namespace_hits.length;
298
+ const osvHigh = out.osv_hits.filter((h) => severityRank(h.severity) >= severityRank('high')).length;
299
+ const exitCode = namespaceCrit > 0 ? 2 : (osvHigh > 0 ? 2 : (out.osv_hits.length > 0 ? 1 : 0));
300
+
301
+ logEvent(
302
+ {
303
+ event: 'sc_guard',
304
+ action: exitCode >= 2 ? 'block' : (exitCode === 1 ? 'allow' : 'scan_run'),
305
+ sub_mode: 'pre-install',
306
+ packages: packages.size,
307
+ namespace_hits: namespaceCrit,
308
+ osv_hits: out.osv_hits.length,
309
+ osv_high: osvHigh,
310
+ network_ok: out.network_ok,
311
+ },
312
+ rootDir,
313
+ );
314
+
315
+ if (useJson) {
316
+ out.summary = { namespace_hits: namespaceCrit, osv_hits: out.osv_hits.length, osv_high: osvHigh, exit_code: exitCode };
317
+ process.stdout.write(JSON.stringify(out) + '\n');
318
+ process.exit(exitCode);
319
+ }
320
+
321
+ log(chalk.bold('Summary'));
322
+ if (exitCode === 0) {
323
+ ok(`Clean — ${packages.size} packages scanned, no matches`);
324
+ } else if (exitCode === 1) {
325
+ warn(`${out.osv_hits.length} low/medium advisor(y/ies) found — review recommended`);
326
+ } else {
327
+ err(`${namespaceCrit} namespace match(es) + ${osvHigh} HIGH+ advisory(s) — rotate credentials if already installed`);
328
+ }
329
+ log('');
330
+ advisoryFooter();
331
+ process.exit(exitCode);
332
+ }
333
+
334
+ function groupBy(arr, key) {
335
+ const out = {};
336
+ for (const item of arr) {
337
+ const k = item[key];
338
+ if (!out[k]) out[k] = [];
339
+ out[k].push(item);
340
+ }
341
+ return out;
342
+ }
343
+
344
+ function severityTag(label) {
345
+ switch (label) {
346
+ case 'critical':
347
+ return chalk.red.bold('[CRITICAL]'.padEnd(12));
348
+ case 'high':
349
+ return chalk.red('[HIGH] '.padEnd(12));
350
+ case 'medium':
351
+ return chalk.yellow('[MEDIUM] '.padEnd(12));
352
+ case 'low':
353
+ return chalk.blue('[LOW] '.padEnd(12));
354
+ default:
355
+ return chalk.dim('[?] '.padEnd(12));
356
+ }
357
+ }
358
+
359
+ function advisoryFooter() {
360
+ info('ADVISORY OUTPUT — NOT a decision rule.');
361
+ log(' Threshold matrix in v14-evaluation-protocol.md is authoritative.');
362
+ log(' Use this scan as evidence-supplement only.');
363
+ log(' Public sunset commitment: 2026-08-12 (see ADR-0005).');
364
+ }
365
+
366
+ async function runJsonMode(rootDir, mode, opts) {
367
+ let out = { mode, ok: true };
368
+
369
+ if (mode === 'update-db' || opts.updateDb) {
370
+ try {
371
+ const start = Date.now();
372
+ const res = await syncSnapshotForProject(rootDir);
373
+ out.sync = { advisory_count: res.advisoryCount, package_count: res.packageCount, latency_ms: Date.now() - start };
374
+ logEvent({ event: 'sc_guard', action: 'sync', advisories: res.advisoryCount, packages: res.packageCount }, rootDir);
375
+ } catch (e) {
376
+ out.ok = false;
377
+ out.error = { code: e.code || 'SYNC_FAILED', message: e.message };
378
+ process.stdout.write(JSON.stringify(out) + '\n');
379
+ process.exit(2);
380
+ }
381
+ if (mode === 'update-db') {
382
+ process.stdout.write(JSON.stringify(out) + '\n');
383
+ return;
384
+ }
385
+ }
386
+
387
+ const info_ = snapshotInfo(rootDir);
388
+ out.snapshot = info_;
389
+ if (!info_.exists) {
390
+ out.ok = false;
391
+ out.error = { code: 'NO_SNAPSHOT', message: 'Run `dw security-scan --update-db`' };
392
+ process.stdout.write(JSON.stringify(out) + '\n');
393
+ process.exit(1);
394
+ }
395
+
396
+ const snap = loadSnapshot(rootDir);
397
+ const start = Date.now();
398
+ const result = scanProject(rootDir, snap);
399
+ out.scan = { ...result, elapsed_ms: Date.now() - start };
400
+
401
+ if (result.error) {
402
+ out.ok = false;
403
+ out.error = { code: result.error.toUpperCase(), message: result.error };
404
+ process.stdout.write(JSON.stringify(out) + '\n');
405
+ process.exit(1);
406
+ }
407
+
408
+ const worst = worstSeverity(result.matches);
409
+ const blockCount = result.matches.filter((m) => severityRank(m.severity) >= severityRank('high')).length;
410
+ out.summary = { matches: result.matches.length, block_count: blockCount, worst_severity: worst };
411
+
412
+ logEvent(
413
+ {
414
+ event: 'sc_guard',
415
+ action: blockCount > 0 ? 'block' : (result.matches.length > 0 ? 'allow' : 'scan_run'),
416
+ matches: result.matches.length,
417
+ block_count: blockCount,
418
+ worst_severity: worst,
419
+ packages: result.packages_scanned,
420
+ snapshot_age_days: info_.age_days,
421
+ },
422
+ rootDir,
423
+ );
424
+
425
+ process.stdout.write(JSON.stringify(out) + '\n');
426
+ process.exit(blockCount > 0 ? 2 : result.matches.length > 0 ? 1 : 0);
427
+ }
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { header, ok, warn, err, info, log, dry } from '../lib/ui.mjs';
5
5
  import { loadConfig, writeConfig, getToolkitVersions } from '../lib/config.mjs';
6
6
  import { diffDirs, copyDir, copyFile } from '../lib/copy.mjs';
7
+ import { ensureDwGitignore, ensureClaudeGitignore } from '../lib/gitignore.mjs';
7
8
 
8
9
  const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
9
10
 
@@ -57,6 +58,7 @@ export async function upgradeCommand(opts) {
57
58
 
58
59
  upgradeScripts(projectDir, opts);
59
60
  upgradeConfigSchema(projectDir, opts);
61
+ upgradeScopedGitignores(projectDir, opts);
60
62
 
61
63
  if (!opts.dryRun && totalChanges > 0) {
62
64
  updateVersionTracking(configPath, projectConfig, toolkitVersions);
@@ -169,6 +171,7 @@ function mergeSettingsJson(projectDir, opts) {
169
171
 
170
172
  if (opts.dryRun) {
171
173
  dry('merge .claude/settings.json');
174
+ dry('post-merge: wire supply-chain-scan.sh hook if missing (idempotent)');
172
175
  return;
173
176
  }
174
177
 
@@ -181,6 +184,57 @@ function mergeSettingsJson(projectDir, opts) {
181
184
  } catch (e) {
182
185
  warn(`settings.json merge failed: ${e.message}`);
183
186
  }
187
+
188
+ // Post-merge: explicitly install supply-chain-scan hook (ADR-0005, idempotent).
189
+ // deepMerge replaces arrays, so user's PostToolUse array may not contain the new
190
+ // hook entry. We must add it explicitly. Respects existing wiring.
191
+ installSupplyChainHookOnUpgrade(projectDir, opts);
192
+ }
193
+
194
+ function upgradeScopedGitignores(projectDir, opts) {
195
+ info('Scoped .gitignore (.dw/, .claude/)');
196
+ if (opts.dryRun) {
197
+ dry('refresh .dw/.gitignore + .claude/.gitignore managed blocks');
198
+ return;
199
+ }
200
+ try {
201
+ const dwR = ensureDwGitignore(projectDir);
202
+ if (dwR.action === 'noop') log(' .dw/.gitignore: up to date');
203
+ else ok(`.dw/.gitignore: ${dwR.action}`);
204
+
205
+ if (existsSync(join(projectDir, '.claude'))) {
206
+ const cR = ensureClaudeGitignore(projectDir);
207
+ if (cR.action === 'noop') log(' .claude/.gitignore: up to date');
208
+ else ok(`.claude/.gitignore: ${cR.action}`);
209
+ }
210
+ } catch (e) {
211
+ warn(`Scoped gitignore: ${e.message}`);
212
+ }
213
+ }
214
+
215
+ function installSupplyChainHookOnUpgrade(projectDir, opts) {
216
+ if (opts.dryRun) return;
217
+ try {
218
+ const configPath = join(projectDir, '.dw', 'config', 'dw.config.yml');
219
+ if (existsSync(configPath)) {
220
+ const cfg = readFileSync(configPath, 'utf-8');
221
+ if (/depth:\s*quick/i.test(cfg) && !/roles:\s*\[?\s*dev\s*,/i.test(cfg)) {
222
+ // Heuristic: solo preset → skip per ADR-0005 TW5
223
+ log(' Supply-chain hook: skipped (solo-style preset detected)');
224
+ log(' Enable later: `dw security-scan --install-hook`');
225
+ return;
226
+ }
227
+ }
228
+ } catch { /* fall through */ }
229
+
230
+ // Defer import (avoid loading at module top) for clean test isolation
231
+ import('../lib/sc-install.mjs').then((m) => {
232
+ const result = m.installHookInProject(projectDir);
233
+ if (result.ok && result.action === 'added') {
234
+ ok('Supply-chain guard hook wired (ADR-0005 — opt-in available since v1.3.5)');
235
+ log(' First scan: `dw security-scan --update-db`');
236
+ }
237
+ }).catch(() => { /* silent — installation is best-effort */ });
184
238
  }
185
239
 
186
240
  function deepMerge(base, override) {