nubos-pilot 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,8 +16,21 @@ const { atomicWriteFileSync, NubosPilotError, withFileLock } = require('../core.
16
16
 
17
17
  const STATUSLINE_REL = '.claude/nubos-pilot/hooks/np-statusline.cjs';
18
18
  const CTX_MONITOR_REL = '.claude/nubos-pilot/hooks/np-ctx-monitor.cjs';
19
+ const SECURITY_HOOK_REL = '.claude/nubos-pilot/hooks/np-security-hook.cjs';
19
20
  const NP_STATUSLINE_MARKER = 'np-statusline.';
20
21
  const NP_CTX_MONITOR_MARKER = 'np-ctx-monitor.';
22
+ const NP_SECURITY_MARKER = 'np-security-hook.';
23
+
24
+ // ADR-0020: in-session security review layer. One DRY hook script, registered
25
+ // against five Claude Code lifecycle events, differentiated by a trailing verb.
26
+ const SECURITY_HOOKS = Object.freeze([
27
+ { verb: 'session-start', event: 'SessionStart', matcher: undefined },
28
+ { verb: 'baseline', event: 'UserPromptSubmit', matcher: undefined },
29
+ { verb: 'scan', event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit|NotebookEdit' },
30
+ { verb: 'review', event: 'Stop', matcher: undefined },
31
+ { verb: 'commit', event: 'PostToolUse', matcher: 'Bash' },
32
+ ]);
33
+ const SECURITY_EVENTS = Object.freeze(['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse']);
21
34
 
22
35
  function _settingsPath(scope, projectRoot) {
23
36
  if (scope === 'global') return path.join(os.homedir(), '.claude', 'settings.json');
@@ -101,6 +114,64 @@ function _installPostToolUse(settings, cmd) {
101
114
  return { action: 'installed' };
102
115
  }
103
116
 
117
+ function _verbOf(command) {
118
+ const m = String(command).match(/"\s+([a-z-]+)\s*$/);
119
+ return m ? m[1] : null;
120
+ }
121
+
122
+ function _installVerbHook(settings, eventName, matcher, cmd, verb) {
123
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
124
+ if (!Array.isArray(settings.hooks[eventName])) settings.hooks[eventName] = [];
125
+ const list = settings.hooks[eventName];
126
+ for (const entry of list) {
127
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
128
+ for (const h of hooks) {
129
+ if (h && typeof h.command === 'string' && h.command.includes(NP_SECURITY_MARKER) && _verbOf(h.command) === verb) {
130
+ h.command = cmd;
131
+ h.type = 'command';
132
+ if (matcher !== undefined) entry.matcher = matcher;
133
+ return { action: 'updated' };
134
+ }
135
+ }
136
+ }
137
+ const entry = matcher !== undefined
138
+ ? { matcher, hooks: [{ type: 'command', command: cmd }] }
139
+ : { hooks: [{ type: 'command', command: cmd }] };
140
+ list.push(entry);
141
+ return { action: 'installed' };
142
+ }
143
+
144
+ function _installSecurity(settings, scope, projectRoot) {
145
+ const base = _hookCommand(SECURITY_HOOK_REL, scope, projectRoot);
146
+ const results = {};
147
+ for (const h of SECURITY_HOOKS) {
148
+ results[h.verb] = _installVerbHook(settings, h.event, h.matcher, base + ' ' + h.verb, h.verb);
149
+ }
150
+ return results;
151
+ }
152
+
153
+ function _removeSecurity(settings) {
154
+ if (!settings.hooks || typeof settings.hooks !== 'object') return { action: 'absent' };
155
+ let removed = 0;
156
+ for (const eventName of SECURITY_EVENTS) {
157
+ if (!Array.isArray(settings.hooks[eventName])) continue;
158
+ const filtered = [];
159
+ for (const entry of settings.hooks[eventName]) {
160
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
161
+ const kept = hooks.filter((h) => !(h && typeof h.command === 'string' && h.command.includes(NP_SECURITY_MARKER)));
162
+ if (kept.length > 0) {
163
+ filtered.push(kept.length === hooks.length ? entry : Object.assign({}, entry, { hooks: kept }));
164
+ } else {
165
+ removed++;
166
+ }
167
+ }
168
+ settings.hooks[eventName] = filtered;
169
+ if (filtered.length === 0) delete settings.hooks[eventName];
170
+ }
171
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
172
+ return { action: removed > 0 ? 'removed' : 'absent' };
173
+ }
174
+
104
175
  function _removeStatusLine(settings) {
105
176
  const existing = settings.statusLine;
106
177
  if (existing && typeof existing === 'object'
@@ -140,13 +211,19 @@ function installClaudeHooks(opts) {
140
211
  const which = o.which || 'both';
141
212
  const settingsPath = _settingsPath(scope, projectRoot);
142
213
 
214
+ const wantStatusline = which === 'statusline' || which === 'both' || which === 'all';
215
+ const wantCtxMonitor = which === 'ctx-monitor' || which === 'both' || which === 'all';
216
+ const wantSecurity = which === 'security' || which === 'all';
217
+
143
218
  const statuslineCmd = _hookCommand(STATUSLINE_REL, scope, projectRoot);
144
219
  const ctxMonitorCmd = _hookCommand(CTX_MONITOR_REL, scope, projectRoot);
145
220
 
146
- const statuslineAbs = path.join(scope === 'global' ? os.homedir() : projectRoot, STATUSLINE_REL);
147
- const ctxMonitorAbs = path.join(scope === 'global' ? os.homedir() : projectRoot, CTX_MONITOR_REL);
221
+ const base = scope === 'global' ? os.homedir() : projectRoot;
222
+ const statuslineAbs = path.join(base, STATUSLINE_REL);
223
+ const ctxMonitorAbs = path.join(base, CTX_MONITOR_REL);
224
+ const securityAbs = path.join(base, SECURITY_HOOK_REL);
148
225
 
149
- if (which === 'statusline' || which === 'both') {
226
+ if (wantStatusline) {
150
227
  if (!fs.existsSync(statuslineAbs)) {
151
228
  throw new NubosPilotError(
152
229
  'claude-hooks-script-missing',
@@ -155,7 +232,7 @@ function installClaudeHooks(opts) {
155
232
  );
156
233
  }
157
234
  }
158
- if (which === 'ctx-monitor' || which === 'both') {
235
+ if (wantCtxMonitor) {
159
236
  if (!fs.existsSync(ctxMonitorAbs)) {
160
237
  throw new NubosPilotError(
161
238
  'claude-hooks-script-missing',
@@ -164,6 +241,15 @@ function installClaudeHooks(opts) {
164
241
  );
165
242
  }
166
243
  }
244
+ if (wantSecurity) {
245
+ if (!fs.existsSync(securityAbs)) {
246
+ throw new NubosPilotError(
247
+ 'claude-hooks-script-missing',
248
+ 'Security hook script not found: ' + securityAbs,
249
+ { script: securityAbs },
250
+ );
251
+ }
252
+ }
167
253
 
168
254
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
169
255
 
@@ -171,12 +257,15 @@ function installClaudeHooks(opts) {
171
257
  const settings = _readJsonSafe(settingsPath);
172
258
  const results = {};
173
259
 
174
- if (which === 'statusline' || which === 'both') {
260
+ if (wantStatusline) {
175
261
  results.statusline = _installStatusLine(settings, statuslineCmd, force);
176
262
  }
177
- if (which === 'ctx-monitor' || which === 'both') {
263
+ if (wantCtxMonitor) {
178
264
  results.ctxMonitor = _installPostToolUse(settings, ctxMonitorCmd);
179
265
  }
266
+ if (wantSecurity) {
267
+ results.security = _installSecurity(settings, scope, projectRoot);
268
+ }
180
269
 
181
270
  if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
182
271
 
@@ -190,13 +279,14 @@ function uninstallClaudeHooks(opts) {
190
279
  const projectRoot = o.projectRoot || process.cwd();
191
280
  const scope = o.scope === 'global' ? 'global' : 'local';
192
281
  const settingsPath = _settingsPath(scope, projectRoot);
193
- if (!fs.existsSync(settingsPath)) return { path: settingsPath, results: { statusline: { action: 'absent' }, ctxMonitor: { action: 'absent' } } };
282
+ if (!fs.existsSync(settingsPath)) return { path: settingsPath, results: { statusline: { action: 'absent' }, ctxMonitor: { action: 'absent' }, security: { action: 'absent' } } };
194
283
 
195
284
  return withFileLock(settingsPath, () => {
196
285
  const settings = _readJsonSafe(settingsPath);
197
286
  const results = {
198
287
  statusline: _removeStatusLine(settings),
199
288
  ctxMonitor: _removePostToolUse(settings),
289
+ security: _removeSecurity(settings),
200
290
  };
201
291
  if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
202
292
  atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
@@ -209,8 +299,11 @@ module.exports = {
209
299
  uninstallClaudeHooks,
210
300
  STATUSLINE_REL,
211
301
  CTX_MONITOR_REL,
302
+ SECURITY_HOOK_REL,
212
303
  NP_STATUSLINE_MARKER,
213
304
  NP_CTX_MONITOR_MARKER,
305
+ NP_SECURITY_MARKER,
306
+ SECURITY_HOOKS,
214
307
  _settingsPath,
215
308
  _hookCommand,
216
309
  };
@@ -225,3 +225,99 @@ test('claude-hooks: settings file with only whitespace is treated as empty', ()
225
225
  fs.rmSync(dir, { recursive: true, force: true });
226
226
  }
227
227
  });
228
+
229
+ function _mkSandboxAll() {
230
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-claude-hooks-all-'));
231
+ const hooksDir = path.join(dir, '.claude', 'nubos-pilot', 'hooks');
232
+ fs.mkdirSync(hooksDir, { recursive: true });
233
+ for (const f of ['np-statusline.cjs', 'np-ctx-monitor.cjs', 'np-security-hook.cjs']) {
234
+ fs.writeFileSync(path.join(hooksDir, f), '// stub\n');
235
+ }
236
+ return dir;
237
+ }
238
+
239
+ test('claude-hooks SEC: which=all registers all five security lifecycle hooks', () => {
240
+ const dir = _mkSandboxAll();
241
+ try {
242
+ const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
243
+ const r = res.results.security;
244
+ assert.equal(r['session-start'].action, 'installed');
245
+ assert.equal(r.baseline.action, 'installed');
246
+ assert.equal(r.scan.action, 'installed');
247
+ assert.equal(r.review.action, 'installed');
248
+ assert.equal(r.commit.action, 'installed');
249
+ const s = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
250
+ assert.equal(s.hooks.SessionStart[0].hooks[0].command.replace(/.*np-security-hook\.cjs"\s*/, '').trim(), 'session-start');
251
+ assert.equal(s.hooks.UserPromptSubmit[0].hooks[0].command.trim().endsWith('baseline'), true);
252
+ assert.equal(s.hooks.Stop[0].hooks[0].command.trim().endsWith('review'), true);
253
+ const ptu = s.hooks.PostToolUse;
254
+ const scan = ptu.find((e) => e.matcher === 'Edit|Write|MultiEdit|NotebookEdit');
255
+ const commit = ptu.find((e) => e.matcher === 'Bash');
256
+ assert.ok(scan && scan.hooks[0].command.trim().endsWith('scan'));
257
+ assert.ok(commit && commit.hooks[0].command.trim().endsWith('commit'));
258
+ assert.ok(ptu.find((e) => e.matcher === '.*'), 'ctx-monitor still present under all');
259
+ } finally {
260
+ fs.rmSync(dir, { recursive: true, force: true });
261
+ }
262
+ });
263
+
264
+ test('claude-hooks SEC: re-install is idempotent (no duplicate entries)', () => {
265
+ const dir = _mkSandboxAll();
266
+ try {
267
+ mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
268
+ const res2 = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
269
+ assert.equal(res2.results.security.scan.action, 'updated');
270
+ const s = JSON.parse(fs.readFileSync(res2.path, 'utf-8'));
271
+ assert.equal(s.hooks.SessionStart.length, 1);
272
+ assert.equal(s.hooks.Stop.length, 1);
273
+ assert.equal(s.hooks.PostToolUse.filter((e) => e.hooks[0].command.includes('np-security-hook.')).length, 2);
274
+ } finally {
275
+ fs.rmSync(dir, { recursive: true, force: true });
276
+ }
277
+ });
278
+
279
+ test('claude-hooks SEC: which=both does NOT install security hooks (legacy unchanged)', () => {
280
+ const dir = _mkSandboxAll();
281
+ try {
282
+ const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'both' });
283
+ assert.equal(res.results.security, undefined);
284
+ const s = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
285
+ assert.ok(!s.hooks.SessionStart);
286
+ assert.ok(!s.hooks.Stop);
287
+ } finally {
288
+ fs.rmSync(dir, { recursive: true, force: true });
289
+ }
290
+ });
291
+
292
+ test('claude-hooks SEC: uninstall removes security hooks but preserves foreign ones', () => {
293
+ const dir = _mkSandboxAll();
294
+ try {
295
+ const settingsPath = path.join(dir, '.claude', 'settings.local.json');
296
+ fs.writeFileSync(settingsPath, JSON.stringify({
297
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo foreign-stop' }] }] },
298
+ }));
299
+ mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
300
+ const res = mod.uninstallClaudeHooks({ projectRoot: dir, scope: 'local' });
301
+ assert.equal(res.results.security.action, 'removed');
302
+ const s = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
303
+ assert.equal(s.hooks.Stop.length, 1);
304
+ assert.equal(s.hooks.Stop[0].hooks[0].command, 'echo foreign-stop');
305
+ assert.ok(!s.hooks.SessionStart, 'our SessionStart removed');
306
+ assert.ok(!s.hooks.PostToolUse || !s.hooks.PostToolUse.some((e) => e.hooks[0].command.includes('np-security-hook.')));
307
+ } finally {
308
+ fs.rmSync(dir, { recursive: true, force: true });
309
+ }
310
+ });
311
+
312
+ test('claude-hooks SEC: which=security missing script throws structured error', () => {
313
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-claude-hooks-nosec-'));
314
+ fs.mkdirSync(path.join(dir, '.claude', 'nubos-pilot', 'hooks'), { recursive: true });
315
+ try {
316
+ assert.throws(
317
+ () => mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'security' }),
318
+ (err) => err && err.code === 'claude-hooks-script-missing',
319
+ );
320
+ } finally {
321
+ fs.rmSync(dir, { recursive: true, force: true });
322
+ }
323
+ });
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const { withFileLock, atomicWriteFileSync } = require('../core.cjs');
8
+
9
+ const LEDGER_VERSION = 1;
10
+ const RISK_SEVERITIES = new Set(['risk', 'high', 'critical', 'fail']);
11
+
12
+ function sanitizeSid(sid) {
13
+ return String(sid || '').replace(/[^a-zA-Z0-9._-]/g, '_');
14
+ }
15
+
16
+ function ledgerPath(sid) {
17
+ return path.join(os.tmpdir(), 'claude-sec-' + sanitizeSid(sid) + '.json');
18
+ }
19
+
20
+ function _skeleton(sid) {
21
+ return {
22
+ session_id: String(sid || ''),
23
+ version: LEDGER_VERSION,
24
+ created_at: Date.now(),
25
+ baseline: null,
26
+ seen_scan: {},
27
+ findings: [],
28
+ review_in_flight: null,
29
+ stop_streak: 0,
30
+ commit_review_times: [],
31
+ };
32
+ }
33
+
34
+ function _read(sid) {
35
+ const p = ledgerPath(sid);
36
+ let raw;
37
+ try { raw = fs.readFileSync(p, 'utf-8'); }
38
+ catch { return _skeleton(sid); }
39
+ if (!raw || raw.trim() === '') return _skeleton(sid);
40
+ let parsed;
41
+ try { parsed = JSON.parse(raw); }
42
+ catch { return _skeleton(sid); }
43
+ if (!parsed || typeof parsed !== 'object') return _skeleton(sid);
44
+ return Object.assign(_skeleton(sid), parsed);
45
+ }
46
+
47
+ function _isPidAlive(pid) {
48
+ if (!Number.isInteger(pid)) return false;
49
+ try { process.kill(pid, 0); return true; }
50
+ catch (err) { return !!(err && err.code === 'EPERM'); }
51
+ }
52
+
53
+ function withLedger(sid, fn) {
54
+ const p = ledgerPath(sid);
55
+ return withFileLock(p, () => {
56
+ const ledger = _read(sid);
57
+ const result = fn(ledger);
58
+ atomicWriteFileSync(p, JSON.stringify(ledger), 'utf-8', 0o600);
59
+ return result;
60
+ });
61
+ }
62
+
63
+ function readLedger(sid) {
64
+ return _read(sid);
65
+ }
66
+
67
+ function initSession(sid) {
68
+ return withLedger(sid, (l) => { l.created_at = l.created_at || Date.now(); return { session_id: l.session_id }; });
69
+ }
70
+
71
+ function setBaseline(sid, baseline) {
72
+ return withLedger(sid, (l) => {
73
+ l.baseline = Object.assign({ captured_at: Date.now() }, baseline || {});
74
+ return l.baseline;
75
+ });
76
+ }
77
+
78
+ function _scanKey(f) {
79
+ return String(f.file) + '::' + String(f.rule_name);
80
+ }
81
+
82
+ function markScanReported(sid, findings) {
83
+ const list = Array.isArray(findings) ? findings : [];
84
+ return withLedger(sid, (l) => {
85
+ const fresh = [];
86
+ for (const f of list) {
87
+ const key = _scanKey(f);
88
+ if (l.seen_scan[key]) continue;
89
+ l.seen_scan[key] = true;
90
+ fresh.push(f);
91
+ }
92
+ return fresh;
93
+ });
94
+ }
95
+
96
+ function _fingerprint(f) {
97
+ return [
98
+ String(f.file || ''),
99
+ String(f.line == null ? '' : f.line),
100
+ String(f.category || ''),
101
+ String(f.rule_name || f.title || ''),
102
+ ].join('|');
103
+ }
104
+
105
+ function addReviewFindings(sid, findings, layer) {
106
+ const list = Array.isArray(findings) ? findings : [];
107
+ return withLedger(sid, (l) => {
108
+ const existing = new Set(l.findings.map((f) => f.fp));
109
+ let added = 0;
110
+ for (const f of list) {
111
+ const fp = _fingerprint(f);
112
+ if (existing.has(fp)) continue;
113
+ existing.add(fp);
114
+ l.findings.push({
115
+ fp,
116
+ file: f.file || null,
117
+ line: f.line == null ? null : f.line,
118
+ category: f.category || null,
119
+ severity: f.severity || 'risk',
120
+ title: f.title || f.rule_name || null,
121
+ mitigation_hint: f.mitigation_hint || f.reminder || null,
122
+ layer: layer || null,
123
+ surfaced: false,
124
+ addressed: false,
125
+ created_at: Date.now(),
126
+ });
127
+ added++;
128
+ }
129
+ return { added };
130
+ });
131
+ }
132
+
133
+ function takeUnsurfacedRisks(sid, opts) {
134
+ const maxStreak = opts && Number.isFinite(opts.maxStreak) ? opts.maxStreak : 3;
135
+ return withLedger(sid, (l) => {
136
+ const unsurfaced = l.findings.filter((f) => !f.surfaced && RISK_SEVERITIES.has(String(f.severity)));
137
+ if (unsurfaced.length === 0) {
138
+ l.stop_streak = 0;
139
+ return { findings: [], yielded: false };
140
+ }
141
+ if (l.stop_streak >= maxStreak) {
142
+ for (const f of unsurfaced) f.surfaced = true;
143
+ l.stop_streak = 0;
144
+ return { findings: [], yielded: true };
145
+ }
146
+ for (const f of unsurfaced) f.surfaced = true;
147
+ l.stop_streak += 1;
148
+ return { findings: unsurfaced.map((f) => ({ ...f })), yielded: false };
149
+ });
150
+ }
151
+
152
+ function tryBeginReview(sid, opts) {
153
+ const staleMs = opts && Number.isFinite(opts.staleMs) ? opts.staleMs : 5 * 60 * 1000;
154
+ return withLedger(sid, (l) => {
155
+ const cur = l.review_in_flight;
156
+ if (cur && typeof cur === 'object') {
157
+ const age = Date.now() - Number(cur.started_at || 0);
158
+ const stale = age > staleMs || !_isPidAlive(Number(cur.pid));
159
+ if (!stale) return { began: false, reason: 'in-flight' };
160
+ }
161
+ l.review_in_flight = { pid: process.pid, started_at: Date.now() };
162
+ return { began: true };
163
+ });
164
+ }
165
+
166
+ function endReview(sid) {
167
+ return withLedger(sid, (l) => { l.review_in_flight = null; return { ok: true }; });
168
+ }
169
+
170
+ function tryRecordCommitReview(sid, opts) {
171
+ const maxPerHour = opts && Number.isFinite(opts.maxPerHour) ? opts.maxPerHour : 20;
172
+ const windowMs = 60 * 60 * 1000;
173
+ return withLedger(sid, (l) => {
174
+ const now = Date.now();
175
+ l.commit_review_times = (l.commit_review_times || []).filter((t) => now - t < windowMs);
176
+ if (l.commit_review_times.length >= maxPerHour) return { allowed: false, count: l.commit_review_times.length };
177
+ l.commit_review_times.push(now);
178
+ return { allowed: true, count: l.commit_review_times.length };
179
+ });
180
+ }
181
+
182
+ function removeLedger(sid) {
183
+ try { fs.unlinkSync(ledgerPath(sid)); } catch {}
184
+ }
185
+
186
+ module.exports = {
187
+ LEDGER_VERSION,
188
+ RISK_SEVERITIES,
189
+ sanitizeSid,
190
+ ledgerPath,
191
+ withLedger,
192
+ readLedger,
193
+ initSession,
194
+ setBaseline,
195
+ markScanReported,
196
+ addReviewFindings,
197
+ takeUnsurfacedRisks,
198
+ tryBeginReview,
199
+ endReview,
200
+ tryRecordCommitReview,
201
+ removeLedger,
202
+ _fingerprint,
203
+ };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+
7
+ const ledger = require('./ledger.cjs');
8
+
9
+ let _sidCounter = 0;
10
+ function freshSid() {
11
+ _sidCounter += 1;
12
+ return 'test-sec-' + process.pid + '-' + _sidCounter;
13
+ }
14
+ function cleanup(sid) {
15
+ ledger.removeLedger(sid);
16
+ try { fs.unlinkSync(ledger.ledgerPath(sid) + '.lock'); } catch {}
17
+ }
18
+
19
+ test('LED-1 scan report-once: same pattern+file reported only once per session', () => {
20
+ const sid = freshSid();
21
+ try {
22
+ const f = [{ file: 'a.js', rule_name: 'eval_call' }];
23
+ const first = ledger.markScanReported(sid, f);
24
+ const second = ledger.markScanReported(sid, f);
25
+ assert.equal(first.length, 1);
26
+ assert.equal(second.length, 0);
27
+ } finally { cleanup(sid); }
28
+ });
29
+
30
+ test('LED-2 scan dedup is per-file: same rule on a different file is fresh', () => {
31
+ const sid = freshSid();
32
+ try {
33
+ ledger.markScanReported(sid, [{ file: 'a.js', rule_name: 'eval_call' }]);
34
+ const other = ledger.markScanReported(sid, [{ file: 'b.js', rule_name: 'eval_call' }]);
35
+ assert.equal(other.length, 1);
36
+ } finally { cleanup(sid); }
37
+ });
38
+
39
+ test('LED-3 review findings dedup by fingerprint (cross-layer)', () => {
40
+ const sid = freshSid();
41
+ try {
42
+ const finding = { file: 'x.js', line: 10, category: 'injection', severity: 'risk', title: 'SQLi' };
43
+ const a = ledger.addReviewFindings(sid, [finding], 'stop');
44
+ const b = ledger.addReviewFindings(sid, [finding], 'commit');
45
+ assert.equal(a.added, 1);
46
+ assert.equal(b.added, 0);
47
+ } finally { cleanup(sid); }
48
+ });
49
+
50
+ test('LED-4 takeUnsurfacedRisks surfaces once, then nothing', () => {
51
+ const sid = freshSid();
52
+ try {
53
+ ledger.addReviewFindings(sid, [{ file: 'x.js', line: 1, category: 'authz', severity: 'risk', title: 'bypass' }], 'stop');
54
+ const first = ledger.takeUnsurfacedRisks(sid, { maxStreak: 3 });
55
+ const second = ledger.takeUnsurfacedRisks(sid, { maxStreak: 3 });
56
+ assert.equal(first.findings.length, 1);
57
+ assert.equal(second.findings.length, 0);
58
+ } finally { cleanup(sid); }
59
+ });
60
+
61
+ test('LED-5 only risk-class severities surface; warn does not block', () => {
62
+ const sid = freshSid();
63
+ try {
64
+ ledger.addReviewFindings(sid, [{ file: 'x.js', line: 2, category: 'style', severity: 'warn', title: 'nit' }], 'stop');
65
+ const r = ledger.takeUnsurfacedRisks(sid, {});
66
+ assert.equal(r.findings.length, 0);
67
+ } finally { cleanup(sid); }
68
+ });
69
+
70
+ test('LED-6 a single surfacing drains all currently-unsurfaced risks at once', () => {
71
+ const sid = freshSid();
72
+ try {
73
+ for (let i = 0; i < 5; i++) {
74
+ ledger.addReviewFindings(sid, [{ file: 'f' + i + '.js', line: i, category: 'injection', severity: 'risk', title: 't' + i }], 'stop');
75
+ }
76
+ const r1 = ledger.takeUnsurfacedRisks(sid, { maxStreak: 3 });
77
+ const r2 = ledger.takeUnsurfacedRisks(sid, { maxStreak: 3 });
78
+ assert.equal(r1.findings.length, 5);
79
+ assert.equal(r2.findings.length, 0);
80
+ } finally { cleanup(sid); }
81
+ });
82
+
83
+ test('LED-6b once streak hits max with leftovers, it yields back to the user', () => {
84
+ const sid = freshSid();
85
+ try {
86
+ let streak = 0;
87
+ let yielded = false;
88
+ for (let turn = 0; turn < 10; turn++) {
89
+ ledger.addReviewFindings(sid, [{ file: 'g' + turn + '.js', line: turn, category: 'injection', severity: 'risk', title: 'g' + turn }], 'stop');
90
+ const r = ledger.takeUnsurfacedRisks(sid, { maxStreak: 3 });
91
+ if (r.yielded) { yielded = true; break; }
92
+ streak++;
93
+ }
94
+ assert.ok(yielded, 'should yield within a few turns');
95
+ assert.ok(streak <= 3, 'never more than maxStreak consecutive blocks');
96
+ } finally { cleanup(sid); }
97
+ });
98
+
99
+ test('LED-7 concurrency guard: second begin while in-flight is rejected', () => {
100
+ const sid = freshSid();
101
+ try {
102
+ const a = ledger.tryBeginReview(sid, {});
103
+ const b = ledger.tryBeginReview(sid, {});
104
+ assert.equal(a.began, true);
105
+ assert.equal(b.began, false);
106
+ ledger.endReview(sid);
107
+ const c = ledger.tryBeginReview(sid, {});
108
+ assert.equal(c.began, true);
109
+ } finally { cleanup(sid); }
110
+ });
111
+
112
+ test('LED-7b stale in-flight (old timestamp) is reclaimed', () => {
113
+ const sid = freshSid();
114
+ try {
115
+ ledger.tryBeginReview(sid, {});
116
+ ledger.withLedger(sid, (l) => { l.review_in_flight.started_at = Date.now() - 10 * 60 * 1000; });
117
+ const c = ledger.tryBeginReview(sid, { staleMs: 5 * 60 * 1000 });
118
+ assert.equal(c.began, true);
119
+ } finally { cleanup(sid); }
120
+ });
121
+
122
+ test('LED-8 commit rolling-hour cap enforced', () => {
123
+ const sid = freshSid();
124
+ try {
125
+ let lastAllowed = true;
126
+ for (let i = 0; i < 20; i++) lastAllowed = ledger.tryRecordCommitReview(sid, { maxPerHour: 20 }).allowed;
127
+ assert.equal(lastAllowed, true);
128
+ const over = ledger.tryRecordCommitReview(sid, { maxPerHour: 20 });
129
+ assert.equal(over.allowed, false);
130
+ } finally { cleanup(sid); }
131
+ });
132
+
133
+ test('LED-9 baseline round-trips', () => {
134
+ const sid = freshSid();
135
+ try {
136
+ ledger.setBaseline(sid, { head: 'abc123' });
137
+ assert.equal(ledger.readLedger(sid).baseline.head, 'abc123');
138
+ } finally { cleanup(sid); }
139
+ });