@vtstech/pi-shared 1.1.8 → 1.2.0

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/config-io.js CHANGED
@@ -2,6 +2,16 @@
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import os from "node:os";
5
+
6
+ // shared/debug.ts
7
+ var DEBUG_ENABLED = process.env.PI_EXTENSIONS_DEBUG === "1";
8
+ function debugLog(module, message, ...args) {
9
+ if (!DEBUG_ENABLED) return;
10
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
11
+ console.debug(`[pi-ext:${module}] ${timestamp} ${message}`, ...args);
12
+ }
13
+
14
+ // shared/config-io.ts
5
15
  var PI_AGENT_DIR = path.join(os.homedir(), ".pi", "agent");
6
16
  function readJsonConfig(filePath, defaultValue = {}) {
7
17
  try {
@@ -9,9 +19,7 @@ function readJsonConfig(filePath, defaultValue = {}) {
9
19
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
10
20
  }
11
21
  } catch (err) {
12
- if (typeof process !== "undefined" && process.env.PI_EXTENSIONS_DEBUG === "1") {
13
- console.debug(`[config-io] Failed to read config: ${filePath}`, err);
14
- }
22
+ debugLog("config-io", `failed to read config: ${filePath}`, err);
15
23
  }
16
24
  return defaultValue;
17
25
  }
package/errors.js CHANGED
@@ -24,11 +24,11 @@ var ApiError = class extends ExtensionError {
24
24
  this.name = "ApiError";
25
25
  }
26
26
  };
27
- var TimeoutError = class extends ExtensionError {
27
+ var ExtensionTimeoutError = class extends ExtensionError {
28
28
  constructor(message, timeoutMs) {
29
29
  super(message, "TIMEOUT");
30
30
  __publicField(this, "timeoutMs", timeoutMs);
31
- this.name = "TimeoutError";
31
+ this.name = "ExtensionTimeoutError";
32
32
  }
33
33
  };
34
34
  var SecurityError = class extends ExtensionError {
@@ -50,7 +50,7 @@ export {
50
50
  ApiError,
51
51
  ConfigError,
52
52
  ExtensionError,
53
+ ExtensionTimeoutError,
53
54
  SecurityError,
54
- TimeoutError,
55
55
  ToolError
56
56
  };
package/ollama.js CHANGED
@@ -12,7 +12,7 @@ function debugLog(module, message, ...args) {
12
12
  }
13
13
 
14
14
  // shared/ollama.ts
15
- var EXTENSION_VERSION = "1.1.8";
15
+ var EXTENSION_VERSION = "1.2.0";
16
16
  var MODELS_JSON_PATH = path.join(os.homedir(), ".pi", "agent", "models.json");
17
17
  var _modelsJsonCache = null;
18
18
  var _ollamaBaseUrlCache = null;
@@ -180,7 +180,7 @@ async function fetchModelContextLength(baseUrl, modelName) {
180
180
  const numCtx = data?.model_info?.["num_ctx"];
181
181
  if (typeof numCtx === "number") return numCtx;
182
182
  } catch (err) {
183
- debugLog("ollama", `failed to fetch context length for ${model}`, err);
183
+ debugLog("ollama", `failed to fetch context length for ${modelName}`, err);
184
184
  return void 0;
185
185
  }
186
186
  return void 0;
@@ -214,7 +214,8 @@ var BUILTIN_PROVIDERS = {
214
214
  xai: { api: "openai-completions", baseUrl: "https://api.x.ai/v1", envKey: "XAI_API_KEY" },
215
215
  together: { api: "openai-completions", baseUrl: "https://api.together.xyz/v1", envKey: "TOGETHER_API_KEY" },
216
216
  fireworks: { api: "openai-completions", baseUrl: "https://api.fireworks.ai/inference/v1", envKey: "FIREWORKS_API_KEY" },
217
- cohere: { api: "cohere-chat", baseUrl: "https://api.cohere.com/v1", envKey: "COHERE_API_KEY" }
217
+ cohere: { api: "cohere-chat", baseUrl: "https://api.cohere.com/v1", envKey: "COHERE_API_KEY" },
218
+ zai: { api: "openai-completions", baseUrl: "https://open.bigmodel.cn/api/paas/v4", envKey: "ZAI_API_KEY" }
218
219
  };
219
220
  function detectModelFamily(modelName) {
220
221
  const name = modelName.toLowerCase();
@@ -234,6 +235,8 @@ function detectModelFamily(modelName) {
234
235
  ["gemma", "gemma2"],
235
236
  ["granite", "granite"],
236
237
  ["dolphin", "dolphin"],
238
+ ["glm-4", "glm"],
239
+ ["glm", "glm"],
237
240
  ["deepseek-r1", "deepseek-r1"],
238
241
  ["deepseek", "deepseek"],
239
242
  ["mistral", "qwen2"],
@@ -247,9 +250,9 @@ function detectModelFamily(modelName) {
247
250
  return "unknown";
248
251
  }
249
252
  function detectProvider(ctx) {
250
- const model2 = ctx.model;
251
- if (!model2) return { kind: "unknown", name: "none" };
252
- const providerName = model2.provider || "";
253
+ const model = ctx.model;
254
+ if (!model) return { kind: "unknown", name: "none" };
255
+ const providerName = model.provider || "";
253
256
  if (!providerName) return { kind: "unknown", name: "none" };
254
257
  const modelsJson = readModelsJson();
255
258
  const userProviderCfg = (modelsJson.providers || {})[providerName];
@@ -286,6 +289,11 @@ function detectProvider(ctx) {
286
289
  }
287
290
  return { kind: "unknown", name: providerName };
288
291
  }
292
+ function isLocalProvider(baseUrl, providerName) {
293
+ if (providerName === "ollama") return true;
294
+ const url = baseUrl || "";
295
+ return url.includes("localhost") || url.includes("127.0.0.1") || url.includes("0.0.0.0");
296
+ }
289
297
  export {
290
298
  BUILTIN_PROVIDERS,
291
299
  EXTENSION_VERSION,
@@ -297,6 +305,7 @@ export {
297
305
  fetchModelContextLength,
298
306
  fetchOllamaModels,
299
307
  getOllamaBaseUrl,
308
+ isLocalProvider,
300
309
  isReasoningModel,
301
310
  readModelsJson,
302
311
  readModifyWriteModelsJson,
package/package.json CHANGED
@@ -1,14 +1,17 @@
1
1
  {
2
2
  "name": "@vtstech/pi-shared",
3
- "version": "1.1.8",
3
+ "version": "1.2.0",
4
4
  "description": "Shared utilities for Pi Coding Agent extensions",
5
5
  "exports": {
6
+ "./config-io": "./config-io.js",
6
7
  "./debug": "./debug.js",
7
8
  "./format": "./format.js",
8
9
  "./model-test-utils": "./model-test-utils.js",
9
10
  "./ollama": "./ollama.js",
11
+ "./provider-sync": "./provider-sync.js",
10
12
  "./react-parser": "./react-parser.js",
11
13
  "./security": "./security.js",
14
+ "./test-report": "./test-report.js",
12
15
  "./types": "./types.js"
13
16
  },
14
17
  "keywords": ["pi-extensions"],
package/security.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // shared/security.ts
2
2
  import * as fs from "node:fs";
3
- import * as path from "node:path";
4
- import os from "node:os";
3
+ import * as path2 from "node:path";
4
+ import os2 from "node:os";
5
5
 
6
6
  // shared/debug.ts
7
7
  var DEBUG_ENABLED = process.env.PI_EXTENSIONS_DEBUG === "1";
@@ -13,8 +13,19 @@ function debugLog(module, message, ...args) {
13
13
 
14
14
  // shared/security.ts
15
15
  import dns from "node:dns";
16
- var SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
17
- var SECURITY_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "security.json");
16
+
17
+ // shared/config-io.ts
18
+ import * as path from "node:path";
19
+ import os from "node:os";
20
+ var PI_AGENT_DIR = path.join(os.homedir(), ".pi", "agent");
21
+ var SETTINGS_PATH = path.join(PI_AGENT_DIR, "settings.json");
22
+ var SECURITY_PATH = path.join(PI_AGENT_DIR, "security.json");
23
+ var REACT_MODE_PATH = path.join(PI_AGENT_DIR, "react-mode.json");
24
+ var MODEL_TEST_CONFIG_PATH = path.join(PI_AGENT_DIR, "model-test-config.json");
25
+
26
+ // shared/security.ts
27
+ var SETTINGS_PATH2 = SETTINGS_PATH;
28
+ var SECURITY_CONFIG_PATH = SECURITY_PATH;
18
29
  function getSecurityMode() {
19
30
  try {
20
31
  if (!fs.existsSync(SECURITY_CONFIG_PATH)) return "max";
@@ -28,7 +39,7 @@ function getSecurityMode() {
28
39
  }
29
40
  }
30
41
  function setSecurityMode(mode) {
31
- const configDir = path.dirname(SECURITY_CONFIG_PATH);
42
+ const configDir = path2.dirname(SECURITY_CONFIG_PATH);
32
43
  try {
33
44
  if (!fs.existsSync(configDir)) {
34
45
  fs.mkdirSync(configDir, { recursive: true });
@@ -222,7 +233,7 @@ function validatePath(filePath, allowedDirs) {
222
233
  }
223
234
  let resolved;
224
235
  try {
225
- resolved = path.resolve(filePath);
236
+ resolved = path2.resolve(filePath);
226
237
  try {
227
238
  resolved = fs.realpathSync(resolved);
228
239
  } catch {
@@ -240,9 +251,9 @@ function validatePath(filePath, allowedDirs) {
240
251
  "/etc/passwd",
241
252
  "/.ssh/",
242
253
  "/.gnupg/",
243
- path.join(os.homedir(), ".ssh"),
244
- path.join(os.homedir(), ".gnupg"),
245
- SETTINGS_PATH,
254
+ path2.join(os2.homedir(), ".ssh"),
255
+ path2.join(os2.homedir(), ".gnupg"),
256
+ SETTINGS_PATH2,
246
257
  SECURITY_CONFIG_PATH
247
258
  // NOTE: models.json is intentionally excluded from sensitivePaths.
248
259
  // Extensions use readModelsJson()/writeModelsJson() from shared/ollama.ts
@@ -262,7 +273,7 @@ function validatePath(filePath, allowedDirs) {
262
273
  if (allowedDirs) {
263
274
  for (const dir of allowedDirs) {
264
275
  try {
265
- const absDir = path.resolve(dir);
276
+ const absDir = path2.resolve(dir);
266
277
  if (resolved.startsWith(absDir)) return { valid: true, error: "" };
267
278
  } catch {
268
279
  }
@@ -345,12 +356,24 @@ function isSafeUrl(url, blockSsrf = true) {
345
356
  const mode = getSecurityMode();
346
357
  for (const pattern of BLOCKED_URL_ALWAYS) {
347
358
  if (normalized === pattern || normalized.endsWith("." + pattern) || normalized.startsWith(pattern)) {
359
+ if (/^\d|^::/.test(pattern)) {
360
+ const nextChar = normalized[pattern.length];
361
+ if (nextChar && nextChar !== "/" && nextChar !== ":" && !/\d/.test(nextChar)) {
362
+ continue;
363
+ }
364
+ }
348
365
  return { safe: false, error: `SSRF protection: blocked hostname pattern '${pattern}'` };
349
366
  }
350
367
  }
351
368
  if (mode === "max") {
352
369
  for (const pattern of BLOCKED_URL_MAX_ONLY) {
353
370
  if (normalized === pattern || normalized.endsWith("." + pattern) || normalized.startsWith(pattern)) {
371
+ if (/^\d|^::/.test(pattern)) {
372
+ const nextChar = normalized[pattern.length];
373
+ if (nextChar && nextChar !== "/" && nextChar !== ":" && !/\d/.test(nextChar)) {
374
+ continue;
375
+ }
376
+ }
354
377
  return { safe: false, error: `SSRF protection: blocked hostname pattern '${pattern}' (max mode)` };
355
378
  }
356
379
  }
@@ -359,31 +382,19 @@ function isSafeUrl(url, blockSsrf = true) {
359
382
  return { safe: true, error: "" };
360
383
  }
361
384
  var INJECTION_PATTERNS = [
362
- // Command chaining with dangerous commands only
385
+ // Semicolon chaining to dangerous commands — mode-independent.
386
+ // Unlike && (conditional), ; ALWAYS runs the second command.
363
387
  /;\s*(rm|sudo|chmod|chown|mkfs|dd|shred|kill|pkill)\b/i,
364
- // Piping to dangerous commands
365
- /\|\s*(rm|sudo|chmod|chown|shred|mkfs)\b/i,
366
- // AND chaining with dangerous commands
367
- /&&\s*(rm|sudo|chmod|chown|mkfs|dd|shred|kill)\b/i,
368
388
  // Command substitution (backticks) — still dangerous
369
389
  /`[^`]+`/,
370
390
  // Command substitution ($()) — still dangerous
371
391
  /\$\([^)]+\)/,
372
392
  // Variable expansion targeting sensitive env vars
373
- /\$\{?(?:HOME|USER|PATH|SHELL|PWD|SSH|GPG|API_KEY|TOKEN|SECRET|PASSWORD)\}?/i,
374
- // Bare pipe without space (likely injection, not intentional piping)
375
- /\|(?=[^\s|])/
393
+ /\$\{?(?:HOME|USER|PATH|SHELL|PWD|SSH|GPG|API_KEY|TOKEN|SECRET|PASSWORD)\}?/i
376
394
  ];
377
- function sanitizeCommand(command) {
378
- if (!command) return { isSafe: false, error: "Command cannot be empty", command: "" };
379
- let normalizedCmd = command.normalize("NFKC");
380
- normalizedCmd = normalizedCmd.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\ufeff\u2060-\u2069]/g, "");
381
- if (normalizedCmd !== command.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\ufeff\u2060-\u2069]/g, "").normalize("NFKC")) {
382
- debugLog("security", "command contained Unicode normalization variance (possible homoglyph bypass)", { original: command });
383
- }
384
- command = normalizedCmd;
395
+ function checkSingleCommand(command, mode) {
385
396
  const trimmed = command.trim();
386
- if (!trimmed) return { isSafe: false, error: "Command cannot be empty", command: "" };
397
+ if (!trimmed) return { isSafe: true, error: "", command: "" };
387
398
  const parts = trimmed.split(/\s+/);
388
399
  let baseCmd = parts[0].toLowerCase();
389
400
  if (baseCmd.includes("/")) baseCmd = baseCmd.split("/").pop();
@@ -396,23 +407,57 @@ function sanitizeCommand(command) {
396
407
  return { isSafe: false, error: `Blocked command: ${word} (critical)`, command: "" };
397
408
  }
398
409
  }
399
- const mode = getSecurityMode();
400
410
  if (mode === "max" && EXTENDED_COMMANDS.has(baseCmd)) {
401
411
  return { isSafe: false, error: `Blocked command: ${baseCmd} (max mode)`, command: "" };
402
412
  }
403
- const stripped = command.replace(/\n/g, " ").replace(/\r/g, " ");
404
- if (stripped !== command) {
413
+ for (const pattern of INJECTION_PATTERNS) {
414
+ if (pattern.test(trimmed)) {
415
+ return { isSafe: false, error: `Potential injection pattern detected in: ${trimmed}`, command: "" };
416
+ }
417
+ }
418
+ return { isSafe: true, error: "", command: trimmed };
419
+ }
420
+ function sanitizeCommand(command) {
421
+ if (!command) return { isSafe: false, error: "Command cannot be empty", command: "" };
422
+ let normalizedCmd = command.normalize("NFKC");
423
+ normalizedCmd = normalizedCmd.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\ufeff\u2060-\u2069]/g, "");
424
+ const strippedForCompare = command.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\ufeff\u2060-\u2069]/g, "").normalize("NFKC");
425
+ if (normalizedCmd !== strippedForCompare) {
426
+ return { isSafe: false, error: `Command rejected: Unicode normalization variance detected (possible homoglyph bypass)`, command: "" };
427
+ }
428
+ command = normalizedCmd;
429
+ const trimmed = command.trim();
430
+ if (!trimmed) return { isSafe: false, error: "Command cannot be empty", command: "" };
431
+ const newlineStripped = command.replace(/\n/g, " ").replace(/\r/g, " ");
432
+ if (newlineStripped !== command) {
405
433
  return { isSafe: false, error: "Newline characters detected: potential command injection", command: "" };
406
434
  }
407
435
  for (const pattern of INJECTION_PATTERNS) {
408
- if (pattern.test(command)) {
436
+ if (pattern.test(trimmed)) {
409
437
  return { isSafe: false, error: `Potential injection pattern detected`, command: "" };
410
438
  }
411
439
  }
440
+ const subCommands = [];
441
+ let remaining = trimmed;
442
+ const chainRegex = /&&|\|\||(?<!\|)\|(?!\|)/g;
443
+ let match;
444
+ let lastIndex = 0;
445
+ while ((match = chainRegex.exec(remaining)) !== null) {
446
+ subCommands.push(remaining.slice(lastIndex, match.index));
447
+ lastIndex = match.index + match[0].length;
448
+ }
449
+ subCommands.push(remaining.slice(lastIndex));
450
+ const mode = getSecurityMode();
451
+ for (const subCmd of subCommands) {
452
+ const result = checkSingleCommand(subCmd, mode);
453
+ if (!result.isSafe) {
454
+ return { isSafe: false, error: result.error, command: "" };
455
+ }
456
+ }
412
457
  return { isSafe: true, error: "", command };
413
458
  }
414
- var AUDIT_DIR = path.join(os.homedir(), ".pi", "agent");
415
- var AUDIT_LOG_PATH = path.join(AUDIT_DIR, "audit.log");
459
+ var AUDIT_DIR = path2.join(os2.homedir(), ".pi", "agent");
460
+ var AUDIT_LOG_PATH = path2.join(AUDIT_DIR, "audit.log");
416
461
  var AUDIT_BUFFER_MAX_ENTRIES = 50;
417
462
  var AUDIT_FLUSH_INTERVAL_MS = 500;
418
463
  var _auditBuffer = [];
@@ -445,6 +490,19 @@ function flushAuditBuffer() {
445
490
  function appendAuditEntry(entry) {
446
491
  try {
447
492
  ensureAuditFlushTimer();
493
+ const AUDIT_LOG_MAX_SIZE = 5 * 1024 * 1024;
494
+ try {
495
+ if (fs.existsSync(AUDIT_LOG_PATH)) {
496
+ const stat = fs.statSync(AUDIT_LOG_PATH);
497
+ if (stat.size > AUDIT_LOG_MAX_SIZE) {
498
+ const entries = readRecentAuditEntries(1e3);
499
+ const content = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
500
+ fs.writeFileSync(AUDIT_LOG_PATH, content, "utf-8");
501
+ }
502
+ }
503
+ } catch (err) {
504
+ debugLog("security", "audit log rotation failed", err);
505
+ }
448
506
  const enriched = { ...entry, securityMode: getSecurityMode() };
449
507
  const line = JSON.stringify(enriched) + "\n";
450
508
  _auditBuffer.push(line);
@@ -458,8 +516,31 @@ function appendAuditEntry(entry) {
458
516
  function readRecentAuditEntries(count = 50) {
459
517
  try {
460
518
  if (!fs.existsSync(AUDIT_LOG_PATH)) return [];
461
- const content = fs.readFileSync(AUDIT_LOG_PATH, "utf-8");
462
- const lines = content.trim().split("\n");
519
+ const fileSize = fs.statSync(AUDIT_LOG_PATH).size;
520
+ if (fileSize === 0) return [];
521
+ const fd = fs.openSync(AUDIT_LOG_PATH, "r");
522
+ const bufferSize = 8192;
523
+ const buffer = Buffer.alloc(bufferSize);
524
+ const lines = [];
525
+ let pos = fileSize;
526
+ let partial = "";
527
+ while (pos > 0 && lines.length < count) {
528
+ const readSize = Math.min(bufferSize, pos);
529
+ pos -= readSize;
530
+ fs.readSync(fd, buffer, 0, readSize, pos);
531
+ const chunk = buffer.slice(0, readSize).toString("utf-8");
532
+ partial = chunk + partial;
533
+ const lineBreak = partial.lastIndexOf("\n");
534
+ if (lineBreak !== -1) {
535
+ const complete = partial.slice(lineBreak + 1);
536
+ if (complete.trim()) lines.unshift(complete);
537
+ partial = partial.slice(0, lineBreak);
538
+ }
539
+ }
540
+ fs.closeSync(fd);
541
+ if (partial.trim() && lines.length < count) {
542
+ lines.unshift(partial);
543
+ }
463
544
  const recent = lines.slice(-count);
464
545
  return recent.map((line) => {
465
546
  try {
@@ -543,7 +624,7 @@ export {
543
624
  CRITICAL_COMMANDS,
544
625
  EXTENDED_COMMANDS,
545
626
  SECURITY_CONFIG_PATH,
546
- SETTINGS_PATH,
627
+ SETTINGS_PATH2 as SETTINGS_PATH,
547
628
  appendAuditEntry,
548
629
  checkBashToolInput,
549
630
  checkFileToolInput,
package/test-report.js CHANGED
@@ -29,7 +29,7 @@ import os from "node:os";
29
29
  var DEBUG_ENABLED = process.env.PI_EXTENSIONS_DEBUG === "1";
30
30
 
31
31
  // shared/ollama.ts
32
- var EXTENSION_VERSION = "1.1.8";
32
+ var EXTENSION_VERSION = "1.2.0";
33
33
  var MODELS_JSON_PATH = path.join(os.homedir(), ".pi", "agent", "models.json");
34
34
 
35
35
  // shared/test-report.ts
@@ -66,18 +66,18 @@ function formatTestSummary(tests, totalMs) {
66
66
  lines.push(info(`Score: ${passed}/${tests.length} tests passed`));
67
67
  return lines;
68
68
  }
69
- function formatRecommendation(model2, passed, total, via) {
69
+ function formatRecommendation(model, passed, total, via) {
70
70
  const suffix = via ? ` via ${via}` : "";
71
71
  const lines = [];
72
72
  lines.push(section("RECOMMENDATION"));
73
73
  if (passed === total) {
74
- lines.push(ok(`${model2} is a STRONG model${suffix} \u2014 full capability`));
74
+ lines.push(ok(`${model} is a STRONG model${suffix} \u2014 full capability`));
75
75
  } else if (passed > 0 && passed >= total - 1) {
76
- lines.push(ok(`${model2} is a GOOD model${suffix} \u2014 most capabilities work`));
76
+ lines.push(ok(`${model} is a GOOD model${suffix} \u2014 most capabilities work`));
77
77
  } else if (passed > 0 && passed >= total - 2) {
78
- lines.push(warn(`${model2} is USABLE${suffix} \u2014 some capabilities are limited`));
78
+ lines.push(warn(`${model} is USABLE${suffix} \u2014 some capabilities are limited`));
79
79
  } else {
80
- lines.push(fail(`${model2} is WEAK${suffix} \u2014 limited capabilities for agent use`));
80
+ lines.push(fail(`${model} is WEAK${suffix} \u2014 limited capabilities for agent use`));
81
81
  }
82
82
  return lines;
83
83
  }