@vtstech/pi-shared 1.1.6 → 1.1.7
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/README.md +3 -3
- package/model-test-utils.js +121 -0
- package/ollama.js +109 -22
- package/package.json +1 -1
- package/security.js +82 -5
package/README.md
CHANGED
|
@@ -10,10 +10,10 @@ This is an internal dependency — you don't need to install it directly. It's p
|
|
|
10
10
|
|--------|-------------|
|
|
11
11
|
| `debug` | Conditional debug logging via `PI_EXTENSIONS_DEBUG=1` env var — `debugLog(module, message, ...args)` |
|
|
12
12
|
| `format` | Section headers, indicators (ok/fail/warn/info), numeric formatters (bytes, ms, percentages), string utilities |
|
|
13
|
-
| `model-test-utils` | Shared test utilities — `ChatFn` abstraction, unified test functions
|
|
14
|
-
| `ollama` | Ollama base URL resolution, models.json I/O with TTL cache, model family detection, provider detection, Ollama API helpers |
|
|
13
|
+
| `model-test-utils` | Shared test utilities — `ChatFn` abstraction, unified test functions, scoring helpers, tool support cache, user config (`~/.pi/agent/model-test-config.json`), test history with regression detection (`~/.pi/agent/cache/model-test-history.json`) |
|
|
14
|
+
| `ollama` | Ollama base URL resolution, models.json I/O with TTL cache, async write mutex (`acquireModelsJsonLock`, `readModifyWriteModelsJson`), exponential backoff retry (`withRetry`), model family detection, provider detection, Ollama API helpers |
|
|
15
15
|
| `react-parser` | Multi-dialect ReAct text parser — 4 dialects (react, function, tool, call), `parseReact()`, `detectReactDialect()`, `fuzzyMatchToolName()` |
|
|
16
|
-
| `security` | Security mode toggle (`basic`/`max`), partitioned command blocklist (41 CRITICAL + 25 EXTENDED), mode-aware SSRF (
|
|
16
|
+
| `security` | Security mode toggle (`basic`/`max`), partitioned command blocklist (41 CRITICAL + 25 EXTENDED) with full-word scanning, mode-aware SSRF (22 + 7 patterns), path validation with symlink dereference, URL validation, command sanitization, DNS rebinding protection (`resolveAndCheckHostname`), buffered audit logging with mode tracking (`AUDIT_LOG_PATH` exported) |
|
|
17
17
|
| `types` | Type definitions (ToolSupportLevel, AuditEntry, etc.) |
|
|
18
18
|
|
|
19
19
|
## Usage
|
package/model-test-utils.js
CHANGED
|
@@ -38,6 +38,34 @@ var CONFIG = {
|
|
|
38
38
|
TEST_DELAY_MS: 1e4
|
|
39
39
|
// 10 seconds between tests to avoid rate limiting
|
|
40
40
|
};
|
|
41
|
+
var TEST_CONFIG_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
42
|
+
var TEST_CONFIG_PATH = path.join(TEST_CONFIG_DIR, "model-test-config.json");
|
|
43
|
+
function readTestConfig() {
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(TEST_CONFIG_PATH)) {
|
|
46
|
+
const raw = fs.readFileSync(TEST_CONFIG_PATH, "utf-8");
|
|
47
|
+
return JSON.parse(raw);
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
function getEffectiveConfig() {
|
|
54
|
+
const userConfig = readTestConfig();
|
|
55
|
+
return {
|
|
56
|
+
...CONFIG,
|
|
57
|
+
DEFAULT_TIMEOUT_MS: userConfig.defaultTimeoutMs ?? CONFIG.DEFAULT_TIMEOUT_MS,
|
|
58
|
+
CONNECT_TIMEOUT_S: userConfig.connectTimeoutS ?? CONFIG.CONNECT_TIMEOUT_S,
|
|
59
|
+
MAX_RETRIES: userConfig.maxRetries ?? CONFIG.MAX_RETRIES,
|
|
60
|
+
RETRY_DELAY_MS: userConfig.retryDelayMs ?? CONFIG.RETRY_DELAY_MS,
|
|
61
|
+
TEST_DELAY_MS: userConfig.testDelayMs ?? CONFIG.TEST_DELAY_MS,
|
|
62
|
+
TOOL_TEST_TIMEOUT_MS: userConfig.toolTestTimeoutMs ?? CONFIG.TOOL_TEST_TIMEOUT_MS,
|
|
63
|
+
PROVIDER_TIMEOUT_MS: userConfig.providerTimeoutMs ?? CONFIG.PROVIDER_TIMEOUT_MS,
|
|
64
|
+
PROVIDER_TOOL_TIMEOUT_MS: userConfig.providerToolTimeoutMs ?? CONFIG.PROVIDER_TOOL_TIMEOUT_MS,
|
|
65
|
+
NUM_PREDICT: userConfig.numPredict ?? CONFIG.NUM_PREDICT,
|
|
66
|
+
TEMPERATURE: userConfig.temperature ?? CONFIG.TEMPERATURE
|
|
67
|
+
};
|
|
68
|
+
}
|
|
41
69
|
var WEATHER_TOOL_DEFINITION = {
|
|
42
70
|
type: "function",
|
|
43
71
|
function: {
|
|
@@ -150,6 +178,91 @@ function cacheToolSupport(model, support, family) {
|
|
|
150
178
|
_toolSupportCacheInMemory = cache;
|
|
151
179
|
writeToolSupportCache(cache);
|
|
152
180
|
}
|
|
181
|
+
var TEST_HISTORY_DIR = path.join(os.homedir(), ".pi", "agent", "cache");
|
|
182
|
+
var TEST_HISTORY_PATH = path.join(TEST_HISTORY_DIR, "model-test-history.json");
|
|
183
|
+
var MAX_HISTORY_PER_MODEL = 50;
|
|
184
|
+
var MAX_HISTORY_TOTAL = 500;
|
|
185
|
+
function readTestHistory() {
|
|
186
|
+
try {
|
|
187
|
+
if (fs.existsSync(TEST_HISTORY_PATH)) {
|
|
188
|
+
const raw = fs.readFileSync(TEST_HISTORY_PATH, "utf-8");
|
|
189
|
+
return JSON.parse(raw);
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
}
|
|
193
|
+
return {};
|
|
194
|
+
}
|
|
195
|
+
function writeTestHistory(history) {
|
|
196
|
+
for (const model of Object.keys(history)) {
|
|
197
|
+
if (history[model].length > MAX_HISTORY_PER_MODEL) {
|
|
198
|
+
history[model] = history[model].slice(-MAX_HISTORY_PER_MODEL);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
let totalEntries = 0;
|
|
202
|
+
const modelsByRecency = Object.entries(history).map(([model, entries]) => ({
|
|
203
|
+
model,
|
|
204
|
+
entries,
|
|
205
|
+
lastEntry: entries[entries.length - 1]?.timestamp || ""
|
|
206
|
+
})).sort((a, b) => b.lastEntry.localeCompare(a.lastEntry));
|
|
207
|
+
const trimmedHistory = {};
|
|
208
|
+
for (const { model, entries } of modelsByRecency) {
|
|
209
|
+
if (totalEntries + entries.length > MAX_HISTORY_TOTAL) {
|
|
210
|
+
const remaining = MAX_HISTORY_TOTAL - totalEntries;
|
|
211
|
+
if (remaining <= 0) break;
|
|
212
|
+
trimmedHistory[model] = entries.slice(-remaining);
|
|
213
|
+
totalEntries += remaining;
|
|
214
|
+
} else {
|
|
215
|
+
trimmedHistory[model] = entries;
|
|
216
|
+
totalEntries += entries.length;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!fs.existsSync(TEST_HISTORY_DIR)) {
|
|
220
|
+
fs.mkdirSync(TEST_HISTORY_DIR, { recursive: true });
|
|
221
|
+
}
|
|
222
|
+
fs.writeFileSync(TEST_HISTORY_PATH, JSON.stringify(trimmedHistory, null, 2) + "\n", "utf-8");
|
|
223
|
+
}
|
|
224
|
+
function appendTestHistory(entry) {
|
|
225
|
+
const history = readTestHistory();
|
|
226
|
+
if (!history[entry.model]) {
|
|
227
|
+
history[entry.model] = [];
|
|
228
|
+
}
|
|
229
|
+
history[entry.model].push(entry);
|
|
230
|
+
writeTestHistory(history);
|
|
231
|
+
}
|
|
232
|
+
function getModelHistory(model, limit = 10) {
|
|
233
|
+
const history = readTestHistory();
|
|
234
|
+
const entries = history[model] || [];
|
|
235
|
+
return entries.slice(-limit);
|
|
236
|
+
}
|
|
237
|
+
function detectRegression(model, current) {
|
|
238
|
+
const history = readTestHistory();
|
|
239
|
+
const entries = history[model] || [];
|
|
240
|
+
if (entries.length < 2) return [];
|
|
241
|
+
const previous = entries[entries.length - 2];
|
|
242
|
+
const regressions = [];
|
|
243
|
+
const scoreOrder = ["STRONG", "MODERATE", "WEAK", "FAIL", "ERROR", "NO", "YES"];
|
|
244
|
+
const scoreRank = (s) => {
|
|
245
|
+
const idx = scoreOrder.indexOf(s);
|
|
246
|
+
return idx >= 0 ? idx : 99;
|
|
247
|
+
};
|
|
248
|
+
if (scoreRank(current.tests.reasoning.score) > scoreRank(previous.tests.reasoning.score)) {
|
|
249
|
+
regressions.push({ test: "Reasoning", previous: previous.tests.reasoning.score, current: current.tests.reasoning.score });
|
|
250
|
+
}
|
|
251
|
+
if (scoreRank(current.tests.toolUsage.score) > scoreRank(previous.tests.toolUsage.score)) {
|
|
252
|
+
regressions.push({ test: "Tool Usage", previous: previous.tests.toolUsage.score, current: current.tests.toolUsage.score });
|
|
253
|
+
}
|
|
254
|
+
if (scoreRank(current.tests.reactParsing.score) > scoreRank(previous.tests.reactParsing.score)) {
|
|
255
|
+
regressions.push({ test: "ReAct Parsing", previous: previous.tests.reactParsing.score, current: current.tests.reactParsing.score });
|
|
256
|
+
}
|
|
257
|
+
if (scoreRank(current.tests.instructionFollowing.score) > scoreRank(previous.tests.instructionFollowing.score)) {
|
|
258
|
+
regressions.push({ test: "Instructions", previous: previous.tests.instructionFollowing.score, current: current.tests.instructionFollowing.score });
|
|
259
|
+
}
|
|
260
|
+
const supportRank = (s) => s === "native" ? 0 : s === "react" ? 1 : 2;
|
|
261
|
+
if (supportRank(current.tests.toolSupport.level) > supportRank(previous.tests.toolSupport.level)) {
|
|
262
|
+
regressions.push({ test: "Tool Support", previous: previous.tests.toolSupport.level, current: current.tests.toolSupport.level });
|
|
263
|
+
}
|
|
264
|
+
return regressions;
|
|
265
|
+
}
|
|
153
266
|
var REASONING_PROMPT = `A snail climbs 3 feet up a wall each day, but slides back 2 feet each night. The wall is 10 feet tall. How many days does it take the snail to reach the top? Think step by step and give the final answer on its own line like: ANSWER: <number>`;
|
|
154
267
|
var TOOL_SYSTEM_PROMPT = "You are a helpful assistant. Use the available tools when needed.";
|
|
155
268
|
var TOOL_USER_PROMPT = "What's the weather like in Paris right now?";
|
|
@@ -309,11 +422,18 @@ The JSON object must have exactly these 4 keys:
|
|
|
309
422
|
}
|
|
310
423
|
export {
|
|
311
424
|
CONFIG,
|
|
425
|
+
TEST_CONFIG_PATH,
|
|
312
426
|
TOOL_SUPPORT_CACHE_PATH,
|
|
313
427
|
WEATHER_TOOL_DEFINITION,
|
|
428
|
+
appendTestHistory,
|
|
314
429
|
cacheToolSupport,
|
|
430
|
+
detectRegression,
|
|
315
431
|
getCachedToolSupport,
|
|
432
|
+
getEffectiveConfig,
|
|
433
|
+
getModelHistory,
|
|
316
434
|
parseTextToolCall,
|
|
435
|
+
readTestConfig,
|
|
436
|
+
readTestHistory,
|
|
317
437
|
readToolSupportCache,
|
|
318
438
|
scoreNativeToolCall,
|
|
319
439
|
scoreReasoning,
|
|
@@ -321,5 +441,6 @@ export {
|
|
|
321
441
|
testInstructionFollowingUnified,
|
|
322
442
|
testReasoningUnified,
|
|
323
443
|
testToolUsageUnified,
|
|
444
|
+
writeTestHistory,
|
|
324
445
|
writeToolSupportCache
|
|
325
446
|
};
|
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.
|
|
15
|
+
var EXTENSION_VERSION = "1.1.7";
|
|
16
16
|
var MODELS_JSON_PATH = path.join(os.homedir(), ".pi", "agent", "models.json");
|
|
17
17
|
var _modelsJsonCache = null;
|
|
18
18
|
var _ollamaBaseUrlCache = null;
|
|
@@ -71,35 +71,119 @@ function writeModelsJson(data) {
|
|
|
71
71
|
_modelsJsonCache = null;
|
|
72
72
|
_ollamaBaseUrlCache = null;
|
|
73
73
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
var _modelsJsonLock = null;
|
|
75
|
+
async function acquireModelsJsonLock() {
|
|
76
|
+
while (_modelsJsonLock) {
|
|
77
|
+
await _modelsJsonLock;
|
|
78
|
+
}
|
|
79
|
+
let releaseLock;
|
|
80
|
+
_modelsJsonLock = new Promise((resolve) => {
|
|
81
|
+
releaseLock = resolve;
|
|
77
82
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
return {
|
|
84
|
+
release: () => {
|
|
85
|
+
releaseLock();
|
|
86
|
+
_modelsJsonLock = null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
81
89
|
}
|
|
82
|
-
async function
|
|
90
|
+
async function readModifyWriteModelsJson(modifier) {
|
|
91
|
+
const { release } = await acquireModelsJsonLock();
|
|
83
92
|
try {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
const data = readModelsJson();
|
|
94
|
+
const modified = modifier(data);
|
|
95
|
+
if (modified === null) return false;
|
|
96
|
+
writeModelsJson(modified);
|
|
97
|
+
return true;
|
|
98
|
+
} finally {
|
|
99
|
+
release();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
103
|
+
maxRetries: 2,
|
|
104
|
+
baseDelayMs: 1e3,
|
|
105
|
+
maxDelayMs: 1e4,
|
|
106
|
+
retryOnTimeout: true,
|
|
107
|
+
retryOnConnectionError: true
|
|
108
|
+
};
|
|
109
|
+
function backoffDelay(attempt, baseDelayMs, maxDelayMs) {
|
|
110
|
+
const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
111
|
+
const jitter = delay * 0.25 * (Math.random() * 2 - 1);
|
|
112
|
+
return Math.max(0, Math.round(delay + jitter));
|
|
113
|
+
}
|
|
114
|
+
var RETRYABLE_ERROR_PATTERNS = [
|
|
115
|
+
"ECONNREFUSED",
|
|
116
|
+
"ECONNRESET",
|
|
117
|
+
"ENOTFOUND",
|
|
118
|
+
"ETIMEDOUT",
|
|
119
|
+
"fetch failed",
|
|
120
|
+
"network error",
|
|
121
|
+
"socket hang up",
|
|
122
|
+
"Empty response"
|
|
123
|
+
];
|
|
124
|
+
function isRetryableError(error, opts) {
|
|
125
|
+
if (error instanceof Error) {
|
|
126
|
+
if (error.name === "AbortError" && opts.retryOnTimeout) return true;
|
|
127
|
+
const msg = error.message;
|
|
128
|
+
if (opts.retryOnConnectionError && RETRYABLE_ERROR_PATTERNS.some((p) => msg.includes(p))) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
async function withRetry(fn, options) {
|
|
135
|
+
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
|
136
|
+
let lastError;
|
|
137
|
+
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
138
|
+
try {
|
|
139
|
+
return await fn();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
lastError = error;
|
|
142
|
+
if (attempt < opts.maxRetries && isRetryableError(error, opts)) {
|
|
143
|
+
const delay = backoffDelay(attempt, opts.baseDelayMs, opts.maxDelayMs);
|
|
144
|
+
debugLog("ollama", `Retry ${attempt + 1}/${opts.maxRetries} after ${delay}ms: ${error instanceof Error ? error.message : String(error)}`);
|
|
145
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw lastError;
|
|
152
|
+
}
|
|
153
|
+
async function fetchOllamaModels(baseUrl) {
|
|
154
|
+
return withRetry(async () => {
|
|
155
|
+
const res = await fetch(`${baseUrl}/api/tags`, {
|
|
156
|
+
signal: AbortSignal.timeout(5e3)
|
|
89
157
|
});
|
|
90
|
-
if (!res.ok)
|
|
158
|
+
if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
|
|
91
159
|
const data = await res.json();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
160
|
+
return data.models ?? [];
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async function fetchModelContextLength(baseUrl, modelName) {
|
|
164
|
+
return withRetry(async () => {
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`${baseUrl}/api/show`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "Content-Type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ name: modelName }),
|
|
170
|
+
signal: AbortSignal.timeout(3e4)
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) return void 0;
|
|
173
|
+
const data = await res.json();
|
|
174
|
+
for (const key of Object.keys(data?.model_info ?? {})) {
|
|
175
|
+
if (key.endsWith(".context_length")) {
|
|
176
|
+
const val = data.model_info[key];
|
|
177
|
+
if (typeof val === "number") return val;
|
|
178
|
+
}
|
|
96
179
|
}
|
|
180
|
+
const numCtx = data?.model_info?.["num_ctx"];
|
|
181
|
+
if (typeof numCtx === "number") return numCtx;
|
|
182
|
+
} catch {
|
|
183
|
+
return void 0;
|
|
97
184
|
}
|
|
98
|
-
const numCtx = data?.model_info?.["num_ctx"];
|
|
99
|
-
if (typeof numCtx === "number") return numCtx;
|
|
100
|
-
} catch {
|
|
101
185
|
return void 0;
|
|
102
|
-
}
|
|
186
|
+
});
|
|
103
187
|
}
|
|
104
188
|
async function fetchContextLengthsBatched(baseUrl, modelNames, batchSize = 3) {
|
|
105
189
|
const result = /* @__PURE__ */ new Map();
|
|
@@ -205,6 +289,7 @@ export {
|
|
|
205
289
|
BUILTIN_PROVIDERS,
|
|
206
290
|
EXTENSION_VERSION,
|
|
207
291
|
MODELS_JSON_PATH,
|
|
292
|
+
acquireModelsJsonLock,
|
|
208
293
|
detectModelFamily,
|
|
209
294
|
detectProvider,
|
|
210
295
|
fetchContextLengthsBatched,
|
|
@@ -213,5 +298,7 @@ export {
|
|
|
213
298
|
getOllamaBaseUrl,
|
|
214
299
|
isReasoningModel,
|
|
215
300
|
readModelsJson,
|
|
301
|
+
readModifyWriteModelsJson,
|
|
302
|
+
withRetry,
|
|
216
303
|
writeModelsJson
|
|
217
304
|
};
|
package/package.json
CHANGED
package/security.js
CHANGED
|
@@ -12,6 +12,7 @@ function debugLog(module, message, ...args) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
// shared/security.ts
|
|
15
|
+
import dns from "node:dns";
|
|
15
16
|
var SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
16
17
|
var SECURITY_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "security.json");
|
|
17
18
|
function getSecurityMode() {
|
|
@@ -248,6 +249,45 @@ function validatePath(filePath, allowedDirs) {
|
|
|
248
249
|
}
|
|
249
250
|
return { valid: false, error: `Path not in allowed directories: ${filePath}` };
|
|
250
251
|
}
|
|
252
|
+
function isLoopbackIp(ip) {
|
|
253
|
+
if (ip.startsWith("127.") || ip === "0.0.0.0") return true;
|
|
254
|
+
if (ip === "::1" || ip === "::ffff:0.0.0.0") return true;
|
|
255
|
+
if (ip.startsWith("::ffff:127.")) return true;
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
function isPrivateIp(ip) {
|
|
259
|
+
if (ip.startsWith("10.") || ip.startsWith("192.168.")) return true;
|
|
260
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
|
261
|
+
if (ip === "169.254.169.254") return true;
|
|
262
|
+
if (ip.startsWith("fc") || ip.startsWith("fd")) return true;
|
|
263
|
+
if (ip.startsWith("fe80:")) return true;
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
async function resolveAndCheckHostname(hostname, blockPrivate = true) {
|
|
267
|
+
try {
|
|
268
|
+
const addresses = await new Promise((resolve2, reject) => {
|
|
269
|
+
dns.lookup(hostname, { all: true }, (err, addresses2) => {
|
|
270
|
+
if (err) reject(err);
|
|
271
|
+
else resolve2(addresses2);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
if (!addresses || addresses.length === 0) {
|
|
275
|
+
return { safe: true, error: "" };
|
|
276
|
+
}
|
|
277
|
+
for (const addr of addresses) {
|
|
278
|
+
const ip = addr.address;
|
|
279
|
+
if (ip === "169.254.169.254") {
|
|
280
|
+
return { safe: false, error: `SSRF protection: hostname ${hostname} resolves to cloud metadata IP ${ip}` };
|
|
281
|
+
}
|
|
282
|
+
if (blockPrivate && (isLoopbackIp(ip) || isPrivateIp(ip))) {
|
|
283
|
+
return { safe: false, error: `SSRF protection: hostname ${hostname} resolves to private/reserved IP ${ip} (DNS rebinding check)` };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { safe: true, error: "" };
|
|
287
|
+
} catch {
|
|
288
|
+
return { safe: true, error: "" };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
251
291
|
function isSafeUrl(url, blockSsrf = true) {
|
|
252
292
|
if (!url) return { safe: false, error: "URL cannot be empty" };
|
|
253
293
|
let parsed;
|
|
@@ -312,8 +352,13 @@ function sanitizeCommand(command) {
|
|
|
312
352
|
let baseCmd = parts[0].toLowerCase();
|
|
313
353
|
if (baseCmd.includes("/")) baseCmd = baseCmd.split("/").pop();
|
|
314
354
|
if (baseCmd.includes("\\")) baseCmd = baseCmd.split("\\").pop();
|
|
315
|
-
|
|
316
|
-
|
|
355
|
+
for (const raw of parts) {
|
|
356
|
+
let word = raw.toLowerCase();
|
|
357
|
+
if (word.includes("/")) word = word.split("/").pop();
|
|
358
|
+
if (word.includes("\\")) word = word.split("\\").pop();
|
|
359
|
+
if (CRITICAL_COMMANDS.has(word)) {
|
|
360
|
+
return { isSafe: false, error: `Blocked command: ${word} (critical)`, command: "" };
|
|
361
|
+
}
|
|
317
362
|
}
|
|
318
363
|
const mode = getSecurityMode();
|
|
319
364
|
if (mode === "max" && EXTENDED_COMMANDS.has(baseCmd)) {
|
|
@@ -332,16 +377,46 @@ function sanitizeCommand(command) {
|
|
|
332
377
|
}
|
|
333
378
|
var AUDIT_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
334
379
|
var AUDIT_LOG_PATH = path.join(AUDIT_DIR, "audit.log");
|
|
335
|
-
|
|
380
|
+
var AUDIT_BUFFER_MAX_ENTRIES = 50;
|
|
381
|
+
var AUDIT_FLUSH_INTERVAL_MS = 500;
|
|
382
|
+
var _auditBuffer = [];
|
|
383
|
+
var _auditFlushTimer = null;
|
|
384
|
+
function ensureAuditFlushTimer() {
|
|
385
|
+
if (_auditFlushTimer) return;
|
|
386
|
+
_auditFlushTimer = setInterval(() => {
|
|
387
|
+
if (_auditBuffer.length > 0) {
|
|
388
|
+
flushAuditBuffer();
|
|
389
|
+
}
|
|
390
|
+
}, AUDIT_FLUSH_INTERVAL_MS);
|
|
391
|
+
const timerRef = _auditFlushTimer;
|
|
392
|
+
if (timerRef.unref) {
|
|
393
|
+
timerRef.unref();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function flushAuditBuffer() {
|
|
397
|
+
if (_auditBuffer.length === 0) return;
|
|
336
398
|
try {
|
|
337
399
|
if (!fs.existsSync(AUDIT_DIR)) {
|
|
338
400
|
fs.mkdirSync(AUDIT_DIR, { recursive: true });
|
|
339
401
|
}
|
|
402
|
+
const batch = _auditBuffer.join("");
|
|
403
|
+
fs.appendFileSync(AUDIT_LOG_PATH, batch, "utf-8");
|
|
404
|
+
} catch (err) {
|
|
405
|
+
debugLog("security", "audit buffer flush failure", err);
|
|
406
|
+
}
|
|
407
|
+
_auditBuffer = [];
|
|
408
|
+
}
|
|
409
|
+
function appendAuditEntry(entry) {
|
|
410
|
+
try {
|
|
411
|
+
ensureAuditFlushTimer();
|
|
340
412
|
const enriched = { ...entry, securityMode: getSecurityMode() };
|
|
341
413
|
const line = JSON.stringify(enriched) + "\n";
|
|
342
|
-
|
|
414
|
+
_auditBuffer.push(line);
|
|
415
|
+
if (_auditBuffer.length >= AUDIT_BUFFER_MAX_ENTRIES) {
|
|
416
|
+
flushAuditBuffer();
|
|
417
|
+
}
|
|
343
418
|
} catch (err) {
|
|
344
|
-
debugLog("security", "audit log
|
|
419
|
+
debugLog("security", "audit log entry creation failure", err);
|
|
345
420
|
}
|
|
346
421
|
}
|
|
347
422
|
function readRecentAuditEntries(count = 50) {
|
|
@@ -432,9 +507,11 @@ export {
|
|
|
432
507
|
checkFileToolInput,
|
|
433
508
|
checkHttpToolInput,
|
|
434
509
|
checkInjectionPatterns,
|
|
510
|
+
flushAuditBuffer,
|
|
435
511
|
getSecurityMode,
|
|
436
512
|
isSafeUrl,
|
|
437
513
|
readRecentAuditEntries,
|
|
514
|
+
resolveAndCheckHostname,
|
|
438
515
|
sanitizeCommand,
|
|
439
516
|
setSecurityMode,
|
|
440
517
|
validatePath
|