nubos-pilot 1.2.0 → 1.2.2
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/CHANGELOG.md +33 -1
- package/agents/np-executor.md +20 -0
- package/agents/np-security-reviewer.md +49 -3
- package/bin/install.js +7 -2
- package/bin/np-tools/_commands.cjs +2 -0
- package/bin/np-tools/doctor.cjs +15 -2
- package/bin/np-tools/graph-impact.cjs +111 -0
- package/bin/np-tools/graph-impact.test.cjs +119 -0
- package/bin/np-tools/scan-codebase.cjs +21 -1
- package/bin/np-tools/security.cjs +177 -0
- package/bin/np-tools/security.test.cjs +82 -0
- package/lib/checkpoint.cjs +3 -0
- package/lib/codebase-graph.cjs +0 -0
- package/lib/codebase-graph.test.cjs +174 -0
- package/lib/codebase-manifest.cjs +3 -0
- package/lib/config-defaults.cjs +23 -0
- package/lib/config-defaults.test.cjs +15 -0
- package/lib/config-schema.cjs +19 -0
- package/lib/config-schema.test.cjs +58 -0
- package/lib/install/claude-hooks.cjs +100 -7
- package/lib/install/claude-hooks.test.cjs +96 -0
- package/lib/learnings.cjs +19 -95
- package/lib/memory.cjs +38 -33
- package/lib/messaging.cjs +12 -6
- package/lib/metrics-aggregate.cjs +14 -2
- package/lib/migrate.cjs +29 -0
- package/lib/migrate.test.cjs +91 -0
- package/lib/schemas/data/checkpoint.v1.json +13 -0
- package/lib/schemas/data/codebase-manifest.v1.json +22 -0
- package/lib/schemas/data/learnings.v1.json +28 -0
- package/lib/schemas/data/memory-manifest.v1.json +14 -0
- package/lib/schemas/data/memory-record.v1.json +16 -0
- package/lib/schemas/data/message.v1.json +19 -0
- package/lib/schemas/data/metrics-record.v1.json +11 -0
- package/lib/security/ledger.cjs +203 -0
- package/lib/security/ledger.test.cjs +139 -0
- package/lib/security/patterns.cjs +119 -0
- package/lib/security/review.cjs +220 -0
- package/lib/security/review.test.cjs +143 -0
- package/lib/security/scan.cjs +180 -0
- package/lib/security/scan.test.cjs +137 -0
- package/lib/validate.cjs +301 -0
- package/lib/validate.test.cjs +242 -0
- package/np-tools.cjs +2 -0
- package/package.json +3 -1
- package/templates/claude/payload/hooks/np-security-hook.cjs +50 -0
- package/workflows/execute-phase.md +11 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const child_process = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const { tryReadConfigPath } = require('../../lib/config.cjs');
|
|
8
|
+
const scan = require('../../lib/security/scan.cjs');
|
|
9
|
+
const ledger = require('../../lib/security/ledger.cjs');
|
|
10
|
+
const review = require('../../lib/security/review.cjs');
|
|
11
|
+
const args = require('./_args.cjs');
|
|
12
|
+
|
|
13
|
+
const COMMIT_RE = /\bgit\b[\s\S]*\b(commit|push)\b/;
|
|
14
|
+
|
|
15
|
+
function _readStdin() {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
if (process.stdin.isTTY) return resolve('');
|
|
18
|
+
let buf = '';
|
|
19
|
+
process.stdin.setEncoding('utf-8');
|
|
20
|
+
const timer = setTimeout(() => { try { process.stdin.removeAllListeners(); } catch {} resolve(buf); }, 800);
|
|
21
|
+
process.stdin.on('data', (c) => { buf += c; });
|
|
22
|
+
process.stdin.on('end', () => { clearTimeout(timer); resolve(buf); });
|
|
23
|
+
process.stdin.on('error', () => { clearTimeout(timer); resolve(buf); });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function _safeParse(s) { try { return s ? JSON.parse(s) : {}; } catch { return {}; }}
|
|
28
|
+
|
|
29
|
+
async function _payload(argv) {
|
|
30
|
+
const inline = args.getFlag(argv, '--payload', { allowDashValues: true });
|
|
31
|
+
if (inline !== undefined) return _safeParse(inline);
|
|
32
|
+
if (argv.includes('--stdin')) return _safeParse(await _readStdin());
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _cfg(cwd) {
|
|
37
|
+
return {
|
|
38
|
+
enabled: tryReadConfigPath(cwd, 'security.enabled', true) !== false,
|
|
39
|
+
scan_on_write: tryReadConfigPath(cwd, 'security.scan_on_write', true) !== false,
|
|
40
|
+
review_on_stop: tryReadConfigPath(cwd, 'security.review_on_stop', true) !== false,
|
|
41
|
+
review_on_commit: tryReadConfigPath(cwd, 'security.review_on_commit', true) !== false,
|
|
42
|
+
custom_rules_path: tryReadConfigPath(cwd, 'security.custom_rules_path', null),
|
|
43
|
+
guidance_path: tryReadConfigPath(cwd, 'security.guidance_path', null),
|
|
44
|
+
review_timeout_ms: Number(tryReadConfigPath(cwd, 'security.review_timeout_ms', 180000)) || 180000,
|
|
45
|
+
max_stop_reviews_in_a_row: Number(tryReadConfigPath(cwd, 'security.max_stop_reviews_in_a_row', 3)) || 3,
|
|
46
|
+
max_commit_reviews_per_hour: Number(tryReadConfigPath(cwd, 'security.max_commit_reviews_per_hour', 20)) || 20,
|
|
47
|
+
max_files_per_review: Number(tryReadConfigPath(cwd, 'security.max_files_per_review', 30)) || 30,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _resolveRel(cwd, p) {
|
|
52
|
+
if (!p) return null;
|
|
53
|
+
return path.isAbsolute(p) ? p : path.join(cwd, p);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function _editedContent(toolInput) {
|
|
57
|
+
if (!toolInput || typeof toolInput !== 'object') return '';
|
|
58
|
+
if (typeof toolInput.content === 'string') return toolInput.content;
|
|
59
|
+
if (typeof toolInput.new_string === 'string') return toolInput.new_string;
|
|
60
|
+
if (typeof toolInput.new_source === 'string') return toolInput.new_source;
|
|
61
|
+
if (Array.isArray(toolInput.edits)) {
|
|
62
|
+
return toolInput.edits.map((e) => (e && typeof e.new_string === 'string' ? e.new_string : '')).join('\n');
|
|
63
|
+
}
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _editedPath(cwd, toolInput) {
|
|
68
|
+
if (!toolInput || typeof toolInput !== 'object') return '';
|
|
69
|
+
const raw = toolInput.file_path || toolInput.notebook_path || '';
|
|
70
|
+
if (!raw) return '';
|
|
71
|
+
return path.isAbsolute(raw) ? path.relative(cwd, raw) : raw;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _spawnWorker(cwd, sid, mode) {
|
|
75
|
+
const npTools = path.join(__dirname, '..', '..', 'np-tools.cjs');
|
|
76
|
+
try {
|
|
77
|
+
const child = child_process.spawn(
|
|
78
|
+
process.execPath,
|
|
79
|
+
[npTools, 'security', 'run-review', '--session', sid, '--mode', mode],
|
|
80
|
+
{ cwd, detached: true, stdio: 'ignore' },
|
|
81
|
+
);
|
|
82
|
+
child.unref();
|
|
83
|
+
return true;
|
|
84
|
+
} catch { return false; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _emit(stdout, obj) { stdout.write(JSON.stringify(obj)); }
|
|
88
|
+
|
|
89
|
+
async function run(argv, ctx) {
|
|
90
|
+
const context = ctx || {};
|
|
91
|
+
const cwd = context.cwd || process.cwd();
|
|
92
|
+
const stdout = context.stdout || process.stdout;
|
|
93
|
+
const list = Array.isArray(argv) ? argv : [];
|
|
94
|
+
const verb = list[0];
|
|
95
|
+
|
|
96
|
+
const cfg = _cfg(cwd);
|
|
97
|
+
if (!cfg.enabled && verb !== 'run-review') return 0;
|
|
98
|
+
|
|
99
|
+
const payload = await _payload(list);
|
|
100
|
+
const sid = payload.session_id || args.getFlag(list, '--session') || '';
|
|
101
|
+
|
|
102
|
+
if (verb === 'session-start') {
|
|
103
|
+
if (sid) { try { ledger.initSession(sid); } catch {} }
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (verb === 'baseline') {
|
|
108
|
+
if (sid) {
|
|
109
|
+
try { ledger.setBaseline(sid, { head: review.headSha(cwd) }); } catch {}
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (verb === 'scan') {
|
|
115
|
+
if (!cfg.scan_on_write || !sid) return 0;
|
|
116
|
+
const filePath = _editedPath(cwd, payload.tool_input);
|
|
117
|
+
const content = _editedContent(payload.tool_input);
|
|
118
|
+
if (!filePath || !content) return 0;
|
|
119
|
+
let result;
|
|
120
|
+
try {
|
|
121
|
+
result = scan.scanContent({ filePath, content, customRulesPath: _resolveRel(cwd, cfg.custom_rules_path) });
|
|
122
|
+
} catch { return 0; }
|
|
123
|
+
let fresh;
|
|
124
|
+
try { fresh = ledger.markScanReported(sid, result.findings); } catch { fresh = result.findings; }
|
|
125
|
+
if (!fresh.length) return 0;
|
|
126
|
+
const lines = fresh.map((f) => '- [' + f.category + '] ' + path.basename(f.file) + ':' + f.line + ' — ' + f.reminder);
|
|
127
|
+
_emit(stdout, {
|
|
128
|
+
hookSpecificOutput: {
|
|
129
|
+
hookEventName: 'PostToolUse',
|
|
130
|
+
additionalContext: '[nubos-pilot security] potential issue(s) in just-written code:\n' + lines.join('\n')
|
|
131
|
+
+ '\nConsider addressing before proceeding (non-blocking).',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (verb === 'review') {
|
|
138
|
+
if (!cfg.review_on_stop || !sid) return 0;
|
|
139
|
+
let harvest = { findings: [] };
|
|
140
|
+
try { harvest = ledger.takeUnsurfacedRisks(sid, { maxStreak: cfg.max_stop_reviews_in_a_row }); } catch {}
|
|
141
|
+
if (harvest.findings && harvest.findings.length) {
|
|
142
|
+
const lines = harvest.findings.map((f) => '- [' + (f.category || 'security') + '] '
|
|
143
|
+
+ (f.file ? path.basename(String(f.file)) + (f.line ? ':' + f.line : '') + ' — ' : '')
|
|
144
|
+
+ (f.title || 'security finding') + (f.mitigation_hint ? ' (' + f.mitigation_hint + ')' : ''));
|
|
145
|
+
_emit(stdout, {
|
|
146
|
+
decision: 'block',
|
|
147
|
+
reason: '[nubos-pilot security] An independent review of this turn\'s changes found '
|
|
148
|
+
+ harvest.findings.length + ' security issue(s). Address them now as a follow-up, then continue:\n'
|
|
149
|
+
+ lines.join('\n'),
|
|
150
|
+
});
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
_spawnWorker(cwd, sid, 'stop');
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (verb === 'commit') {
|
|
158
|
+
if (!cfg.review_on_commit || !sid) return 0;
|
|
159
|
+
const cmd = payload.tool_input && typeof payload.tool_input.command === 'string' ? payload.tool_input.command : '';
|
|
160
|
+
if (!cmd || !COMMIT_RE.test(cmd)) return 0;
|
|
161
|
+
let allowed = { allowed: false };
|
|
162
|
+
try { allowed = ledger.tryRecordCommitReview(sid, { maxPerHour: cfg.max_commit_reviews_per_hour }); } catch {}
|
|
163
|
+
if (allowed.allowed) _spawnWorker(cwd, sid, 'commit');
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (verb === 'run-review') {
|
|
168
|
+
if (!cfg.enabled || !sid) return 0;
|
|
169
|
+
const mode = args.getFlag(list, '--mode') === 'commit' ? 'commit' : 'stop';
|
|
170
|
+
try { review.runReview({ cwd, sid, mode, config: { ...cfg, guidance_path: _resolveRel(cwd, cfg.guidance_path) } }); } catch {}
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { run, COMMIT_RE, _editedContent, _editedPath };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const security = require('./security.cjs');
|
|
10
|
+
const ledger = require('../../lib/security/ledger.cjs');
|
|
11
|
+
|
|
12
|
+
let _c = 0;
|
|
13
|
+
function freshSid() { _c += 1; return 'cmd-sec-' + process.pid + '-' + _c; }
|
|
14
|
+
function cleanup(sid) { ledger.removeLedger(sid); try { fs.unlinkSync(ledger.ledgerPath(sid) + '.lock'); } catch {} }
|
|
15
|
+
|
|
16
|
+
function collector() {
|
|
17
|
+
const chunks = [];
|
|
18
|
+
return { stdout: { write: (s) => chunks.push(s) }, text: () => chunks.join('') };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function runVerb(verb, payload, cwd, extra) {
|
|
22
|
+
const c = collector();
|
|
23
|
+
const argv = [verb, '--payload', JSON.stringify(payload), ...(extra || [])];
|
|
24
|
+
await security.run(argv, { cwd: cwd || process.cwd(), stdout: c.stdout });
|
|
25
|
+
return c.text();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('SECCMD-1 scan emits additionalContext on first hit, silent on repeat (report-once)', async () => {
|
|
29
|
+
const sid = freshSid();
|
|
30
|
+
try {
|
|
31
|
+
const payload = { session_id: sid, tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'const r = eval(q)' } };
|
|
32
|
+
const first = await runVerb('scan', payload);
|
|
33
|
+
const second = await runVerb('scan', payload);
|
|
34
|
+
assert.match(first, /hookSpecificOutput/);
|
|
35
|
+
assert.match(first, /nubos-pilot security/);
|
|
36
|
+
assert.equal(second, '');
|
|
37
|
+
} finally { cleanup(sid); }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('SECCMD-2 review harvests unsurfaced risks and emits a non-blocking Stop block decision', async () => {
|
|
41
|
+
const sid = freshSid();
|
|
42
|
+
try {
|
|
43
|
+
ledger.addReviewFindings(sid, [{ file: 'a.js', line: 5, category: 'injection', severity: 'risk', title: 'SQLi', mitigation_hint: 'parameterize' }], 'stop');
|
|
44
|
+
const out = await runVerb('review', { session_id: sid });
|
|
45
|
+
const parsed = JSON.parse(out);
|
|
46
|
+
assert.equal(parsed.decision, 'block');
|
|
47
|
+
assert.match(parsed.reason, /nubos-pilot security/);
|
|
48
|
+
assert.match(parsed.reason, /SQLi/);
|
|
49
|
+
} finally { cleanup(sid); }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('SECCMD-3 commit verb ignores non-git Bash commands', async () => {
|
|
53
|
+
const sid = freshSid();
|
|
54
|
+
try {
|
|
55
|
+
const out = await runVerb('commit', { session_id: sid, tool_name: 'Bash', tool_input: { command: 'ls -la' } });
|
|
56
|
+
assert.equal(out, '');
|
|
57
|
+
assert.equal(ledger.readLedger(sid).commit_review_times.length, 0);
|
|
58
|
+
} finally { cleanup(sid); }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('SECCMD-4 master toggle off makes every hook verb a silent no-op', async () => {
|
|
62
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-sec-proj-'));
|
|
63
|
+
fs.mkdirSync(path.join(root, '.nubos-pilot'), { recursive: true });
|
|
64
|
+
fs.writeFileSync(path.join(root, '.nubos-pilot', 'config.json'), JSON.stringify({ security: { enabled: false } }));
|
|
65
|
+
const sid = freshSid();
|
|
66
|
+
try {
|
|
67
|
+
const scanOut = await runVerb('scan', { session_id: sid, tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'eval(q)' } }, root);
|
|
68
|
+
ledger.addReviewFindings(sid, [{ file: 'a.js', line: 1, category: 'x', severity: 'risk', title: 't' }], 'stop');
|
|
69
|
+
const reviewOut = await runVerb('review', { session_id: sid }, root);
|
|
70
|
+
assert.equal(scanOut, '');
|
|
71
|
+
assert.equal(reviewOut, '');
|
|
72
|
+
} finally { cleanup(sid); fs.rmSync(root, { recursive: true, force: true }); }
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('SECCMD-5 session-start and baseline are safe no-throw no-ops without a repo', async () => {
|
|
76
|
+
const sid = freshSid();
|
|
77
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-sec-nr-'));
|
|
78
|
+
try {
|
|
79
|
+
assert.equal(await runVerb('session-start', { session_id: sid }, root), '');
|
|
80
|
+
assert.equal(await runVerb('baseline', { session_id: sid }, root), '');
|
|
81
|
+
} finally { cleanup(sid); fs.rmSync(root, { recursive: true, force: true }); }
|
|
82
|
+
});
|
package/lib/checkpoint.cjs
CHANGED
|
@@ -10,8 +10,10 @@ const {
|
|
|
10
10
|
} = require('./core.cjs');
|
|
11
11
|
const { parseState, serializeState } = require('./state.cjs');
|
|
12
12
|
const { TASK_ID_RE } = require('./ids.cjs');
|
|
13
|
+
const { assertValid } = require('./validate.cjs');
|
|
13
14
|
|
|
14
15
|
const CHECKPOINT_SCHEMA_VERSION = 1;
|
|
16
|
+
const STORE_SCHEMA = 'checkpoint.v1';
|
|
15
17
|
|
|
16
18
|
function _assertSafeTaskId(taskId) {
|
|
17
19
|
if (typeof taskId !== 'string' || !TASK_ID_RE.test(taskId)) {
|
|
@@ -78,6 +80,7 @@ function _assertCompatibleSchema(existing, cpPath) {
|
|
|
78
80
|
},
|
|
79
81
|
);
|
|
80
82
|
}
|
|
83
|
+
assertValid(existing, STORE_SCHEMA, 'checkpoint-corrupt', { path: cpPath });
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
function _sliceFromTaskId(taskId) {
|
|
Binary file
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const g = require('./codebase-graph.cjs');
|
|
5
|
+
|
|
6
|
+
function fact(id, directory, files, extra) {
|
|
7
|
+
const source_paths = files.map((f) => f.path);
|
|
8
|
+
return Object.assign({
|
|
9
|
+
id,
|
|
10
|
+
name: directory || 'root',
|
|
11
|
+
directory: directory || '',
|
|
12
|
+
primary_language: 'javascript',
|
|
13
|
+
language_distribution: { javascript: files.length },
|
|
14
|
+
file_count: files.length,
|
|
15
|
+
source_paths,
|
|
16
|
+
symbols: [],
|
|
17
|
+
internal_deps: [],
|
|
18
|
+
external_deps: [],
|
|
19
|
+
files,
|
|
20
|
+
}, extra || {});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('CG-1: buildModuleGraph creates a node per fact', () => {
|
|
24
|
+
const facts = [
|
|
25
|
+
fact('src-auth', 'src/auth', [{ path: 'src/auth/login.js', language: 'javascript', symbols: [], deps: [] }]),
|
|
26
|
+
fact('src-db', 'src/db', [{ path: 'src/db/index.js', language: 'javascript', symbols: [], deps: [] }]),
|
|
27
|
+
];
|
|
28
|
+
const graph = g.buildModuleGraph(facts);
|
|
29
|
+
assert.equal(graph.module_count, 2);
|
|
30
|
+
assert.deepEqual(graph.nodes.map((n) => n.id), ['src-auth', 'src-db']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('CG-2: relative import resolves to a cross-module edge', () => {
|
|
34
|
+
const facts = [
|
|
35
|
+
fact('src-auth', 'src/auth', [
|
|
36
|
+
{ path: 'src/auth/login.js', language: 'javascript', symbols: [], deps: ['../db', 'bcrypt'] },
|
|
37
|
+
]),
|
|
38
|
+
fact('src-db', 'src/db', [
|
|
39
|
+
{ path: 'src/db/index.js', language: 'javascript', symbols: [], deps: [] },
|
|
40
|
+
]),
|
|
41
|
+
];
|
|
42
|
+
const graph = g.buildModuleGraph(facts);
|
|
43
|
+
assert.equal(graph.edge_count, 1);
|
|
44
|
+
assert.deepEqual(graph.edges[0], { from: 'src-auth', to: 'src-db', weight: 1 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('CG-3: external (non-relative) deps never become edges', () => {
|
|
48
|
+
const facts = [
|
|
49
|
+
fact('src-auth', 'src/auth', [
|
|
50
|
+
{ path: 'src/auth/login.js', language: 'javascript', symbols: [], deps: ['bcrypt', 'node:fs'] },
|
|
51
|
+
]),
|
|
52
|
+
];
|
|
53
|
+
const graph = g.buildModuleGraph(facts);
|
|
54
|
+
assert.equal(graph.edge_count, 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('CG-4: same-module relative import is not a self-edge', () => {
|
|
58
|
+
const facts = [
|
|
59
|
+
fact('src-auth', 'src/auth', [
|
|
60
|
+
{ path: 'src/auth/login.js', language: 'javascript', symbols: [], deps: ['./session'] },
|
|
61
|
+
{ path: 'src/auth/session.js', language: 'javascript', symbols: [], deps: [] },
|
|
62
|
+
]),
|
|
63
|
+
];
|
|
64
|
+
const graph = g.buildModuleGraph(facts);
|
|
65
|
+
assert.equal(graph.edge_count, 0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('CG-5: repeated imports raise edge weight', () => {
|
|
69
|
+
const facts = [
|
|
70
|
+
fact('a', 'a', [
|
|
71
|
+
{ path: 'a/one.js', language: 'javascript', symbols: [], deps: ['../b'] },
|
|
72
|
+
{ path: 'a/two.js', language: 'javascript', symbols: [], deps: ['../b/helper'] },
|
|
73
|
+
]),
|
|
74
|
+
fact('b', 'b', [
|
|
75
|
+
{ path: 'b/index.js', language: 'javascript', symbols: [], deps: [] },
|
|
76
|
+
{ path: 'b/helper.js', language: 'javascript', symbols: [], deps: [] },
|
|
77
|
+
]),
|
|
78
|
+
];
|
|
79
|
+
const graph = g.buildModuleGraph(facts);
|
|
80
|
+
assert.equal(graph.edges.length, 1);
|
|
81
|
+
assert.equal(graph.edges[0].weight, 2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('CG-6: unresolved internal-looking deps are counted, not edged', () => {
|
|
85
|
+
const facts = [
|
|
86
|
+
fact('a', 'a', [
|
|
87
|
+
{ path: 'a/one.js', language: 'javascript', symbols: [], deps: ['../nonexistent'] },
|
|
88
|
+
]),
|
|
89
|
+
];
|
|
90
|
+
const graph = g.buildModuleGraph(facts);
|
|
91
|
+
assert.equal(graph.edge_count, 0);
|
|
92
|
+
assert.equal(graph.metrics.unresolved_internal_deps, 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('CG-7: Tarjan detects a 2-module cycle', () => {
|
|
96
|
+
const facts = [
|
|
97
|
+
fact('a', 'a', [{ path: 'a/x.js', language: 'javascript', symbols: [], deps: ['../b'] }]),
|
|
98
|
+
fact('b', 'b', [{ path: 'b/index.js', language: 'javascript', symbols: [], deps: ['../a'] }]),
|
|
99
|
+
];
|
|
100
|
+
const graph = g.buildModuleGraph(facts);
|
|
101
|
+
assert.equal(graph.cycles.length, 1);
|
|
102
|
+
assert.deepEqual(graph.cycles[0], ['a', 'b']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('CG-8: acyclic graph reports no cycles', () => {
|
|
106
|
+
const facts = [
|
|
107
|
+
fact('a', 'a', [{ path: 'a/x.js', language: 'javascript', symbols: [], deps: ['../b'] }]),
|
|
108
|
+
fact('b', 'b', [{ path: 'b/index.js', language: 'javascript', symbols: [], deps: ['../c'] }]),
|
|
109
|
+
fact('c', 'c', [{ path: 'c/index.js', language: 'javascript', symbols: [], deps: [] }]),
|
|
110
|
+
];
|
|
111
|
+
const graph = g.buildModuleGraph(facts);
|
|
112
|
+
assert.equal(graph.cycles.length, 0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('CG-9: transitive dependents (impact) walk the reverse graph', () => {
|
|
116
|
+
const facts = [
|
|
117
|
+
fact('a', 'a', [{ path: 'a/x.js', language: 'javascript', symbols: [], deps: ['../b'] }]),
|
|
118
|
+
fact('b', 'b', [{ path: 'b/index.js', language: 'javascript', symbols: [], deps: ['../c'] }]),
|
|
119
|
+
fact('c', 'c', [{ path: 'c/index.js', language: 'javascript', symbols: [], deps: [] }]),
|
|
120
|
+
];
|
|
121
|
+
const graph = g.buildModuleGraph(facts);
|
|
122
|
+
assert.deepEqual(g.transitiveDependents(graph, 'c'), ['a', 'b']);
|
|
123
|
+
assert.deepEqual(g.directDependents(graph, 'c'), ['b']);
|
|
124
|
+
assert.deepEqual(g.transitiveDependencies(graph, 'a'), ['b', 'c']);
|
|
125
|
+
assert.deepEqual(g.directDependencies(graph, 'a'), ['b']);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('CG-10: deterministic clustering groups a connected component together', () => {
|
|
129
|
+
const facts = [
|
|
130
|
+
fact('a', 'a', [{ path: 'a/x.js', language: 'javascript', symbols: [], deps: ['../b'] }]),
|
|
131
|
+
fact('b', 'b', [{ path: 'b/index.js', language: 'javascript', symbols: [], deps: ['../a'] }]),
|
|
132
|
+
fact('lonely', 'lonely', [{ path: 'lonely/z.js', language: 'javascript', symbols: [], deps: [] }]),
|
|
133
|
+
];
|
|
134
|
+
const graph = g.buildModuleGraph(facts);
|
|
135
|
+
const first = g.buildModuleGraph(facts);
|
|
136
|
+
assert.deepEqual(graph.clusters, first.clusters);
|
|
137
|
+
const clusterAB = graph.clusters.find((c) => c.members.includes('a'));
|
|
138
|
+
assert.ok(clusterAB.members.includes('b'));
|
|
139
|
+
assert.ok(!clusterAB.members.includes('lonely'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('CG-11: cycleFor and clusterOf locate a module', () => {
|
|
143
|
+
const facts = [
|
|
144
|
+
fact('a', 'a', [{ path: 'a/x.js', language: 'javascript', symbols: [], deps: ['../b'] }]),
|
|
145
|
+
fact('b', 'b', [{ path: 'b/index.js', language: 'javascript', symbols: [], deps: ['../a'] }]),
|
|
146
|
+
];
|
|
147
|
+
const graph = g.buildModuleGraph(facts);
|
|
148
|
+
assert.deepEqual(g.cycleFor(graph, 'a'), ['a', 'b']);
|
|
149
|
+
assert.equal(typeof g.clusterOf(graph, 'a'), 'number');
|
|
150
|
+
assert.equal(g.cycleFor(graph, 'missing'), null);
|
|
151
|
+
assert.equal(g.clusterOf(graph, 'missing'), null);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('CG-12: root-relative (/-prefixed) deps resolve from project root', () => {
|
|
155
|
+
const facts = [
|
|
156
|
+
fact('src-auth', 'src/auth', [
|
|
157
|
+
{ path: 'src/auth/login.js', language: 'javascript', symbols: [], deps: ['/src/db'] },
|
|
158
|
+
]),
|
|
159
|
+
fact('src-db', 'src/db', [
|
|
160
|
+
{ path: 'src/db/index.js', language: 'javascript', symbols: [], deps: [] },
|
|
161
|
+
]),
|
|
162
|
+
];
|
|
163
|
+
const graph = g.buildModuleGraph(facts);
|
|
164
|
+
assert.equal(graph.edge_count, 1);
|
|
165
|
+
assert.deepEqual(graph.edges[0], { from: 'src-auth', to: 'src-db', weight: 1 });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('CG-13: empty facts yield an empty graph', () => {
|
|
169
|
+
const graph = g.buildModuleGraph([]);
|
|
170
|
+
assert.equal(graph.module_count, 0);
|
|
171
|
+
assert.equal(graph.edge_count, 0);
|
|
172
|
+
assert.deepEqual(graph.cycles, []);
|
|
173
|
+
assert.deepEqual(graph.clusters, []);
|
|
174
|
+
});
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { atomicWriteFileSync, NubosPilotError } = require('./core.cjs');
|
|
6
|
+
const { assertValid } = require('./validate.cjs');
|
|
6
7
|
|
|
7
8
|
const SCHEMA_VERSION = 1;
|
|
9
|
+
const STORE_SCHEMA = 'codebase-manifest.v1';
|
|
8
10
|
const CODEBASE_DIR_NAME = 'codebase';
|
|
9
11
|
const MANIFEST_FILENAME = '.hashes.json';
|
|
10
12
|
|
|
@@ -63,6 +65,7 @@ function readManifest(projectRoot) {
|
|
|
63
65
|
);
|
|
64
66
|
}
|
|
65
67
|
if (!parsed.files || typeof parsed.files !== 'object') parsed.files = {};
|
|
68
|
+
assertValid(parsed, STORE_SCHEMA, 'manifest-invalid-shape', { path: p });
|
|
66
69
|
return parsed;
|
|
67
70
|
}
|
|
68
71
|
|
package/lib/config-defaults.cjs
CHANGED
|
@@ -41,6 +41,23 @@ const DEFAULT_SWARM = Object.freeze({
|
|
|
41
41
|
knowledge_adapter: 'local',
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
const DEFAULT_SECURITY = Object.freeze({
|
|
45
|
+
enabled: true,
|
|
46
|
+
scan_on_write: true,
|
|
47
|
+
review_on_stop: true,
|
|
48
|
+
review_on_commit: true,
|
|
49
|
+
custom_rules_path: null,
|
|
50
|
+
guidance_path: null,
|
|
51
|
+
review_timeout_ms: 180000,
|
|
52
|
+
max_stop_reviews_in_a_row: 3,
|
|
53
|
+
max_commit_reviews_per_hour: 20,
|
|
54
|
+
max_files_per_review: 30,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const DEFAULT_CONFORMANCE = Object.freeze({
|
|
58
|
+
inject_criteria: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
44
61
|
const DEFAULT_AUTO_LOG_LEARNING = true;
|
|
45
62
|
|
|
46
63
|
const DEFAULT_SPAWN_HEADLESS = Object.freeze({
|
|
@@ -67,6 +84,8 @@ const DEFAULT_CONFIG_TREE = Object.freeze({
|
|
|
67
84
|
loop: DEFAULT_LOOP,
|
|
68
85
|
swarm: DEFAULT_SWARM,
|
|
69
86
|
spawn: DEFAULT_SPAWN,
|
|
87
|
+
security: DEFAULT_SECURITY,
|
|
88
|
+
conformance: DEFAULT_CONFORMANCE,
|
|
70
89
|
auto_log_learning: DEFAULT_AUTO_LOG_LEARNING,
|
|
71
90
|
});
|
|
72
91
|
|
|
@@ -98,6 +117,8 @@ function buildInstallConfig(answers) {
|
|
|
98
117
|
fallback_on_error: DEFAULT_SPAWN_HEADLESS.fallback_on_error,
|
|
99
118
|
},
|
|
100
119
|
},
|
|
120
|
+
security: { ...DEFAULT_SECURITY },
|
|
121
|
+
conformance: { ...DEFAULT_CONFORMANCE },
|
|
101
122
|
auto_log_learning: DEFAULT_AUTO_LOG_LEARNING,
|
|
102
123
|
};
|
|
103
124
|
}
|
|
@@ -112,6 +133,8 @@ module.exports = {
|
|
|
112
133
|
DEFAULT_SWARM_CRITIC,
|
|
113
134
|
DEFAULT_SPAWN,
|
|
114
135
|
DEFAULT_SPAWN_HEADLESS,
|
|
136
|
+
DEFAULT_SECURITY,
|
|
137
|
+
DEFAULT_CONFORMANCE,
|
|
115
138
|
DEFAULT_AUTO_LOG_LEARNING,
|
|
116
139
|
DEFAULT_MODEL_PROFILE,
|
|
117
140
|
DEFAULT_SCOPE,
|
|
@@ -69,3 +69,18 @@ test('CFD-7: end-to-end — user answers "true" via askUser → commit_artifacts
|
|
|
69
69
|
try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
test('CFD-SEC-1: buildInstallConfig writes always-on security defaults', () => {
|
|
74
|
+
const cfg = buildInstallConfig({ runtime: 'claude' });
|
|
75
|
+
assert.equal(cfg.security.enabled, true);
|
|
76
|
+
assert.equal(cfg.security.scan_on_write, true);
|
|
77
|
+
assert.equal(cfg.security.review_on_stop, true);
|
|
78
|
+
assert.equal(cfg.security.review_on_commit, true);
|
|
79
|
+
assert.equal(cfg.security.custom_rules_path, null);
|
|
80
|
+
assert.equal(cfg.security.max_files_per_review, 30);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('CFD-CONF-1: buildInstallConfig writes conformance.inject_criteria default', () => {
|
|
84
|
+
const cfg = buildInstallConfig({ runtime: 'claude' });
|
|
85
|
+
assert.equal(cfg.conformance.inject_criteria, true);
|
|
86
|
+
});
|
package/lib/config-schema.cjs
CHANGED
|
@@ -67,6 +67,25 @@ const SCHEMA = Object.freeze({
|
|
|
67
67
|
},
|
|
68
68
|
},
|
|
69
69
|
},
|
|
70
|
+
security: {
|
|
71
|
+
type: 'object', optional: true, shape: {
|
|
72
|
+
enabled: { type: 'boolean', optional: true },
|
|
73
|
+
scan_on_write: { type: 'boolean', optional: true },
|
|
74
|
+
review_on_stop: { type: 'boolean', optional: true },
|
|
75
|
+
review_on_commit: { type: 'boolean', optional: true },
|
|
76
|
+
custom_rules_path: { type: 'any', optional: true }, // string | null
|
|
77
|
+
guidance_path: { type: 'any', optional: true }, // string | null
|
|
78
|
+
review_timeout_ms: { type: 'number', optional: true },
|
|
79
|
+
max_stop_reviews_in_a_row: { type: 'number', optional: true },
|
|
80
|
+
max_commit_reviews_per_hour:{ type: 'number', optional: true },
|
|
81
|
+
max_files_per_review: { type: 'number', optional: true },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
conformance: {
|
|
85
|
+
type: 'object', optional: true, shape: {
|
|
86
|
+
inject_criteria: { type: 'boolean', optional: true },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
70
89
|
});
|
|
71
90
|
|
|
72
91
|
function _typeOf(v) {
|
|
@@ -146,3 +146,61 @@ test('SCHEMA-SYNC-1 every top-level key in DEFAULT_CONFIG_TREE has a SCHEMA entr
|
|
|
146
146
|
'SCHEMA.' + key + ' is neither in DEFAULT_CONFIG_TREE nor SCHEMA_ONLY_KEYS — drift');
|
|
147
147
|
}
|
|
148
148
|
});
|
|
149
|
+
|
|
150
|
+
test('SEC-CFG-1 valid security block produces zero warnings', () => {
|
|
151
|
+
const w = validateConfig({
|
|
152
|
+
security: {
|
|
153
|
+
enabled: true,
|
|
154
|
+
scan_on_write: true,
|
|
155
|
+
review_on_stop: false,
|
|
156
|
+
review_on_commit: true,
|
|
157
|
+
custom_rules_path: '.nubos-pilot/security-rules.json',
|
|
158
|
+
guidance_path: null,
|
|
159
|
+
review_timeout_ms: 120000,
|
|
160
|
+
max_stop_reviews_in_a_row: 3,
|
|
161
|
+
max_commit_reviews_per_hour: 20,
|
|
162
|
+
max_files_per_review: 30,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
assert.deepEqual(w, []);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('SEC-CFG-2 wrong type in security flags is flagged', () => {
|
|
169
|
+
const w = validateConfig({ security: { enabled: 'yes', max_files_per_review: 'lots' } });
|
|
170
|
+
assert.equal(w.length, 2);
|
|
171
|
+
assert.ok(w.every((x) => x.kind === 'invalid-type'));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('SEC-CFG-3 unknown security sub-key is flagged', () => {
|
|
175
|
+
const w = validateConfig({ security: { scan_everywhere: true } });
|
|
176
|
+
assert.equal(w.length, 1);
|
|
177
|
+
assert.equal(w[0].kind, 'unknown-key');
|
|
178
|
+
assert.equal(w[0].path, 'security.scan_everywhere');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('SEC-CFG-4 default security tree validates clean', () => {
|
|
182
|
+
const defaults = require('./config-defaults.cjs');
|
|
183
|
+
assert.deepEqual(validateConfig({ security: defaults.DEFAULT_SECURITY }), []);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('CONF-CFG-1 valid conformance block produces zero warnings', () => {
|
|
187
|
+
assert.deepEqual(validateConfig({ conformance: { inject_criteria: true } }), []);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('CONF-CFG-2 wrong type in conformance.inject_criteria is flagged', () => {
|
|
191
|
+
const w = validateConfig({ conformance: { inject_criteria: 'yes' } });
|
|
192
|
+
assert.equal(w.length, 1);
|
|
193
|
+
assert.equal(w[0].kind, 'invalid-type');
|
|
194
|
+
assert.equal(w[0].path, 'conformance.inject_criteria');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('CONF-CFG-3 unknown conformance sub-key is flagged', () => {
|
|
198
|
+
const w = validateConfig({ conformance: { review_on_executor_stop: true } });
|
|
199
|
+
assert.equal(w.length, 1);
|
|
200
|
+
assert.equal(w[0].kind, 'unknown-key');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('CONF-CFG-4 default conformance tree validates clean', () => {
|
|
204
|
+
const defaults = require('./config-defaults.cjs');
|
|
205
|
+
assert.deepEqual(validateConfig({ conformance: defaults.DEFAULT_CONFORMANCE }), []);
|
|
206
|
+
});
|