@unrdf/kgc-probe 26.4.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.
@@ -0,0 +1,454 @@
1
+ /**
2
+ * @fileoverview Tooling Surface Probe - Safe API-based tool detection
3
+ * @module @unrdf/kgc-probe/probes/tooling
4
+ *
5
+ * CRITICAL: This probe uses ONLY safe APIs with explicit allowlisting.
6
+ * NO arbitrary command execution. All commands timeout at 5s.
7
+ *
8
+ * Allowlist: ['git', 'node', 'npm', 'pnpm', 'which']
9
+ *
10
+ * @agent Agent 8 - Tooling Surface Probe (KGC Probe Swarm)
11
+ */
12
+
13
+ import { execFile } from 'node:child_process';
14
+ import { promisify } from 'node:util';
15
+ import { z } from 'zod';
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ // ============================================================================
20
+ // SCHEMAS
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Observation schema - represents a single probe measurement
25
+ * @typedef {Object} Observation
26
+ * @property {string} method - Probe method identifier (e.g., "tooling.git_version")
27
+ * @property {Record<string, any>} inputs - Input parameters used for probing
28
+ * @property {Record<string, any>} outputs - Observed outputs/measurements
29
+ * @property {string} [guardDecision] - Guard decision: "allowed", "denied", "unknown"
30
+ * @property {Record<string, any>} [metadata] - Additional metadata
31
+ */
32
+ const ObservationSchema = z.object({
33
+ method: z.string().min(1),
34
+ inputs: z.record(z.any()),
35
+ outputs: z.record(z.any()),
36
+ guardDecision: z.enum(['allowed', 'denied', 'unknown']).optional(),
37
+ metadata: z.record(z.any()).optional(),
38
+ });
39
+
40
+ /**
41
+ * Probe configuration schema
42
+ * @typedef {Object} ProbeConfig
43
+ * @property {number} [timeout] - Command timeout in milliseconds (default: 5000)
44
+ * @property {boolean} [strict] - Strict mode - fail on any error (default: false)
45
+ */
46
+ const ProbeConfigSchema = z.object({
47
+ timeout: z.number().int().positive().max(10000).default(5000),
48
+ strict: z.boolean().default(false),
49
+ }).default({});
50
+
51
+ // ============================================================================
52
+ // GUARD: COMMAND ALLOWLIST (POKA-YOKE)
53
+ // ============================================================================
54
+
55
+ /**
56
+ * CRITICAL: Allowlisted commands - ONLY these can be executed
57
+ * NO shell metacharacters. NO arbitrary commands.
58
+ */
59
+ const ALLOWED_COMMANDS = new Set(['git', 'node', 'npm', 'pnpm', 'which']);
60
+
61
+ /**
62
+ * Guard function - validates command is allowlisted
63
+ * @param {string} command - Command to validate
64
+ * @returns {boolean} True if allowed, false otherwise
65
+ */
66
+ function isCommandAllowed(command) {
67
+ return ALLOWED_COMMANDS.has(command);
68
+ }
69
+
70
+ /**
71
+ * Validates command arguments for shell metacharacters
72
+ * @param {string[]} args - Command arguments
73
+ * @returns {boolean} True if safe, false if potentially dangerous
74
+ */
75
+ function argsAreSafe(args) {
76
+ // No shell metacharacters allowed
77
+ const dangerous = /[;&|`$<>(){}[\]\\'"]/;
78
+ return args.every(arg => !dangerous.test(arg));
79
+ }
80
+
81
+ // ============================================================================
82
+ // SAFE COMMAND EXECUTION
83
+ // ============================================================================
84
+
85
+ /**
86
+ * Executes a command with strict safety guards
87
+ * @param {string} command - Command name (must be allowlisted)
88
+ * @param {string[]} args - Command arguments (no shell metacharacters)
89
+ * @param {number} timeout - Timeout in milliseconds
90
+ * @returns {Promise<{stdout: string, stderr: string, success: boolean, guardDecision: string}>}
91
+ */
92
+ async function safeExec(command, args, timeout) {
93
+ // Guard 1: Command allowlist check
94
+ if (!isCommandAllowed(command)) {
95
+ return {
96
+ stdout: '',
97
+ stderr: `Command '${command}' not in allowlist`,
98
+ success: false,
99
+ guardDecision: 'denied',
100
+ };
101
+ }
102
+
103
+ // Guard 2: Argument safety check
104
+ if (!argsAreSafe(args)) {
105
+ return {
106
+ stdout: '',
107
+ stderr: 'Arguments contain shell metacharacters',
108
+ success: false,
109
+ guardDecision: 'denied',
110
+ };
111
+ }
112
+
113
+ // Execute with timeout (no shell, no stdin, capture stdout/stderr only)
114
+ try {
115
+ const { stdout, stderr } = await execFileAsync(command, args, {
116
+ timeout,
117
+ maxBuffer: 1024 * 1024, // 1MB max output
118
+ shell: false, // CRITICAL: NO shell execution
119
+ windowsHide: true,
120
+ });
121
+
122
+ return {
123
+ stdout: stdout.trim(),
124
+ stderr: stderr.trim(),
125
+ success: true,
126
+ guardDecision: 'allowed',
127
+ };
128
+ } catch (error) {
129
+ // Command failed or timed out
130
+ return {
131
+ stdout: error.stdout?.trim() || '',
132
+ stderr: error.stderr?.trim() || error.message,
133
+ success: false,
134
+ guardDecision: error.code === 'ETIMEDOUT' ? 'unknown' : 'allowed',
135
+ };
136
+ }
137
+ }
138
+
139
+ // ============================================================================
140
+ // TOOL DETECTION
141
+ // ============================================================================
142
+
143
+ /**
144
+ * Probes for git availability and version
145
+ * @param {number} timeout - Timeout in milliseconds
146
+ * @returns {Promise<Observation>}
147
+ */
148
+ async function probeGit(timeout) {
149
+ const result = await safeExec('git', ['--version'], timeout);
150
+
151
+ const outputs = {};
152
+ const metadata = {};
153
+
154
+ if (result.success && result.stdout) {
155
+ // Parse version: "git version 2.34.1" -> "2.34.1"
156
+ const match = result.stdout.match(/git version ([\d.]+)/);
157
+ if (match) {
158
+ outputs.version = match[1];
159
+ outputs.available = true;
160
+ }
161
+ } else {
162
+ outputs.available = false;
163
+ metadata.error = result.stderr || 'Command failed';
164
+ }
165
+
166
+ return {
167
+ method: 'tooling.git_version',
168
+ inputs: { command: 'git', args: ['--version'] },
169
+ outputs,
170
+ guardDecision: result.guardDecision,
171
+ metadata,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Probes for Node.js availability and version
177
+ * @param {number} timeout - Timeout in milliseconds
178
+ * @returns {Promise<Observation>}
179
+ */
180
+ async function probeNode(timeout) {
181
+ const result = await safeExec('node', ['--version'], timeout);
182
+
183
+ const outputs = {};
184
+ const metadata = {};
185
+
186
+ if (result.success && result.stdout) {
187
+ // Parse version: "v18.17.0" -> "18.17.0"
188
+ const version = result.stdout.replace(/^v/, '');
189
+ outputs.version = version;
190
+ outputs.available = true;
191
+ } else {
192
+ outputs.available = false;
193
+ metadata.error = result.stderr || 'Command failed';
194
+ }
195
+
196
+ return {
197
+ method: 'tooling.node_version',
198
+ inputs: { command: 'node', args: ['--version'] },
199
+ outputs,
200
+ guardDecision: result.guardDecision,
201
+ metadata,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Probes for npm availability and version
207
+ * @param {number} timeout - Timeout in milliseconds
208
+ * @returns {Promise<Observation>}
209
+ */
210
+ async function probeNpm(timeout) {
211
+ const result = await safeExec('npm', ['--version'], timeout);
212
+
213
+ const outputs = {};
214
+ const metadata = {};
215
+
216
+ if (result.success && result.stdout) {
217
+ outputs.version = result.stdout;
218
+ outputs.available = true;
219
+ } else {
220
+ outputs.available = false;
221
+ metadata.error = result.stderr || 'Command failed';
222
+ }
223
+
224
+ return {
225
+ method: 'tooling.npm_version',
226
+ inputs: { command: 'npm', args: ['--version'] },
227
+ outputs,
228
+ guardDecision: result.guardDecision,
229
+ metadata,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Probes for pnpm availability and version
235
+ * @param {number} timeout - Timeout in milliseconds
236
+ * @returns {Promise<Observation>}
237
+ */
238
+ async function probePnpm(timeout) {
239
+ const result = await safeExec('pnpm', ['--version'], timeout);
240
+
241
+ const outputs = {};
242
+ const metadata = {};
243
+
244
+ if (result.success && result.stdout) {
245
+ outputs.version = result.stdout;
246
+ outputs.available = true;
247
+ } else {
248
+ outputs.available = false;
249
+ metadata.error = result.stderr || 'Command failed';
250
+ }
251
+
252
+ return {
253
+ method: 'tooling.pnpm_version',
254
+ inputs: { command: 'pnpm', args: ['--version'] },
255
+ outputs,
256
+ guardDecision: result.guardDecision,
257
+ metadata,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Probes for shell availability
263
+ * Uses 'which' to detect shell binaries (sh, bash)
264
+ * @param {number} timeout - Timeout in milliseconds
265
+ * @returns {Promise<Observation[]>}
266
+ */
267
+ async function probeShells(timeout) {
268
+ const shells = ['sh', 'bash'];
269
+ const observations = [];
270
+
271
+ for (const shell of shells) {
272
+ const result = await safeExec('which', [shell], timeout);
273
+
274
+ const outputs = {};
275
+ const metadata = {};
276
+
277
+ if (result.success && result.stdout) {
278
+ outputs.path = result.stdout;
279
+ outputs.available = true;
280
+ } else {
281
+ outputs.available = false;
282
+ metadata.reason = result.stderr || 'Not found';
283
+ }
284
+
285
+ observations.push({
286
+ method: `tooling.shell_${shell}`,
287
+ inputs: { command: 'which', args: [shell] },
288
+ outputs,
289
+ guardDecision: result.guardDecision,
290
+ metadata,
291
+ });
292
+ }
293
+
294
+ return observations;
295
+ }
296
+
297
+ /**
298
+ * Probes for build tools (make, cmake)
299
+ * @param {number} timeout - Timeout in milliseconds
300
+ * @returns {Promise<Observation[]>}
301
+ */
302
+ async function probeBuildTools(timeout) {
303
+ // NOTE: make and cmake are NOT in allowlist
304
+ // Return observations with guardDecision: "denied"
305
+ const tools = ['make', 'cmake'];
306
+ const observations = [];
307
+
308
+ for (const tool of tools) {
309
+ observations.push({
310
+ method: `tooling.build_${tool}`,
311
+ inputs: { command: tool },
312
+ outputs: { available: false },
313
+ guardDecision: 'denied',
314
+ metadata: { reason: 'not_in_allowlist' },
315
+ });
316
+ }
317
+
318
+ return observations;
319
+ }
320
+
321
+ /**
322
+ * Detects package manager in use
323
+ * Checks for lock files and tool availability
324
+ * @param {number} timeout - Timeout in milliseconds
325
+ * @returns {Promise<Observation>}
326
+ */
327
+ async function detectPackageManager(timeout) {
328
+ const outputs = {};
329
+ const metadata = {};
330
+
331
+ // Check npm
332
+ const npmResult = await safeExec('npm', ['--version'], timeout);
333
+ if (npmResult.success) {
334
+ outputs.npm = { available: true, version: npmResult.stdout };
335
+ }
336
+
337
+ // Check pnpm
338
+ const pnpmResult = await safeExec('pnpm', ['--version'], timeout);
339
+ if (pnpmResult.success) {
340
+ outputs.pnpm = { available: true, version: pnpmResult.stdout };
341
+ }
342
+
343
+ // Determine primary (prefer pnpm if both available)
344
+ if (outputs.pnpm?.available) {
345
+ outputs.primary = 'pnpm';
346
+ } else if (outputs.npm?.available) {
347
+ outputs.primary = 'npm';
348
+ } else {
349
+ outputs.primary = 'none';
350
+ metadata.warning = 'No package manager detected';
351
+ }
352
+
353
+ return {
354
+ method: 'tooling.package_manager',
355
+ inputs: {},
356
+ outputs,
357
+ guardDecision: 'allowed',
358
+ metadata,
359
+ };
360
+ }
361
+
362
+ // ============================================================================
363
+ // MAIN PROBE FUNCTION
364
+ // ============================================================================
365
+
366
+ /**
367
+ * Probes tooling surface using ONLY safe APIs
368
+ *
369
+ * Returns observations for:
370
+ * - Available CLI tools (git, node, npm, pnpm)
371
+ * - Tool versions
372
+ * - Shell availability (sh, bash)
373
+ * - Package manager detection
374
+ * - Build tool availability (denied due to allowlist)
375
+ *
376
+ * GUARD CONSTRAINTS:
377
+ * - ONLY allowlisted commands: ['git', 'node', 'npm', 'pnpm', 'which']
378
+ * - NO shell metacharacters
379
+ * - Each command: timeout 5s (default), no stdin, capture stdout only
380
+ * - If execution unavailable: returns observations with guardDecision: "unknown"
381
+ *
382
+ * @param {ProbeConfig} [config] - Probe configuration
383
+ * @returns {Promise<Observation[]>} Array of observations
384
+ *
385
+ * @example
386
+ * const observations = await probeTooling({ timeout: 5000 });
387
+ * observations.forEach(obs => {
388
+ * console.log(`${obs.method}: ${JSON.stringify(obs.outputs)}`);
389
+ * });
390
+ */
391
+ export async function probeTooling(config = {}) {
392
+ // Validate config
393
+ const validatedConfig = ProbeConfigSchema.parse(config);
394
+ const { timeout } = validatedConfig;
395
+
396
+ const observations = [];
397
+
398
+ try {
399
+ // Probe all tools in parallel
400
+ const [
401
+ gitObs,
402
+ nodeObs,
403
+ npmObs,
404
+ pnpmObs,
405
+ shellObs,
406
+ buildObs,
407
+ pkgMgrObs,
408
+ ] = await Promise.all([
409
+ probeGit(timeout),
410
+ probeNode(timeout),
411
+ probeNpm(timeout),
412
+ probePnpm(timeout),
413
+ probeShells(timeout),
414
+ probeBuildTools(timeout),
415
+ detectPackageManager(timeout),
416
+ ]);
417
+
418
+ // Collect all observations
419
+ observations.push(gitObs, nodeObs, npmObs, pnpmObs);
420
+ observations.push(...shellObs);
421
+ observations.push(...buildObs);
422
+ observations.push(pkgMgrObs);
423
+
424
+ } catch (error) {
425
+ // Process execution unavailable or catastrophic failure
426
+ observations.push({
427
+ method: 'tooling.execution_error',
428
+ inputs: {},
429
+ outputs: {},
430
+ guardDecision: 'unknown',
431
+ metadata: {
432
+ reason: 'process_execution_unavailable',
433
+ error: error.message,
434
+ },
435
+ });
436
+ }
437
+
438
+ // Validate all observations
439
+ return observations.map(obs => ObservationSchema.parse(obs));
440
+ }
441
+
442
+ // ============================================================================
443
+ // EXPORTS
444
+ // ============================================================================
445
+
446
+ /**
447
+ * Re-export schemas for external validation
448
+ */
449
+ export { ObservationSchema, ProbeConfigSchema };
450
+
451
+ /**
452
+ * Re-export guard functions for testing
453
+ */
454
+ export { isCommandAllowed, argsAreSafe, safeExec };