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.
@@ -0,0 +1,137 @@
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 { scanContent, loadCustomRules, _globToRegExp, _looksCatastrophic } = require('./scan.cjs');
10
+
11
+ function cats(findings) {
12
+ return new Set(findings.map((f) => f.category));
13
+ }
14
+
15
+ test('SCAN-1 each built-in category triggers on representative content', () => {
16
+ const samples = {
17
+ 'dynamic-exec': 'const r = eval(userInput);',
18
+ 'unsafe-deserialization': 'data = pickle.loads(blob)',
19
+ 'dom-injection': 'el.innerHTML = userInput;',
20
+ 'hardcoded-secret': 'const key = "-----BEGIN PRIVATE KEY-----";',
21
+ };
22
+ for (const [category, content] of Object.entries(samples)) {
23
+ const { findings } = scanContent({ filePath: 'src/x.js', content });
24
+ assert.ok(cats(findings).has(category), category + ' should trigger; got ' + [...cats(findings)].join(','));
25
+ }
26
+ });
27
+
28
+ test('SCAN-2 workflow-file is path-only and fires regardless of content', () => {
29
+ const { findings } = scanContent({ filePath: '.github/workflows/deploy.yml', content: 'name: ci' });
30
+ assert.ok(findings.some((f) => f.category === 'workflow-file'));
31
+ });
32
+
33
+ test('SCAN-3 clean code produces no findings (no false positives)', () => {
34
+ const content = [
35
+ 'function add(a, b) {',
36
+ ' return a + b;',
37
+ '}',
38
+ 'const greeting = "hello world";',
39
+ 'el.textContent = greeting;',
40
+ ].join('\n');
41
+ const { findings } = scanContent({ filePath: 'src/util.js', content });
42
+ assert.deepEqual(findings, []);
43
+ });
44
+
45
+ test('SCAN-4 finding carries the first matching line number', () => {
46
+ const content = 'line one\nline two\nconst r = eval(x);\n';
47
+ const { findings } = scanContent({ filePath: 'a.js', content });
48
+ const evalFinding = findings.find((f) => f.rule_name === 'eval_call');
49
+ assert.equal(evalFinding.line, 3);
50
+ });
51
+
52
+ test('SCAN-5 custom rules augment built-ins (both present)', () => {
53
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-scan-'));
54
+ const rulesFile = path.join(dir, 'rules.json');
55
+ fs.writeFileSync(rulesFile, JSON.stringify({
56
+ patterns: [{
57
+ rule_name: 'tenant_unfiltered_query',
58
+ category: 'multi-tenant',
59
+ severity: 'risk',
60
+ regex: '\\.objects\\.all\\(\\)',
61
+ reminder: 'Filter by org_id.',
62
+ }],
63
+ }));
64
+ try {
65
+ const content = 'q = Model.objects.all()\nr = eval(z)';
66
+ const { findings } = scanContent({ filePath: 'src/tenants/x.py', content, customRulesPath: rulesFile });
67
+ assert.ok(findings.some((f) => f.rule_name === 'tenant_unfiltered_query'), 'custom rule fires');
68
+ assert.ok(findings.some((f) => f.rule_name === 'eval_call'), 'built-in still fires');
69
+ } finally {
70
+ fs.rmSync(dir, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test('SCAN-6 custom rule paths scope limits where it applies', () => {
75
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-scan-'));
76
+ const rulesFile = path.join(dir, 'rules.json');
77
+ fs.writeFileSync(rulesFile, JSON.stringify({
78
+ patterns: [{
79
+ rule_name: 'tenant_unfiltered_query',
80
+ regex: '\\.objects\\.all\\(\\)',
81
+ paths: ['**/src/tenants/**'],
82
+ reminder: 'scoped',
83
+ }],
84
+ }));
85
+ try {
86
+ const content = 'q = Model.objects.all()';
87
+ const inScope = scanContent({ filePath: 'src/tenants/a.py', content, customRulesPath: rulesFile });
88
+ const outScope = scanContent({ filePath: 'src/public/a.py', content, customRulesPath: rulesFile });
89
+ assert.ok(inScope.findings.some((f) => f.rule_name === 'tenant_unfiltered_query'));
90
+ assert.ok(!outScope.findings.some((f) => f.rule_name === 'tenant_unfiltered_query'));
91
+ } finally {
92
+ fs.rmSync(dir, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test('SCAN-7 catastrophic regex in custom rule is skipped, not loaded', () => {
97
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-scan-'));
98
+ const rulesFile = path.join(dir, 'rules.json');
99
+ fs.writeFileSync(rulesFile, JSON.stringify({
100
+ patterns: [{ rule_name: 'evil', regex: '(a+)+$', reminder: 'x' }],
101
+ }));
102
+ try {
103
+ const { rules, skipped } = loadCustomRules(rulesFile);
104
+ assert.equal(rules.length, 0);
105
+ assert.ok(skipped.some((s) => s.reason === 'catastrophic-regex'));
106
+ } finally {
107
+ fs.rmSync(dir, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ test('SCAN-8 custom rule cap at 50 enforced with diagnostic', () => {
112
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-scan-'));
113
+ const rulesFile = path.join(dir, 'rules.json');
114
+ const many = [];
115
+ for (let i = 0; i < 60; i++) many.push({ rule_name: 'r' + i, substrings: ['ZZZ' + i], reminder: 'x' });
116
+ fs.writeFileSync(rulesFile, JSON.stringify({ patterns: many }));
117
+ try {
118
+ const { rules, skipped } = loadCustomRules(rulesFile);
119
+ assert.equal(rules.length, 50);
120
+ assert.ok(skipped.some((s) => s.reason === 'rule-cap-exceeded'));
121
+ } finally {
122
+ fs.rmSync(dir, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ test('SCAN-9 missing custom rules path is a no-op (additive, resilient)', () => {
127
+ const { rules, skipped } = loadCustomRules(null);
128
+ assert.deepEqual(rules, []);
129
+ assert.deepEqual(skipped, []);
130
+ });
131
+
132
+ test('SCAN-10 glob and catastrophic helpers behave', () => {
133
+ assert.ok(_globToRegExp('**/src/tenants/**').test('app/src/tenants/x.py'));
134
+ assert.ok(!_globToRegExp('**/src/tenants/**').test('app/src/public/x.py'));
135
+ assert.ok(_looksCatastrophic('(.*)*'));
136
+ assert.ok(!_looksCatastrophic('\\beval\\s*\\('));
137
+ });
package/np-tools.cjs CHANGED
@@ -103,6 +103,7 @@ const topLevelCommands = {
103
103
  'loop-stuck': require('./bin/np-tools/loop-stuck.cjs'),
104
104
  'loop-metrics': require('./bin/np-tools/loop-metrics.cjs'),
105
105
  'spawn-headless': require('./bin/np-tools/spawn-headless.cjs'),
106
+ 'security': require('./bin/np-tools/security.cjs'),
106
107
  'learning-log': require('./bin/np-tools/learning-log.cjs'),
107
108
  'learning-match': require('./bin/np-tools/learning-match.cjs'),
108
109
  '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.1",
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": {
@@ -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).