wogiflow 1.5.13 → 1.5.14
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/package.json +1 -1
- package/scripts/flow-community.js +89 -27
- package/scripts/flow-regression.js +39 -21
- package/scripts/flow-script-resolver.js +4 -3
- package/scripts/flow-utils.js +6 -4
- package/scripts/hooks/core/extension-registry.js +8 -1
- package/scripts/hooks/entry/claude-code/session-start.js +16 -12
package/package.json
CHANGED
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const https = require('https');
|
|
16
|
-
const http = require('http');
|
|
17
16
|
const crypto = require('crypto');
|
|
18
17
|
const os = require('os');
|
|
18
|
+
const { execFileSync } = require('child_process');
|
|
19
19
|
|
|
20
|
-
const {
|
|
20
|
+
const { PATHS, safeJsonParse, safeJsonParseString } = require('./flow-utils');
|
|
21
21
|
|
|
22
22
|
// ~/.wogiflow/ directory for user-level state (persists across projects)
|
|
23
23
|
const WOGIFLOW_HOME = path.join(os.homedir(), '.wogiflow');
|
|
@@ -28,6 +28,7 @@ const LAST_PUSH_PATH = path.join(WOGIFLOW_HOME, 'last-community-push');
|
|
|
28
28
|
const CONSENT_PATH = path.join(WOGIFLOW_HOME, 'consent-acknowledged');
|
|
29
29
|
|
|
30
30
|
const REQUEST_TIMEOUT_MS = 5000;
|
|
31
|
+
const MAX_RESPONSE_BYTES = 512 * 1024; // 512KB max response size
|
|
31
32
|
|
|
32
33
|
// ──────────────────────────────────────────────
|
|
33
34
|
// Anonymous ID
|
|
@@ -146,7 +147,6 @@ function stripPII(data, config) {
|
|
|
146
147
|
let gitUser = '';
|
|
147
148
|
let gitEmail = '';
|
|
148
149
|
try {
|
|
149
|
-
const { execFileSync } = require('child_process');
|
|
150
150
|
gitUser = execFileSync('git', ['config', 'user.name'], { encoding: 'utf-8', timeout: 2000 }).trim();
|
|
151
151
|
gitEmail = execFileSync('git', ['config', 'user.email'], { encoding: 'utf-8', timeout: 2000 }).trim();
|
|
152
152
|
} catch {
|
|
@@ -159,7 +159,7 @@ function stripPII(data, config) {
|
|
|
159
159
|
let result = str;
|
|
160
160
|
|
|
161
161
|
// Replace absolute paths (Unix and Windows)
|
|
162
|
-
result = result.replace(/(?:\/(?:Users|home|var|tmp|opt|etc|usr)\/[^\s,;:'")\]}>]+)/g, '[PATH]');
|
|
162
|
+
result = result.replace(/(?:\/(?:Users|home|var|tmp|opt|etc|usr|root|app|srv|run|mnt|media|proc|data)\/[^\s,;:'")\]}>]+)/g, '[PATH]');
|
|
163
163
|
result = result.replace(/(?:[A-Z]:\\[^\s,;:'")\]}>]+)/gi, '[PATH]');
|
|
164
164
|
|
|
165
165
|
// Replace home directory references
|
|
@@ -266,13 +266,17 @@ function collectShareableData(config) {
|
|
|
266
266
|
* Get WogiFlow version from package.json.
|
|
267
267
|
* @returns {string}
|
|
268
268
|
*/
|
|
269
|
+
let _cachedVersion = null;
|
|
269
270
|
function getWogiFlowVersion() {
|
|
271
|
+
if (_cachedVersion) return _cachedVersion;
|
|
270
272
|
try {
|
|
271
273
|
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
272
|
-
const pkg =
|
|
273
|
-
|
|
274
|
+
const pkg = safeJsonParse(pkgPath, {});
|
|
275
|
+
_cachedVersion = pkg.version || 'unknown';
|
|
276
|
+
return _cachedVersion;
|
|
274
277
|
} catch {
|
|
275
|
-
|
|
278
|
+
_cachedVersion = 'unknown';
|
|
279
|
+
return _cachedVersion;
|
|
276
280
|
}
|
|
277
281
|
}
|
|
278
282
|
|
|
@@ -505,6 +509,42 @@ function collectSkillLearnings() {
|
|
|
505
509
|
// HTTP Helpers
|
|
506
510
|
// ──────────────────────────────────────────────
|
|
507
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Validate server URL to prevent SSRF attacks.
|
|
514
|
+
* Enforces HTTPS-only and blocks private/internal addresses.
|
|
515
|
+
* @param {string} urlStr
|
|
516
|
+
* @returns {boolean}
|
|
517
|
+
*/
|
|
518
|
+
function isAllowedServerUrl(urlStr) {
|
|
519
|
+
try {
|
|
520
|
+
const url = new URL(urlStr);
|
|
521
|
+
// Enforce HTTPS only
|
|
522
|
+
if (url.protocol !== 'https:') return false;
|
|
523
|
+
// Block localhost and loopback
|
|
524
|
+
// URL.hostname returns IPv6 with bracket delimiters (e.g., '[::1]') — strip them
|
|
525
|
+
const rawHostname = url.hostname.toLowerCase();
|
|
526
|
+
const hostname = rawHostname.startsWith('[') ? rawHostname.slice(1, -1) : rawHostname;
|
|
527
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false;
|
|
528
|
+
// Block IPv6 private/loopback ranges
|
|
529
|
+
if (hostname.startsWith('::ffff:')) return false; // IPv4-mapped IPv6
|
|
530
|
+
if (hostname.startsWith('fe80:') || hostname.startsWith('fe80::')) return false; // Link-local
|
|
531
|
+
if (hostname.startsWith('fc00:') || hostname.startsWith('fd00:')) return false; // Unique local (RFC 4193)
|
|
532
|
+
// Block private IP ranges (RFC-1918 + link-local)
|
|
533
|
+
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.\d+\.\d+$/);
|
|
534
|
+
if (ipMatch) {
|
|
535
|
+
const a = parseInt(ipMatch[1], 10);
|
|
536
|
+
const b = parseInt(ipMatch[2], 10);
|
|
537
|
+
if (a === 10) return false; // 10.0.0.0/8
|
|
538
|
+
if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12
|
|
539
|
+
if (a === 192 && b === 168) return false; // 192.168.0.0/16
|
|
540
|
+
if (a === 169 && b === 254) return false; // 169.254.0.0/16 (link-local)
|
|
541
|
+
}
|
|
542
|
+
return true;
|
|
543
|
+
} catch {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
508
548
|
/**
|
|
509
549
|
* Make an HTTPS request with timeout. Fire-and-forget pattern.
|
|
510
550
|
* @param {string} method - HTTP method
|
|
@@ -516,13 +556,19 @@ function collectSkillLearnings() {
|
|
|
516
556
|
function httpRequest(method, urlStr, body = null, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
517
557
|
return new Promise((resolve) => {
|
|
518
558
|
try {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
559
|
+
if (!isAllowedServerUrl(urlStr)) {
|
|
560
|
+
if (process.env.DEBUG) {
|
|
561
|
+
console.error(`[flow-community] Blocked request to disallowed URL: ${urlStr}`);
|
|
562
|
+
}
|
|
563
|
+
resolve(null);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
522
566
|
|
|
567
|
+
const url = new URL(urlStr);
|
|
568
|
+
// isAllowedServerUrl already enforces HTTPS — use https directly
|
|
523
569
|
const options = {
|
|
524
570
|
hostname: url.hostname,
|
|
525
|
-
port: url.port ||
|
|
571
|
+
port: url.port || 443,
|
|
526
572
|
path: url.pathname + url.search,
|
|
527
573
|
method,
|
|
528
574
|
headers: {
|
|
@@ -533,9 +579,17 @@ function httpRequest(method, urlStr, body = null, timeoutMs = REQUEST_TIMEOUT_MS
|
|
|
533
579
|
timeout: timeoutMs
|
|
534
580
|
};
|
|
535
581
|
|
|
536
|
-
const req =
|
|
582
|
+
const req = https.request(options, (res) => {
|
|
537
583
|
let data = '';
|
|
538
|
-
res.on('data', (chunk) => {
|
|
584
|
+
res.on('data', (chunk) => {
|
|
585
|
+
// Check size BEFORE appending to prevent single oversized chunk from buffering
|
|
586
|
+
if (data.length + chunk.length > MAX_RESPONSE_BYTES) {
|
|
587
|
+
req.destroy();
|
|
588
|
+
resolve(null);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
data += chunk;
|
|
592
|
+
});
|
|
539
593
|
res.on('end', () => {
|
|
540
594
|
resolve({ statusCode: res.statusCode, body: data });
|
|
541
595
|
});
|
|
@@ -626,7 +680,8 @@ async function pullFromServer(config) {
|
|
|
626
680
|
|
|
627
681
|
if (result && result.statusCode >= 200 && result.statusCode < 300) {
|
|
628
682
|
try {
|
|
629
|
-
const knowledge =
|
|
683
|
+
const knowledge = safeJsonParseString(result.body);
|
|
684
|
+
if (!knowledge || typeof knowledge !== 'object') return cached || null;
|
|
630
685
|
knowledge._cachedAt = new Date().toISOString();
|
|
631
686
|
saveCommunityCache(knowledge);
|
|
632
687
|
return knowledge;
|
|
@@ -663,10 +718,13 @@ async function submitSuggestion(text, type, config) {
|
|
|
663
718
|
const validTypes = ['idea', 'bug', 'improvement'];
|
|
664
719
|
const suggestionType = validTypes.includes(type) ? type : 'idea';
|
|
665
720
|
|
|
721
|
+
// Strip PII from suggestion text before sending
|
|
722
|
+
const strippedText = stripPII(text.trim(), config);
|
|
723
|
+
|
|
666
724
|
const suggestion = {
|
|
667
725
|
anonId: getOrCreateAnonId(),
|
|
668
726
|
type: suggestionType,
|
|
669
|
-
content: text.trim(),
|
|
727
|
+
content: typeof strippedText === 'string' ? strippedText : text.trim(),
|
|
670
728
|
wogiflowVersion: getWogiFlowVersion(),
|
|
671
729
|
submittedAt: new Date().toISOString()
|
|
672
730
|
};
|
|
@@ -701,7 +759,7 @@ function queuePendingSuggestion(suggestion) {
|
|
|
701
759
|
if (fs.existsSync(PENDING_SUGGESTIONS_PATH)) {
|
|
702
760
|
try {
|
|
703
761
|
const content = fs.readFileSync(PENDING_SUGGESTIONS_PATH, 'utf-8');
|
|
704
|
-
const parsed =
|
|
762
|
+
const parsed = safeJsonParseString(content, []);
|
|
705
763
|
if (Array.isArray(parsed)) {
|
|
706
764
|
pending = parsed;
|
|
707
765
|
}
|
|
@@ -737,7 +795,7 @@ async function retryPendingSuggestions(config) {
|
|
|
737
795
|
const content = fs.readFileSync(PENDING_SUGGESTIONS_PATH, 'utf-8');
|
|
738
796
|
let pending;
|
|
739
797
|
try {
|
|
740
|
-
pending =
|
|
798
|
+
pending = safeJsonParseString(content, null);
|
|
741
799
|
} catch {
|
|
742
800
|
return;
|
|
743
801
|
}
|
|
@@ -785,7 +843,7 @@ function loadCommunityCache() {
|
|
|
785
843
|
try {
|
|
786
844
|
if (!fs.existsSync(COMMUNITY_CACHE_PATH)) return null;
|
|
787
845
|
const content = fs.readFileSync(COMMUNITY_CACHE_PATH, 'utf-8');
|
|
788
|
-
return
|
|
846
|
+
return safeJsonParseString(content, null);
|
|
789
847
|
} catch {
|
|
790
848
|
return null;
|
|
791
849
|
}
|
|
@@ -887,25 +945,29 @@ function mergeModelIntelligence(items) {
|
|
|
887
945
|
if (!fs.existsSync(filePath)) continue;
|
|
888
946
|
|
|
889
947
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
948
|
+
const detail = (item.adjustments || item.strengths || item.weaknesses || '').slice(0, 500);
|
|
949
|
+
if (!detail) continue;
|
|
890
950
|
|
|
891
951
|
// Check if community section already exists
|
|
892
952
|
if (content.includes(COMMUNITY_MARKER)) {
|
|
893
|
-
//
|
|
894
|
-
const
|
|
895
|
-
|
|
953
|
+
// Scope dedup check to community section only (not full file)
|
|
954
|
+
const markerIndex = content.indexOf(COMMUNITY_MARKER);
|
|
955
|
+
const communitySection = content.slice(markerIndex);
|
|
956
|
+
if (communitySection.includes(detail)) {
|
|
896
957
|
continue; // Already merged
|
|
897
958
|
}
|
|
898
|
-
// Append
|
|
899
|
-
const
|
|
900
|
-
const
|
|
959
|
+
// Append at END of community section (next ## heading or EOF) for chronological order
|
|
960
|
+
const afterMarker = content.slice(markerIndex);
|
|
961
|
+
const nextHeadingMatch = afterMarker.match(/\n## /);
|
|
962
|
+
const insertPoint = nextHeadingMatch
|
|
963
|
+
? markerIndex + nextHeadingMatch.index
|
|
964
|
+
: content.length;
|
|
901
965
|
const newLine = `- ${detail}\n`;
|
|
902
966
|
const updated = content.slice(0, insertPoint) + newLine + content.slice(insertPoint);
|
|
903
967
|
fs.writeFileSync(filePath, updated, 'utf-8');
|
|
904
968
|
merged++;
|
|
905
969
|
} else {
|
|
906
970
|
// Add new community section at end of file
|
|
907
|
-
const detail = item.adjustments || item.strengths || item.weaknesses || '';
|
|
908
|
-
if (!detail) continue;
|
|
909
971
|
const section = `\n\n## Community Learnings\n${COMMUNITY_MARKER}\n- ${detail}\n`;
|
|
910
972
|
fs.writeFileSync(filePath, content.trimEnd() + section, 'utf-8');
|
|
911
973
|
merged++;
|
|
@@ -1013,7 +1075,7 @@ function mergePatterns(items) {
|
|
|
1013
1075
|
// Check if this community pattern already exists
|
|
1014
1076
|
if (content.includes(patternName)) continue;
|
|
1015
1077
|
|
|
1016
|
-
const description = item.description.replace(/\|/g, '/'); // Escape pipes for table
|
|
1078
|
+
const description = item.description.slice(0, 500).replace(/\|/g, '/'); // Escape pipes for table, cap length
|
|
1017
1079
|
const occurrences = item.occurrences || 1;
|
|
1018
1080
|
newRows.push(`| ${today} | ${patternName} | Community: ${description} | ${occurrences} | Informational |`);
|
|
1019
1081
|
merged++;
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const path = require('path');
|
|
19
|
-
const {
|
|
20
|
-
const { getProjectRoot, colors, getConfig } = require('./flow-utils');
|
|
21
|
-
const {
|
|
19
|
+
const { execFileSync } = require('child_process');
|
|
20
|
+
const { getProjectRoot, colors, getConfig, safeJsonParse } = require('./flow-utils');
|
|
21
|
+
const { getExecParts, getCommand } = require('./flow-script-resolver');
|
|
22
22
|
|
|
23
23
|
const PROJECT_ROOT = getProjectRoot();
|
|
24
24
|
const STATE_DIR = path.join(PROJECT_ROOT, '.workflow', 'state');
|
|
@@ -37,7 +37,7 @@ function getCompletedTasks() {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
try {
|
|
40
|
-
const ready =
|
|
40
|
+
const ready = safeJsonParse(READY_PATH, {});
|
|
41
41
|
return ready.recentlyCompleted || [];
|
|
42
42
|
} catch (err) {
|
|
43
43
|
log('yellow', `Warning: Could not parse ready.json: ${err.message}`);
|
|
@@ -51,9 +51,17 @@ function getCompletedTasks() {
|
|
|
51
51
|
function findTestFiles(taskId, taskData) {
|
|
52
52
|
const testFiles = [];
|
|
53
53
|
|
|
54
|
-
// Check if task has explicit test files
|
|
54
|
+
// Check if task has explicit test files (validate paths to prevent injection)
|
|
55
55
|
if (taskData?.testFiles) {
|
|
56
|
-
|
|
56
|
+
const SAFE_PATH = /^[a-zA-Z0-9_.\-/]+$/;
|
|
57
|
+
for (const tf of taskData.testFiles) {
|
|
58
|
+
if (typeof tf === 'string' && SAFE_PATH.test(tf) && !tf.includes('..')) {
|
|
59
|
+
const resolved = path.resolve(PROJECT_ROOT, tf);
|
|
60
|
+
if (resolved.startsWith(PROJECT_ROOT + path.sep) || resolved === PROJECT_ROOT) {
|
|
61
|
+
testFiles.push(tf);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
57
65
|
return testFiles;
|
|
58
66
|
}
|
|
59
67
|
|
|
@@ -81,7 +89,12 @@ function findTestFiles(taskId, taskData) {
|
|
|
81
89
|
// Also look in request-log for files changed
|
|
82
90
|
const logPath = path.join(STATE_DIR, 'request-log.md');
|
|
83
91
|
if (fs.existsSync(logPath)) {
|
|
84
|
-
|
|
92
|
+
let logContent;
|
|
93
|
+
try {
|
|
94
|
+
logContent = fs.readFileSync(logPath, 'utf8');
|
|
95
|
+
} catch {
|
|
96
|
+
return [...new Set(testFiles)]; // Dedupe and return what we have
|
|
97
|
+
}
|
|
85
98
|
// Escape special regex characters in taskId
|
|
86
99
|
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
87
100
|
const taskPattern = new RegExp(`### R-\\d+.*${escapedTaskId}[\\s\\S]*?Files:\\s*([^\\n]+)`, 'i');
|
|
@@ -127,9 +140,9 @@ function runTaskTests(taskId, taskData) {
|
|
|
127
140
|
log('white', ` Running tests: ${testFiles.join(', ')}`);
|
|
128
141
|
|
|
129
142
|
try {
|
|
130
|
-
// Try to run tests with common test runners
|
|
131
|
-
const
|
|
132
|
-
|
|
143
|
+
// Try to run tests with common test runners (using execFileSync for injection safety)
|
|
144
|
+
const { cmd, args } = detectTestRunner(testFiles);
|
|
145
|
+
execFileSync(cmd, args, {
|
|
133
146
|
cwd: PROJECT_ROOT,
|
|
134
147
|
stdio: 'pipe',
|
|
135
148
|
timeout: 60000 // 1 minute timeout per task
|
|
@@ -151,44 +164,49 @@ function runTaskTests(taskId, taskData) {
|
|
|
151
164
|
}
|
|
152
165
|
|
|
153
166
|
/**
|
|
154
|
-
* Detect which test runner to use
|
|
167
|
+
* Detect which test runner to use.
|
|
168
|
+
* Returns { cmd, args } for use with execFileSync (prevents shell injection).
|
|
169
|
+
* @returns {{ cmd: string, args: string[] }}
|
|
155
170
|
*/
|
|
156
171
|
function detectTestRunner(testFiles) {
|
|
157
172
|
const packageJson = path.join(PROJECT_ROOT, 'package.json');
|
|
158
173
|
|
|
159
174
|
if (fs.existsSync(packageJson)) {
|
|
160
175
|
try {
|
|
161
|
-
const pkg =
|
|
176
|
+
const pkg = safeJsonParse(packageJson, {});
|
|
162
177
|
|
|
163
178
|
// Check scripts
|
|
164
179
|
if (pkg.scripts?.test) {
|
|
165
180
|
// If specific files, pass them
|
|
166
181
|
if (testFiles.length > 0) {
|
|
167
|
-
const fileArgs = testFiles.join(' ');
|
|
168
182
|
// Jest-style
|
|
169
183
|
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) {
|
|
170
|
-
return
|
|
184
|
+
return getExecParts('jest', [...testFiles, '--passWithNoTests']);
|
|
171
185
|
}
|
|
172
186
|
// Vitest
|
|
173
187
|
if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest) {
|
|
174
|
-
return
|
|
188
|
+
return getExecParts('vitest', ['run', ...testFiles]);
|
|
175
189
|
}
|
|
176
190
|
// Mocha
|
|
177
191
|
if (pkg.devDependencies?.mocha || pkg.dependencies?.mocha) {
|
|
178
|
-
return
|
|
192
|
+
return getExecParts('mocha', testFiles);
|
|
179
193
|
}
|
|
180
194
|
}
|
|
181
|
-
// Fallback to resolved test command
|
|
182
|
-
const testCmd = getCommand('test')
|
|
183
|
-
|
|
195
|
+
// Fallback to resolved test command via package manager
|
|
196
|
+
const testCmd = getCommand('test');
|
|
197
|
+
if (testCmd) {
|
|
198
|
+
const parts = testCmd.split(/\s+/);
|
|
199
|
+
return { cmd: parts[0], args: [...parts.slice(1), '--', '--passWithNoTests'] };
|
|
200
|
+
}
|
|
201
|
+
return { cmd: 'npm', args: ['test', '--', '--passWithNoTests'] };
|
|
184
202
|
}
|
|
185
|
-
} catch
|
|
203
|
+
} catch {
|
|
186
204
|
// package.json is malformed, fall through to default
|
|
187
205
|
}
|
|
188
206
|
}
|
|
189
207
|
|
|
190
208
|
// Default to jest via exec resolver
|
|
191
|
-
return
|
|
209
|
+
return getExecParts('jest', [...testFiles, '--passWithNoTests']);
|
|
192
210
|
}
|
|
193
211
|
|
|
194
212
|
/**
|
|
@@ -27,7 +27,7 @@ const path = require('path');
|
|
|
27
27
|
* Validate a script name is safe for shell usage.
|
|
28
28
|
* Rejects names containing shell metacharacters.
|
|
29
29
|
*/
|
|
30
|
-
const UNSAFE_CHARS = /[;&|$`()"'\\<>!\n\r]/;
|
|
30
|
+
const UNSAFE_CHARS = /[;&|$`()"'\\<>!\n\r/]/;
|
|
31
31
|
function isSafeScriptName(name) {
|
|
32
32
|
return typeof name === 'string' && name.length > 0 && name.length < 100 && !UNSAFE_CHARS.test(name);
|
|
33
33
|
}
|
|
@@ -183,8 +183,9 @@ function getCommand(name, options = {}) {
|
|
|
183
183
|
// 1. Check config override
|
|
184
184
|
const configScripts = config.scripts || {};
|
|
185
185
|
if (configScripts[name] && typeof configScripts[name] === 'string') {
|
|
186
|
-
// Validate config override
|
|
187
|
-
|
|
186
|
+
// Validate entire config override — reject if any part contains shell metacharacters
|
|
187
|
+
const overrideParts = configScripts[name].trim().split(/\s+/).filter(Boolean);
|
|
188
|
+
if (!overrideParts.every(part => isSafeScriptName(part))) return null;
|
|
188
189
|
return configScripts[name];
|
|
189
190
|
}
|
|
190
191
|
|
package/scripts/flow-utils.js
CHANGED
|
@@ -750,17 +750,19 @@ function safeJsonParse(filePath, defaultValue = null) {
|
|
|
750
750
|
}
|
|
751
751
|
|
|
752
752
|
/**
|
|
753
|
-
* Safely parse a JSON string with prototype pollution protection
|
|
754
|
-
* Use this when you already have the JSON content as a string
|
|
753
|
+
* Safely parse a JSON string with prototype pollution protection.
|
|
754
|
+
* Use this when you already have the JSON content as a string.
|
|
755
|
+
* Note: Unlike safeJsonParse (file-based), this allows arrays through
|
|
756
|
+
* since it validates typeof === 'object' which is true for arrays.
|
|
755
757
|
* @param {string} jsonString - JSON string to parse
|
|
756
758
|
* @param {*} [defaultValue=null] - Default value if parsing fails
|
|
757
|
-
* @returns {object|null} Parsed JSON or defaultValue on error
|
|
759
|
+
* @returns {object|Array|null} Parsed JSON (object or array) or defaultValue on error
|
|
758
760
|
*/
|
|
759
761
|
function safeJsonParseString(jsonString, defaultValue = null) {
|
|
760
762
|
try {
|
|
761
763
|
const parsed = JSON.parse(jsonString);
|
|
762
764
|
|
|
763
|
-
// Validate it's an object
|
|
765
|
+
// Validate it's an object or array (not primitive for config files)
|
|
764
766
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
765
767
|
return defaultValue;
|
|
766
768
|
}
|
|
@@ -17,14 +17,21 @@
|
|
|
17
17
|
|
|
18
18
|
const registeredExtensions = new Map();
|
|
19
19
|
|
|
20
|
+
// Extension names must be DNS-label-like: lowercase alphanumeric + hyphens, no trailing hyphens, max 64 chars
|
|
21
|
+
const VALID_EXTENSION_NAME = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
22
|
+
|
|
20
23
|
/**
|
|
21
24
|
* Register an extension's hook module.
|
|
25
|
+
* Note: This registry is in-process only — each hook invocation runs in a fresh
|
|
26
|
+
* Node.js process, so registrations do not persist across hook calls.
|
|
27
|
+
* For cross-invocation extension state, use settings.json (as postinstall.js does).
|
|
28
|
+
*
|
|
22
29
|
* @param {string} name - Extension name (e.g., 'teams')
|
|
23
30
|
* @param {Object} hookModule - Module object with hook functions
|
|
24
31
|
* @returns {boolean} True if registered, false if already exists
|
|
25
32
|
*/
|
|
26
33
|
function register(name, hookModule) {
|
|
27
|
-
if (!name || typeof name !== 'string') {
|
|
34
|
+
if (!name || typeof name !== 'string' || !VALID_EXTENSION_NAME.test(name)) {
|
|
28
35
|
if (process.env.DEBUG) {
|
|
29
36
|
console.error(`[extension-registry] Invalid extension name: ${name}`);
|
|
30
37
|
}
|
|
@@ -11,6 +11,7 @@ const { gatherSessionContext } = require('../../core/session-context');
|
|
|
11
11
|
const { claudeCodeAdapter } = require('../../adapters/claude-code');
|
|
12
12
|
const { setCliSessionId, clearStaleCurrentTaskAsync } = require('../../../flow-session-state');
|
|
13
13
|
const { checkAndResetStalePhase } = require('../../core/phase-gate');
|
|
14
|
+
const { safeJsonParseString } = require('../../../flow-utils');
|
|
14
15
|
|
|
15
16
|
// Lazy-load bridge state to avoid circular dependencies
|
|
16
17
|
let autoSyncBridge = null;
|
|
@@ -42,7 +43,7 @@ async function main() {
|
|
|
42
43
|
inputData += chunk;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
const input = inputData ?
|
|
46
|
+
const input = inputData ? (safeJsonParseString(inputData, {}) || {}) : {};
|
|
46
47
|
const parsedInput = claudeCodeAdapter.parseInput(input);
|
|
47
48
|
|
|
48
49
|
// Store CLI session ID for tracking (CLI-agnostic via session-state)
|
|
@@ -109,17 +110,20 @@ async function main() {
|
|
|
109
110
|
// Retry pending suggestions (fire-and-forget)
|
|
110
111
|
community.retryPendingSuggestions(config).catch(() => {});
|
|
111
112
|
|
|
112
|
-
// Pull community knowledge (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
community
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
113
|
+
// Pull community knowledge (respects pullOnSessionStart toggle)
|
|
114
|
+
if (config.community?.pullOnSessionStart !== false) {
|
|
115
|
+
// Non-blocking pull with 5s timeout — uses cache if unavailable
|
|
116
|
+
const knowledge = await community.pullFromServer(config);
|
|
117
|
+
if (knowledge && coreResult && coreResult.context) {
|
|
118
|
+
coreResult.context.communityKnowledge = knowledge;
|
|
119
|
+
|
|
120
|
+
// Merge community knowledge into local state files (Phase C2)
|
|
121
|
+
try {
|
|
122
|
+
community.mergeCommunityKnowledge(knowledge, config);
|
|
123
|
+
} catch (mergeErr) {
|
|
124
|
+
if (process.env.DEBUG) {
|
|
125
|
+
console.error(`[session-start] Community merge failed: ${mergeErr.message}`);
|
|
126
|
+
}
|
|
123
127
|
}
|
|
124
128
|
}
|
|
125
129
|
}
|