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.
Files changed (47) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/agents/np-executor.md +20 -0
  3. package/agents/np-security-reviewer.md +49 -3
  4. package/bin/install.js +7 -2
  5. package/bin/np-tools/_commands.cjs +2 -0
  6. package/bin/np-tools/doctor.cjs +15 -2
  7. package/bin/np-tools/graph-impact.cjs +111 -0
  8. package/bin/np-tools/graph-impact.test.cjs +119 -0
  9. package/bin/np-tools/scan-codebase.cjs +21 -1
  10. package/bin/np-tools/security.cjs +177 -0
  11. package/bin/np-tools/security.test.cjs +82 -0
  12. package/lib/checkpoint.cjs +3 -0
  13. package/lib/codebase-graph.cjs +0 -0
  14. package/lib/codebase-graph.test.cjs +174 -0
  15. package/lib/codebase-manifest.cjs +3 -0
  16. package/lib/config-defaults.cjs +23 -0
  17. package/lib/config-defaults.test.cjs +15 -0
  18. package/lib/config-schema.cjs +19 -0
  19. package/lib/config-schema.test.cjs +58 -0
  20. package/lib/install/claude-hooks.cjs +100 -7
  21. package/lib/install/claude-hooks.test.cjs +96 -0
  22. package/lib/learnings.cjs +19 -95
  23. package/lib/memory.cjs +38 -33
  24. package/lib/messaging.cjs +12 -6
  25. package/lib/metrics-aggregate.cjs +14 -2
  26. package/lib/migrate.cjs +29 -0
  27. package/lib/migrate.test.cjs +91 -0
  28. package/lib/schemas/data/checkpoint.v1.json +13 -0
  29. package/lib/schemas/data/codebase-manifest.v1.json +22 -0
  30. package/lib/schemas/data/learnings.v1.json +28 -0
  31. package/lib/schemas/data/memory-manifest.v1.json +14 -0
  32. package/lib/schemas/data/memory-record.v1.json +16 -0
  33. package/lib/schemas/data/message.v1.json +19 -0
  34. package/lib/schemas/data/metrics-record.v1.json +11 -0
  35. package/lib/security/ledger.cjs +203 -0
  36. package/lib/security/ledger.test.cjs +139 -0
  37. package/lib/security/patterns.cjs +119 -0
  38. package/lib/security/review.cjs +220 -0
  39. package/lib/security/review.test.cjs +143 -0
  40. package/lib/security/scan.cjs +180 -0
  41. package/lib/security/scan.test.cjs +137 -0
  42. package/lib/validate.cjs +301 -0
  43. package/lib/validate.test.cjs +242 -0
  44. package/np-tools.cjs +2 -0
  45. package/package.json +3 -1
  46. package/templates/claude/payload/hooks/np-security-hook.cjs +50 -0
  47. package/workflows/execute-phase.md +11 -1
@@ -0,0 +1,301 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { NubosPilotError } = require('./core.cjs');
6
+
7
+ const SCHEMA_DIR = path.join(__dirname, 'schemas', 'data');
8
+ const PATTERN_INPUT_MAX = 64 * 1024;
9
+ const _cache = new Map();
10
+
11
+ function _hasOwn(obj, key) {
12
+ return Object.prototype.hasOwnProperty.call(obj, key);
13
+ }
14
+
15
+ function _deepFreeze(obj) {
16
+ if (obj && typeof obj === 'object' && !Object.isFrozen(obj)) {
17
+ Object.freeze(obj);
18
+ for (const key of Object.keys(obj)) _deepFreeze(obj[key]);
19
+ }
20
+ return obj;
21
+ }
22
+
23
+ function _deepEqual(a, b) {
24
+ if (a === b) return true;
25
+ if (typeof a !== typeof b) return false;
26
+ if (a === null || b === null) return a === b;
27
+ if (typeof a !== 'object') return false;
28
+ const aArr = Array.isArray(a);
29
+ if (aArr !== Array.isArray(b)) return false;
30
+ if (aArr) {
31
+ if (a.length !== b.length) return false;
32
+ for (let i = 0; i < a.length; i += 1) if (!_deepEqual(a[i], b[i])) return false;
33
+ return true;
34
+ }
35
+ const aKeys = Object.keys(a);
36
+ if (aKeys.length !== Object.keys(b).length) return false;
37
+ for (const key of aKeys) {
38
+ if (!_hasOwn(b, key) || !_deepEqual(a[key], b[key])) return false;
39
+ }
40
+ return true;
41
+ }
42
+
43
+ function _loadSchema(name) {
44
+ if (_cache.has(name)) return _cache.get(name);
45
+ if (!/^[a-z0-9][a-z0-9.\-]*$/.test(String(name))) {
46
+ throw new NubosPilotError(
47
+ 'data-schema-not-found',
48
+ 'Invalid data-schema name: ' + JSON.stringify(name),
49
+ { name },
50
+ );
51
+ }
52
+ const p = path.join(SCHEMA_DIR, name + '.json');
53
+ let raw;
54
+ try { raw = fs.readFileSync(p, 'utf-8'); }
55
+ catch (err) {
56
+ throw new NubosPilotError(
57
+ 'data-schema-not-found',
58
+ 'Unknown data schema: ' + String(name),
59
+ { name, cause: err && err.code, available: listSchemas() },
60
+ );
61
+ }
62
+ let schema;
63
+ try { schema = JSON.parse(raw); }
64
+ catch (err) {
65
+ throw new NubosPilotError(
66
+ 'data-schema-corrupt',
67
+ 'Data schema ' + name + '.json is not valid JSON: ' + (err && err.message),
68
+ { name },
69
+ );
70
+ }
71
+ _deepFreeze(schema);
72
+ _cache.set(name, schema);
73
+ return schema;
74
+ }
75
+
76
+ function listSchemas() {
77
+ let entries;
78
+ try { entries = fs.readdirSync(SCHEMA_DIR); }
79
+ catch { return []; }
80
+ return entries
81
+ .filter((f) => f.endsWith('.json'))
82
+ .map((f) => f.slice(0, -5))
83
+ .sort();
84
+ }
85
+
86
+ function _typeOf(value) {
87
+ if (value === null) return 'null';
88
+ if (Array.isArray(value)) return 'array';
89
+ if (Number.isInteger(value)) return 'integer';
90
+ return typeof value;
91
+ }
92
+
93
+ function _matchesType(value, type) {
94
+ if (type === 'integer') return typeof value === 'number' && Number.isInteger(value);
95
+ if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
96
+ if (type === 'array') return Array.isArray(value);
97
+ if (type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
98
+ if (type === 'null') return value === null;
99
+ return typeof value === type;
100
+ }
101
+
102
+ function _segmentsToPath(segments) {
103
+ if (!segments.length) return '';
104
+ return '/' + segments.map((s) => String(s)).join('/');
105
+ }
106
+
107
+ function _lastNamed(segments) {
108
+ for (let i = segments.length - 1; i >= 0; i -= 1) {
109
+ if (typeof segments[i] === 'string') return segments[i];
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function _firstIndex(segments) {
115
+ for (const s of segments) if (typeof s === 'number') return s;
116
+ return null;
117
+ }
118
+
119
+ function _push(errors, segments, keyword, message, extra) {
120
+ const err = {
121
+ instancePath: _segmentsToPath(segments),
122
+ keyword,
123
+ message,
124
+ field: _lastNamed(segments),
125
+ index: _firstIndex(segments),
126
+ };
127
+ if (extra) Object.assign(err, extra);
128
+ errors.push(err);
129
+ }
130
+
131
+ function _validateNode(value, schema, segments, errors) {
132
+ if (!schema || typeof schema !== 'object') return;
133
+
134
+ if ('const' in schema && !_deepEqual(value, schema.const)) {
135
+ _push(errors, segments, 'const', _label(segments) + ' must equal ' + JSON.stringify(schema.const),
136
+ { expected: schema.const, actual: value });
137
+ return;
138
+ }
139
+
140
+ if (schema.type) {
141
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
142
+ if (!types.some((t) => _matchesType(value, t))) {
143
+ _push(errors, segments, 'type',
144
+ _label(segments) + ' must be ' + types.join(' or ') + ' (got ' + _typeOf(value) + ')',
145
+ { expected: schema.type, actual: _typeOf(value) });
146
+ return;
147
+ }
148
+ }
149
+
150
+ if (Array.isArray(schema.enum) && !schema.enum.some((e) => _deepEqual(e, value))) {
151
+ _push(errors, segments, 'enum',
152
+ _label(segments) + ' must be one of ' + JSON.stringify(schema.enum) + ' (got ' + JSON.stringify(value) + ')',
153
+ { expected: schema.enum, actual: value });
154
+ }
155
+
156
+ if (typeof value === 'string') _validateString(value, schema, segments, errors);
157
+ if (typeof value === 'number') _validateNumber(value, schema, segments, errors);
158
+ if (Array.isArray(value)) _validateArray(value, schema, segments, errors);
159
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
160
+ _validateObject(value, schema, segments, errors);
161
+ }
162
+ }
163
+
164
+ function _label(segments) {
165
+ const named = _lastNamed(segments);
166
+ const idx = _firstIndex(segments);
167
+ if (named && idx !== null && segments[segments.length - 1] === named) {
168
+ return named;
169
+ }
170
+ return named || (idx !== null ? '[' + idx + ']' : 'value');
171
+ }
172
+
173
+ function _validateString(value, schema, segments, errors) {
174
+ if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
175
+ _push(errors, segments, 'minLength',
176
+ _label(segments) + ' must be at least ' + schema.minLength + ' characters',
177
+ { expected: schema.minLength, actual: value.length });
178
+ }
179
+ if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
180
+ _push(errors, segments, 'maxLength',
181
+ _label(segments) + ' must be at most ' + schema.maxLength + ' characters',
182
+ { expected: schema.maxLength, actual: value.length });
183
+ }
184
+ if (typeof schema.maxBytes === 'number') {
185
+ const bytes = Buffer.byteLength(value, 'utf-8');
186
+ if (bytes > schema.maxBytes) {
187
+ _push(errors, segments, 'maxBytes',
188
+ _label(segments) + ' exceeds ' + schema.maxBytes + ' bytes (got ' + bytes + ')',
189
+ { expected: schema.maxBytes, actual: bytes });
190
+ }
191
+ }
192
+ if (typeof schema.pattern === 'string') {
193
+ if (value.length > PATTERN_INPUT_MAX) {
194
+ _push(errors, segments, 'pattern',
195
+ _label(segments) + ' is too long (' + value.length + ' chars) to match ' + schema.pattern,
196
+ { expected: schema.pattern, actual: value.length });
197
+ } else if (!new RegExp(schema.pattern).test(value)) {
198
+ _push(errors, segments, 'pattern',
199
+ _label(segments) + ' must match ' + schema.pattern,
200
+ { expected: schema.pattern, actual: value });
201
+ }
202
+ }
203
+ }
204
+
205
+ function _validateNumber(value, schema, segments, errors) {
206
+ if (typeof schema.minimum === 'number' && value < schema.minimum) {
207
+ _push(errors, segments, 'minimum',
208
+ _label(segments) + ' must be >= ' + schema.minimum + ' (got ' + value + ')',
209
+ { expected: schema.minimum, actual: value });
210
+ }
211
+ if (typeof schema.exclusiveMinimum === 'number' && value <= schema.exclusiveMinimum) {
212
+ _push(errors, segments, 'exclusiveMinimum',
213
+ _label(segments) + ' must be > ' + schema.exclusiveMinimum + ' (got ' + value + ')',
214
+ { expected: schema.exclusiveMinimum, actual: value });
215
+ }
216
+ if (typeof schema.maximum === 'number' && value > schema.maximum) {
217
+ _push(errors, segments, 'maximum',
218
+ _label(segments) + ' must be <= ' + schema.maximum + ' (got ' + value + ')',
219
+ { expected: schema.maximum, actual: value });
220
+ }
221
+ }
222
+
223
+ function _validateArray(value, schema, segments, errors) {
224
+ if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
225
+ _push(errors, segments, 'minItems',
226
+ _label(segments) + ' must have at least ' + schema.minItems + ' items',
227
+ { expected: schema.minItems, actual: value.length });
228
+ }
229
+ if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
230
+ _push(errors, segments, 'maxItems',
231
+ _label(segments) + ' must have at most ' + schema.maxItems + ' items',
232
+ { expected: schema.maxItems, actual: value.length });
233
+ }
234
+ if (schema.items) {
235
+ for (let i = 0; i < value.length; i += 1) {
236
+ _validateNode(value[i], schema.items, segments.concat(i), errors);
237
+ }
238
+ }
239
+ }
240
+
241
+ function _validateObject(value, schema, segments, errors) {
242
+ if (Array.isArray(schema.required)) {
243
+ for (const field of schema.required) {
244
+ if (!_hasOwn(value, field) || value[field] === undefined) {
245
+ _push(errors, segments.concat(field), 'required',
246
+ _objLabel(segments) + ' missing required field "' + field + '"',
247
+ { field });
248
+ }
249
+ }
250
+ }
251
+ const props = schema.properties || {};
252
+ const addl = schema.additionalProperties;
253
+ if (addl === false) {
254
+ for (const key of Object.keys(value)) {
255
+ if (!_hasOwn(props, key) && value[key] !== undefined) {
256
+ _push(errors, segments.concat(key), 'additionalProperties',
257
+ _objLabel(segments) + ' has unknown field "' + key + '"',
258
+ { field: key });
259
+ }
260
+ }
261
+ } else if (addl && typeof addl === 'object') {
262
+ for (const key of Object.keys(value)) {
263
+ if (!_hasOwn(props, key) && value[key] !== undefined) {
264
+ _validateNode(value[key], addl, segments.concat(key), errors);
265
+ }
266
+ }
267
+ }
268
+ for (const key of Object.keys(props)) {
269
+ if (_hasOwn(value, key) && value[key] !== undefined) {
270
+ _validateNode(value[key], props[key], segments.concat(key), errors);
271
+ }
272
+ }
273
+ }
274
+
275
+ function _objLabel(segments) {
276
+ const idx = _firstIndex(segments);
277
+ const named = _lastNamed(segments);
278
+ if (named) return named;
279
+ if (idx !== null) return '[' + idx + ']';
280
+ return 'object';
281
+ }
282
+
283
+ function validate(value, schemaName) {
284
+ const schema = typeof schemaName === 'string' ? _loadSchema(schemaName) : schemaName;
285
+ const errors = [];
286
+ _validateNode(value, schema, [], errors);
287
+ return errors;
288
+ }
289
+
290
+ function assertValid(value, schemaName, code, baseDetails) {
291
+ const errors = validate(value, schemaName);
292
+ if (errors.length === 0) return;
293
+ const first = errors[0];
294
+ throw new NubosPilotError(
295
+ code,
296
+ first.message,
297
+ Object.assign({ schema: schemaName, errors }, first, baseDetails || {}),
298
+ );
299
+ }
300
+
301
+ module.exports = { validate, assertValid, listSchemas, _loadSchema, SCHEMA_DIR };
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const { validate, assertValid, listSchemas, _loadSchema } = require('./validate.cjs');
7
+ const { NubosPilotError } = require('./core.cjs');
8
+ const learnings = require('./learnings.cjs');
9
+ const memory = require('./memory.cjs');
10
+ const messaging = require('./messaging.cjs');
11
+
12
+ const RECORD = {
13
+ type: 'object',
14
+ required: ['fingerprint', 'occurrence'],
15
+ properties: {
16
+ fingerprint: { type: 'string', pattern: '^[a-f0-9]{16}$' },
17
+ occurrence: { type: 'integer', minimum: 1 },
18
+ pattern: { type: 'string', maxBytes: 8 },
19
+ status: { type: 'string', enum: ['a', 'b'] },
20
+ tags: { type: 'array', items: { type: 'string' } },
21
+ },
22
+ };
23
+
24
+ test('VAL-1: valid object yields no errors', () => {
25
+ assert.deepEqual(validate({ fingerprint: 'aaaaaaaaaaaaaaaa', occurrence: 3 }, RECORD), []);
26
+ });
27
+
28
+ test('VAL-2: missing required field reports field name', () => {
29
+ const errs = validate({ fingerprint: 'aaaaaaaaaaaaaaaa' }, RECORD);
30
+ assert.equal(errs.length, 1);
31
+ assert.equal(errs[0].keyword, 'required');
32
+ assert.equal(errs[0].field, 'occurrence');
33
+ });
34
+
35
+ test('VAL-3: type mismatch reports expected/actual', () => {
36
+ const errs = validate({ fingerprint: 'aaaaaaaaaaaaaaaa', occurrence: 'x' }, RECORD);
37
+ assert.equal(errs[0].keyword, 'type');
38
+ assert.equal(errs[0].field, 'occurrence');
39
+ assert.equal(errs[0].actual, 'string');
40
+ });
41
+
42
+ test('VAL-4: pattern mismatch', () => {
43
+ const errs = validate({ fingerprint: 'NOTHEX', occurrence: 1 }, RECORD);
44
+ assert.equal(errs[0].keyword, 'pattern');
45
+ assert.match(errs[0].message, /fingerprint/);
46
+ });
47
+
48
+ test('VAL-5: minimum violation', () => {
49
+ const errs = validate({ fingerprint: 'aaaaaaaaaaaaaaaa', occurrence: 0 }, RECORD);
50
+ assert.equal(errs[0].keyword, 'minimum');
51
+ });
52
+
53
+ test('VAL-6: non-integer rejected by integer type', () => {
54
+ const errs = validate({ fingerprint: 'aaaaaaaaaaaaaaaa', occurrence: 1.5 }, RECORD);
55
+ assert.equal(errs[0].keyword, 'type');
56
+ });
57
+
58
+ test('VAL-7: maxBytes counts UTF-8 bytes', () => {
59
+ const errs = validate({ fingerprint: 'aaaaaaaaaaaaaaaa', occurrence: 1, pattern: 'üüüüü' }, RECORD);
60
+ assert.equal(errs[0].keyword, 'maxBytes');
61
+ assert.equal(errs[0].actual, 10);
62
+ });
63
+
64
+ test('VAL-8: enum violation', () => {
65
+ const errs = validate({ fingerprint: 'aaaaaaaaaaaaaaaa', occurrence: 1, status: 'z' }, RECORD);
66
+ assert.equal(errs[0].keyword, 'enum');
67
+ });
68
+
69
+ test('VAL-9: nested array items validated with index in path', () => {
70
+ const errs = validate({ fingerprint: 'aaaaaaaaaaaaaaaa', occurrence: 1, tags: ['ok', 5] }, RECORD);
71
+ assert.equal(errs[0].keyword, 'type');
72
+ assert.equal(errs[0].index, 1);
73
+ assert.equal(errs[0].instancePath, '/tags/1');
74
+ });
75
+
76
+ test('VAL-10: assertValid throws NubosPilotError with given code and errors detail', () => {
77
+ assert.throws(
78
+ () => assertValid({ fingerprint: 'NOTHEX' }, RECORD, 'demo-corrupt', { path: '/tmp/x' }),
79
+ (err) => err instanceof NubosPilotError
80
+ && err.code === 'demo-corrupt'
81
+ && Array.isArray(err.details.errors)
82
+ && err.details.path === '/tmp/x',
83
+ );
84
+ });
85
+
86
+ test('VAL-11: assertValid is a no-op when valid', () => {
87
+ assert.doesNotThrow(() => assertValid({ fingerprint: 'aaaaaaaaaaaaaaaa', occurrence: 1 }, RECORD, 'demo-corrupt'));
88
+ });
89
+
90
+ test('VAL-12: unknown schema name throws data-schema-not-found', () => {
91
+ assert.throws(
92
+ () => validate({}, 'no-such-schema'),
93
+ (err) => err instanceof NubosPilotError && err.code === 'data-schema-not-found',
94
+ );
95
+ });
96
+
97
+ test('VAL-13: invalid schema name is rejected before fs access', () => {
98
+ assert.throws(
99
+ () => validate({}, '../etc/passwd'),
100
+ (err) => err instanceof NubosPilotError && err.code === 'data-schema-not-found',
101
+ );
102
+ });
103
+
104
+ test('VAL-14: learnings.v1 is registered and loadable', () => {
105
+ assert.ok(listSchemas().includes('learnings.v1'));
106
+ const schema = _loadSchema('learnings.v1');
107
+ assert.equal(schema.$id, 'learnings.v1');
108
+ });
109
+
110
+ test('VAL-15: learnings.v1 pattern maxBytes stays in sync with MAX_PATTERN_BYTES', () => {
111
+ const schema = _loadSchema('learnings.v1');
112
+ const patternSchema = schema.properties.learnings.items.properties.pattern;
113
+ assert.equal(
114
+ patternSchema.maxBytes,
115
+ learnings.MAX_PATTERN_BYTES,
116
+ 'learnings.v1.json pattern.maxBytes drifted from learnings.MAX_PATTERN_BYTES',
117
+ );
118
+ });
119
+
120
+ test('VAL-16: learnings.v1 outcome maxBytes stays in sync with MAX_OUTCOME_BYTES', () => {
121
+ const schema = _loadSchema('learnings.v1');
122
+ const outcomeSchema = schema.properties.learnings.items.properties.outcome;
123
+ assert.equal(
124
+ outcomeSchema.maxBytes,
125
+ learnings.MAX_OUTCOME_BYTES,
126
+ 'learnings.v1.json outcome.maxBytes drifted from learnings.MAX_OUTCOME_BYTES',
127
+ );
128
+ });
129
+
130
+ test('VAL-17: additionalProperties:false flags inherited-name own keys (no prototype-chain bypass)', () => {
131
+ const schema = { type: 'object', additionalProperties: false, properties: { a: { type: 'string' } } };
132
+ const input = JSON.parse('{"a":"x","constructor":1,"toString":1}');
133
+ const errs = validate(input, schema);
134
+ const unknown = errs.filter((e) => e.keyword === 'additionalProperties').map((e) => e.field).sort();
135
+ assert.deepEqual(unknown, ['constructor', 'toString']);
136
+ });
137
+
138
+ test('VAL-18: required is satisfied only by own properties, not Object.prototype members', () => {
139
+ const schema = { type: 'object', required: ['toString', 'fingerprint'] };
140
+ const errs = validate({}, schema);
141
+ const missing = errs.filter((e) => e.keyword === 'required').map((e) => e.field).sort();
142
+ assert.deepEqual(missing, ['fingerprint', 'toString']);
143
+ });
144
+
145
+ test('VAL-19: a property named like a prototype member does not validate the inherited value', () => {
146
+ const schema = { type: 'object', properties: { toString: { type: 'string' } } };
147
+ const errs = validate(JSON.parse('{"a":1}'), schema);
148
+ assert.deepEqual(errs, []);
149
+ });
150
+
151
+ test('VAL-20: pattern does not run on pathological oversized input (ReDoS guard)', () => {
152
+ const schema = { type: 'string', pattern: '^(a+)+$' };
153
+ const errs = validate('a'.repeat(70000) + '!', schema);
154
+ assert.equal(errs[0].keyword, 'pattern');
155
+ assert.match(errs[0].message, /too long/);
156
+ });
157
+
158
+ test('VAL-21: enum/const equality is key-order independent', () => {
159
+ const enumSchema = { enum: [{ a: 1, b: 2 }] };
160
+ assert.deepEqual(validate(JSON.parse('{"b":2,"a":1}'), enumSchema), []);
161
+ const constSchema = { const: { a: 1, b: 2 } };
162
+ assert.deepEqual(validate(JSON.parse('{"b":2,"a":1}'), constSchema), []);
163
+ });
164
+
165
+ test('VAL-23: memory-record.v1 type enum stays in sync with TYPE_ENUM', () => {
166
+ const schema = _loadSchema('memory-record.v1');
167
+ assert.deepEqual(schema.properties.type.enum, [...memory.TYPE_ENUM]);
168
+ });
169
+
170
+ test('VAL-24: memory-record.v1 provenance enum stays in sync with PROVENANCE_ENUM', () => {
171
+ const schema = _loadSchema('memory-record.v1');
172
+ const nonNull = schema.properties.provenance.enum.filter((e) => e !== null);
173
+ assert.deepEqual(nonNull, [...memory.PROVENANCE_ENUM]);
174
+ assert.ok(schema.properties.provenance.enum.includes(null));
175
+ });
176
+
177
+ test('VAL-25: memory-manifest.v1 accepts both init and rebuilt manifests', () => {
178
+ assert.deepEqual(validate({ schema_version: 1, model: 'm', dim: 384, alpha: 0.6, created_at: 'x' }, 'memory-manifest.v1'), []);
179
+ assert.deepEqual(validate({ schema_version: 1, model: 'm', dim: 384, alpha: 0.6, rebuilt_at: 'x' }, 'memory-manifest.v1'), []);
180
+ assert.equal(validate({ schema_version: 1, model: 'm' }, 'memory-manifest.v1').length, 1);
181
+ });
182
+
183
+ test('VAL-26: additionalProperties as a schema validates every map value', () => {
184
+ const schema = { type: 'object', additionalProperties: { type: 'object', required: ['sha256'], properties: { sha256: { type: 'string' } } } };
185
+ assert.deepEqual(validate({ 'a.js': { sha256: 'x' }, 'b.js': { sha256: 'y' } }, schema), []);
186
+ const errs = validate({ 'a.js': { sha256: 'x' }, 'b.js': { size: 5 } }, schema);
187
+ assert.equal(errs[0].keyword, 'required');
188
+ assert.equal(errs[0].field, 'sha256');
189
+ assert.equal(errs[0].instancePath, '/b.js/sha256');
190
+ });
191
+
192
+ test('VAL-27: codebase-manifest.v1 rejects a file entry missing sha256', () => {
193
+ const errs = validate({ schema_version: 1, files: { 'a.js': { size: 10, ext: '.js' } } }, 'codebase-manifest.v1');
194
+ assert.equal(errs[0].keyword, 'required');
195
+ assert.equal(errs[0].field, 'sha256');
196
+ });
197
+
198
+ test('VAL-28: message.v1 id/from/to patterns stay in sync with messaging regexes', () => {
199
+ const schema = _loadSchema('message.v1');
200
+ assert.equal(schema.properties.id.pattern, messaging.ID_RE.source);
201
+ assert.equal(schema.properties.from.pattern, messaging.AGENT_RE.source);
202
+ assert.equal(schema.properties.to.pattern, messaging.AGENT_RE.source);
203
+ });
204
+
205
+ test('VAL-29: message.v1 body maxBytes stays in sync with MAX_BODY_BYTES', () => {
206
+ const schema = _loadSchema('message.v1');
207
+ assert.equal(schema.properties.body.maxBytes, messaging.MAX_BODY_BYTES);
208
+ });
209
+
210
+ test('VAL-32: message.v1 kind enum stays in sync with KIND_ENUM', () => {
211
+ const schema = _loadSchema('message.v1');
212
+ assert.deepEqual(schema.properties.kind.enum, [...messaging.KIND_ENUM]);
213
+ });
214
+
215
+ test('VAL-30: metrics-record.v1 accepts coexisting record shapes (buildRecord + session-aggregate)', () => {
216
+ const buildShape = {
217
+ agent: 'a', tier: 't', resolved_model: 'm', phase: '10', plan: 'p', task: 'k',
218
+ started_at: 'x', ended_at: 'y', duration_ms: 1000, tokens_in: 100, tokens_out: 50,
219
+ retry_count: 0, status: 'ok', runtime: 'claude', error: null,
220
+ };
221
+ const sessionShape = {
222
+ schema_version: 2, started_at: 'x', phase: 'P1', agent: 'a', tier: 't', resolved_model: 'm',
223
+ plan: 'PL1', task: 'TA1', tokens_in: 10, tokens_out: 20, duration_ms: 100, status: 'ok',
224
+ runtime: 'claude', retry_count: 0,
225
+ };
226
+ assert.deepEqual(validate(buildShape, 'metrics-record.v1'), []);
227
+ assert.deepEqual(validate(sessionShape, 'metrics-record.v1'), []);
228
+ });
229
+
230
+ test('VAL-31: metrics-record.v1 rejects a record whose arithmetic field is the wrong type', () => {
231
+ const errs = validate({ agent: 'a', tokens_in: 'not-a-number' }, 'metrics-record.v1');
232
+ assert.equal(errs[0].keyword, 'type');
233
+ assert.equal(errs[0].field, 'tokens_in');
234
+ });
235
+
236
+ test('VAL-22: circular / non-serializable input does not throw a raw TypeError', () => {
237
+ const circular = { a: 1 };
238
+ circular.self = circular;
239
+ let errs;
240
+ assert.doesNotThrow(() => { errs = validate(circular, { const: { a: 1 } }); });
241
+ assert.equal(errs[0].keyword, 'const');
242
+ });
package/np-tools.cjs CHANGED
@@ -42,6 +42,7 @@ const topLevelCommands = {
42
42
  'config-get': require('./bin/np-tools/config.cjs'),
43
43
  'scan-codebase': require('./bin/np-tools/scan-codebase.cjs'),
44
44
  'update-docs': require('./bin/np-tools/update-docs.cjs'),
45
+ 'graph-impact': require('./bin/np-tools/graph-impact.cjs'),
45
46
  'doctor': require('./bin/np-tools/doctor.cjs'),
46
47
  'generate-slug': require('./bin/np-tools/slug.cjs'),
47
48
  'metrics': require('./bin/np-tools/metrics.cjs'),
@@ -103,6 +104,7 @@ const topLevelCommands = {
103
104
  'loop-stuck': require('./bin/np-tools/loop-stuck.cjs'),
104
105
  'loop-metrics': require('./bin/np-tools/loop-metrics.cjs'),
105
106
  'spawn-headless': require('./bin/np-tools/spawn-headless.cjs'),
107
+ 'security': require('./bin/np-tools/security.cjs'),
106
108
  'learning-log': require('./bin/np-tools/learning-log.cjs'),
107
109
  'learning-match': require('./bin/np-tools/learning-match.cjs'),
108
110
  'learning-list': require('./bin/np-tools/learning-list.cjs'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Self-hosted AI pilot for any codebase. Researcher and critic agents plan, execute and verify each change.",
5
5
  "homepage": "https://pilot.nubos.cloud",
6
6
  "repository": {
@@ -43,6 +43,8 @@
43
43
  "check-coverage": "node bin/check-coverage.cjs",
44
44
  "docs:generate": "node scripts/generate-docs.cjs",
45
45
  "docs:check": "node scripts/generate-docs.cjs --check",
46
+ "attributions": "node scripts/generate-attributions.cjs",
47
+ "attributions:check": "node scripts/generate-attributions.cjs --check",
46
48
  "ci": "npm run test && npm run check-coverage"
47
49
  }
48
50
  }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const cp = require('node:child_process');
7
+
8
+ const ALLOWED_VERBS = new Set(['session-start', 'baseline', 'scan', 'review', 'commit']);
9
+
10
+ function resolveNpTools() {
11
+ const candidates = [
12
+ path.join(process.cwd(), '.nubos-pilot', 'bin', 'np-tools.cjs'),
13
+ path.join(__dirname, '..', '..', '..', '.nubos-pilot', 'bin', 'np-tools.cjs'),
14
+ ];
15
+ for (const c of candidates) {
16
+ try { if (fs.statSync(c).isFile()) return c; } catch {}
17
+ }
18
+ return null;
19
+ }
20
+
21
+ function readStdin() {
22
+ return new Promise((resolve) => {
23
+ if (process.stdin.isTTY) return resolve('');
24
+ let buf = '';
25
+ process.stdin.setEncoding('utf-8');
26
+ const timer = setTimeout(() => { try { process.stdin.removeAllListeners(); } catch {} resolve(buf); }, 800);
27
+ process.stdin.on('data', (c) => { buf += c; });
28
+ process.stdin.on('end', () => { clearTimeout(timer); resolve(buf); });
29
+ process.stdin.on('error', () => { clearTimeout(timer); resolve(buf); });
30
+ });
31
+ }
32
+
33
+ (async () => {
34
+ const verb = process.argv[2];
35
+ if (!ALLOWED_VERBS.has(verb)) { process.exit(0); return; }
36
+ const npTools = resolveNpTools();
37
+ if (!npTools) { process.exit(0); return; }
38
+ const input = await readStdin();
39
+ try {
40
+ const r = cp.spawnSync(process.execPath, [npTools, 'security', verb, '--stdin'], {
41
+ input,
42
+ encoding: 'utf-8',
43
+ timeout: 20000,
44
+ maxBuffer: 8 * 1024 * 1024,
45
+ cwd: process.cwd(),
46
+ });
47
+ if (r && typeof r.stdout === 'string' && r.stdout.length) process.stdout.write(r.stdout);
48
+ } catch { /* never let a security hook break the session */ }
49
+ process.exit(0);
50
+ })().catch(() => { process.exit(0); });
@@ -168,6 +168,10 @@ AUTO_LOG_LEARNING=$(node .nubos-pilot/bin/np-tools.cjs config-get auto_log_learn
168
168
  SPAWN_HEADLESS_ENABLED=$(node .nubos-pilot/bin/np-tools.cjs config-get spawn.headless.enabled 2>/dev/null || echo false)
169
169
  SPAWN_HEADLESS_AGENTS=$(node .nubos-pilot/bin/np-tools.cjs config-get spawn.headless.agents 2>/dev/null || echo '["np-critic","np-researcher"]')
170
170
  SPAWN_HEADLESS_FALLBACK=$(node .nubos-pilot/bin/np-tools.cjs config-get spawn.headless.fallback_on_error 2>/dev/null || echo true)
171
+ CONF_INJECT_CRITERIA=$(node .nubos-pilot/bin/np-tools.cjs config-get conformance.inject_criteria 2>/dev/null || echo true)
172
+ # Milestone success_criteria as the executor's acceptance target (rendered once from the INIT payload).
173
+ # Intent-level only (ADR-0019): these describe what "done right" means, NOT how to build it.
174
+ SUCCESS_CRITERIA_BLOCK=$(echo "$INIT" | node -e 'process.stdin.on("data",d=>{try{const c=JSON.parse(d).success_criteria||[];console.log(c.map(x=>"- "+(x.id?x.id+": ":"")+(x.text||x)).join("\n"))}catch(e){console.log("")}})')
171
175
  ```
172
176
 
173
177
  ## Spawn dispatch — agent-tool vs. headless subprocess (ADR-0010 §L6)
@@ -336,11 +340,17 @@ for WAVE_INDEX in 0 1 2 ...; do
336
340
  # Prompt fields:
337
341
  # <files_to_read>: task plan, slice plan, prior slice SUMMARYs, CONTEXT.md
338
342
  # <consensus_pattern>: $CONSENSUS_PATTERN (with [VERIFIED]/[PROVISIONAL]/[CACHED])
343
+ # <success_criteria>: when $CONF_INJECT_CRITERIA = true, include the milestone
344
+ # acceptance target — $SUCCESS_CRITERIA_BLOCK plus the slice UAT path
345
+ # (.nubos-pilot/milestones/M<NNN>/slices/S<NNN>/S<NNN>-UAT.md). Frame it as
346
+ # "what done-right means (intent, ADR-0019) — NOT a build spec, NOT a scope
347
+ # expansion". Omit the field entirely when the flag is false.
339
348
  # <prior_findings>: critic findings JSON (R≥2 only)
340
349
  # <verify_excerpt>: tail of $VERIFY_LOG (R≥2 only)
341
350
  # <lang_directive>: $LANG_DIRECTIVE
342
351
  # <skills>: $AGENT_SKILLS_EXECUTOR
343
- # RULES — Agent MUST: edit ONLY paths in files_modified (D-04 scope guard),
352
+ # RULES — Agent MUST: edit ONLY paths in files_modified (D-04 scope guard)
353
+ # success_criteria are the acceptance target, NEVER a licence to touch other files,
344
354
  # run `node np-tools.cjs knowledge-search "<q>" --task $TASK_ID` via Bash
345
355
  # ≥1× (Rule 9 — the --task flag writes the audit evidence ledger),
346
356
  # NOT call commit-task. Capture tool_use stream for audit (group (3) below).