micro-models-agent 0.63.0 → 1.1.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/CHANGELOG.md +174 -0
- package/dist/cli/cache-line.js +30 -0
- package/dist/cli/command-suggest.js +38 -0
- package/dist/cli/commands.js +285 -60
- package/dist/cli/completer.js +16 -16
- package/dist/cli/json-payload.js +32 -0
- package/dist/cli/main.js +165 -77
- package/dist/cli/plugin-commands.js +5 -4
- package/dist/cli/relaunch.js +37 -0
- package/dist/cli/repl-commands.js +441 -307
- package/dist/cli/repl.js +360 -83
- package/dist/cli/run-result.js +12 -6
- package/dist/cli/security-commands.js +64 -60
- package/dist/cli/setup-order.js +57 -0
- package/dist/cli/setup-prompt.js +49 -0
- package/dist/cli/setup.js +52 -48
- package/dist/config/budget.js +48 -0
- package/dist/config/config.js +132 -70
- package/dist/config/defaults.js +37 -11
- package/dist/config/domains.js +9 -50
- package/dist/config/utils.js +56 -0
- package/dist/core/agent/audit-gate.js +49 -0
- package/dist/core/agent/compaction.js +89 -0
- package/dist/core/agent/constants.js +61 -0
- package/dist/core/agent/context-renderer.js +40 -0
- package/dist/core/agent/hallucination-gate.js +87 -0
- package/dist/core/agent/loop-state.js +53 -0
- package/dist/core/agent/prefix-monitor.js +101 -0
- package/dist/core/agent/reasoning-resolver.js +56 -0
- package/dist/core/agent/token-tracker.js +96 -0
- package/dist/core/agent/tool-batch.js +237 -0
- package/dist/core/agent/tool-output.js +62 -0
- package/dist/core/agent-moe.js +214 -69
- package/dist/core/agent.js +506 -546
- package/dist/core/bootstrap.js +297 -98
- package/dist/core/crash-handler.js +2 -1
- package/dist/core/prompt-builder.js +3 -0
- package/dist/core/prompt-overflow.js +307 -0
- package/dist/core/session-logger.js +34 -2
- package/dist/i18n/en.json +8 -4
- package/dist/i18n/ru.json +8 -4
- package/dist/index.js +5 -1
- package/dist/llm/cache-usage.js +76 -0
- package/dist/llm/image-utils.js +20 -16
- package/dist/llm/llm-errors.js +41 -0
- package/dist/llm/model-loader.js +30 -0
- package/dist/llm/openai-compat.js +287 -101
- package/dist/llm/orchestrator.js +140 -68
- package/dist/llm/provider-budget.js +68 -0
- package/dist/llm/provider.js +0 -1
- package/dist/llm/stream-state.js +26 -0
- package/dist/llm/token-counter.js +28 -0
- package/dist/logger/app-logger.js +12 -15
- package/dist/main.js +1755 -841
- package/dist/migration/detect.js +3 -1
- package/dist/modules/browser/actions.js +0 -3
- package/dist/modules/browser/bridge-client.js +2 -0
- package/dist/modules/browser/bridge-server.mjs +37 -4
- package/dist/modules/browser/driver.js +46 -4
- package/dist/modules/certification/cli.js +85 -42
- package/dist/modules/certification/loader.js +15 -1
- package/dist/modules/certification/manifest.js +126 -15
- package/dist/modules/certification/runner.js +4 -26
- package/dist/modules/certification/scenarios.js +184 -5
- package/dist/modules/certification/syntax-scenarios.js +51 -0
- package/dist/modules/context/chunk-query.js +25 -5
- package/dist/modules/context/fact-extractor.js +6 -2
- package/dist/modules/context/manager.js +23 -7
- package/dist/modules/execution/audit-runners.js +7 -1
- package/dist/modules/execution/auditor.js +3 -3
- package/dist/modules/execution/execution-plugin.js +22 -15
- package/dist/modules/execution/input-from.js +46 -0
- package/dist/modules/execution/module.js +107 -18
- package/dist/modules/execution/moe-executor.js +166 -54
- package/dist/modules/execution/plan-actions.js +524 -0
- package/dist/modules/execution/plan-steps.js +23 -0
- package/dist/modules/execution/plan-store.js +15 -3
- package/dist/modules/execution/plan-tool.js +6 -488
- package/dist/modules/execution/plan-validator.js +24 -0
- package/dist/modules/execution/stuck-detector.js +3 -18
- package/dist/modules/execution/tracker.js +14 -5
- package/dist/modules/execution/transient-error.js +30 -0
- package/dist/modules/execution/verifier.js +94 -7
- package/dist/modules/execution/windows-commands.js +11 -0
- package/dist/modules/hallucination/confidence.js +36 -23
- package/dist/modules/hallucination/consistency.js +3 -0
- package/dist/modules/hallucination/detector.js +8 -3
- package/dist/modules/hallucination/factual.js +26 -7
- package/dist/modules/hallucination/llm-judge.js +12 -2
- package/dist/modules/indexer/map-command.js +35 -0
- package/dist/modules/indexer/map-select.js +87 -0
- package/dist/modules/indexer/module.js +34 -22
- package/dist/modules/indexer/symbols.js +189 -0
- package/dist/modules/indexer/walker.js +96 -42
- package/dist/modules/lsp/check-tool.js +2 -1
- package/dist/modules/lsp/client.js +49 -32
- package/dist/modules/lsp/config.js +55 -2
- package/dist/modules/lsp/module.js +38 -5
- package/dist/modules/lsp/probe.js +4 -3
- package/dist/modules/lsp/project-root.js +41 -1
- package/dist/modules/lsp/startup-check.js +12 -4
- package/dist/modules/mcp/client.js +153 -104
- package/dist/modules/mcp/module.js +165 -41
- package/dist/modules/memory/module.js +4 -3
- package/dist/modules/plugins/builtin/lint-on-write.js +36 -6
- package/dist/modules/plugins/manager.js +47 -84
- package/dist/modules/pricing/index.js +17 -7
- package/dist/modules/pricing/prices.js +30 -12
- package/dist/modules/processes/index.js +1 -0
- package/dist/modules/processes/kill-tree.js +56 -0
- package/dist/modules/processes/registry.js +2 -54
- package/dist/modules/providers/cache.js +23 -0
- package/dist/modules/providers/factory.js +28 -0
- package/dist/modules/providers/fallback.js +7 -5
- package/dist/modules/providers/health.js +2 -1
- package/dist/modules/providers/index.js +1 -0
- package/dist/modules/providers/manager.js +17 -2
- package/dist/modules/providers/presets.js +79 -6
- package/dist/modules/reasoning/policy.js +40 -0
- package/dist/modules/reasoning/probe.js +111 -0
- package/dist/modules/security/audit-notifier.js +42 -27
- package/dist/modules/security/command-validator.js +25 -20
- package/dist/modules/security/encryption.js +6 -12
- package/dist/modules/security/network-validator.js +76 -5
- package/dist/modules/security/path-validator.js +77 -34
- package/dist/modules/security/rate-limiter.js +11 -0
- package/dist/modules/security/security-policies.js +1 -1
- package/dist/modules/security/session-encryption.js +13 -2
- package/dist/modules/security/session-isolation.js +2 -9
- package/dist/modules/session/manager.js +11 -0
- package/dist/modules/session/module.js +11 -3
- package/dist/modules/session/store.js +41 -5
- package/dist/modules/skills/loader.js +7 -1
- package/dist/modules/skills/module.js +2 -1
- package/dist/modules/updater/changelog-reader.js +94 -0
- package/dist/modules/updater/dev-detect.js +17 -0
- package/dist/modules/updater/index.js +1 -0
- package/dist/modules/updater/module.js +14 -3
- package/dist/output/bus.js +32 -0
- package/dist/output/channel.js +233 -0
- package/dist/output/format.js +14 -0
- package/dist/output/index.js +7 -0
- package/dist/output/json-sink.js +22 -0
- package/dist/output/machine.js +8 -0
- package/dist/output/session-sink.js +27 -0
- package/dist/output/types.js +1 -0
- package/dist/tools/approve.js +6 -2
- package/dist/tools/attach-image.js +11 -11
- package/dist/tools/auto-fixer.js +198 -0
- package/dist/tools/bash.js +142 -89
- package/dist/tools/chunk-query.js +10 -6
- package/dist/tools/download-file.js +1 -1
- package/dist/tools/edit-file.js +20 -2
- package/dist/tools/executor.js +54 -9
- package/dist/tools/glob-tool.js +7 -0
- package/dist/tools/grep-tool.js +15 -1
- package/dist/tools/index.js +3 -1
- package/dist/tools/list-dir.js +3 -1
- package/dist/tools/load-skill.js +2 -1
- package/dist/tools/mcp-call.js +1 -1
- package/dist/tools/move-file.js +5 -4
- package/dist/tools/path-utils.js +7 -0
- package/dist/tools/pipeline-run.js +1 -1
- package/dist/tools/prompt-io.js +28 -0
- package/dist/tools/question.js +12 -12
- package/dist/tools/scope-request.js +91 -0
- package/dist/tools/session-info.js +44 -0
- package/dist/tools/set-thinking.js +71 -0
- package/dist/tools/subagent.js +50 -9
- package/dist/tools/syntax-validator.js +177 -0
- package/dist/tools/user-input.js +16 -9
- package/dist/tools/write-file.js +17 -1
- package/dist/ui/diff.js +10 -0
- package/dist/ui/line-editor.js +179 -26
- package/dist/ui/line-math.js +20 -3
- package/dist/ui/md-formatter.js +100 -10
- package/dist/ui/output.js +5 -4
- package/dist/ui/plan-view.js +2 -7
- package/dist/ui/renderer.js +89 -85
- package/dist/ui/spinner.js +14 -4
- package/dist/utils/error.js +4 -0
- package/dist/utils/index.js +4 -0
- package/dist/utils/retry.js +17 -0
- package/dist/utils/sleep.js +23 -0
- package/dist/utils/truncate.js +9 -0
- package/package.json +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { writeFileSync, appendFileSync, existsSync, mkdirSync } from "fs";
|
|
2
2
|
import { join, dirname } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
+
import { backoffDelay } from "../../utils/retry";
|
|
5
|
+
import { defaultOutputBus } from "../../output";
|
|
4
6
|
/**
|
|
5
7
|
* Severity weights for comparison
|
|
6
8
|
*/
|
|
@@ -114,7 +116,13 @@ export class AuditNotifier {
|
|
|
114
116
|
this.writeToFile(notification);
|
|
115
117
|
// Send to webhook if configured
|
|
116
118
|
if (this.config.webhookUrl) {
|
|
117
|
-
|
|
119
|
+
try {
|
|
120
|
+
await this.sendToWebhook(notification);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
defaultOutputBus.log("warn", "audit", `[AuditNotifier] Webhook failed, adding to retry queue: ${error instanceof Error ? error.message : String(error)}`, { persist: true });
|
|
124
|
+
this.addToRetryQueue(notification);
|
|
125
|
+
}
|
|
118
126
|
}
|
|
119
127
|
return notification;
|
|
120
128
|
}
|
|
@@ -160,32 +168,31 @@ export class AuditNotifier {
|
|
|
160
168
|
appendFileSync(this.config.filePath, line + "\n", "utf8");
|
|
161
169
|
}
|
|
162
170
|
catch (error) {
|
|
163
|
-
|
|
171
|
+
defaultOutputBus.log("error", "audit", `[AuditNotifier] Failed to write to file: ${error instanceof Error ? error.message : String(error)}`, { persist: true });
|
|
164
172
|
}
|
|
165
173
|
}
|
|
166
174
|
/**
|
|
167
|
-
* Send notification to webhook
|
|
175
|
+
* Send notification to webhook. Throws on failure — retry policy is the
|
|
176
|
+
* caller's decision (notify() queues; processRetryQueue() counts attempts).
|
|
168
177
|
*/
|
|
169
178
|
async sendToWebhook(notification) {
|
|
170
179
|
if (!this.config.webhookUrl)
|
|
171
180
|
return;
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.webhookTimeout);
|
|
172
183
|
try {
|
|
173
|
-
const controller = new AbortController();
|
|
174
|
-
const timeoutId = setTimeout(() => controller.abort(), this.config.webhookTimeout);
|
|
175
184
|
const response = await fetch(this.config.webhookUrl, {
|
|
176
185
|
method: "POST",
|
|
177
186
|
headers: { "Content-Type": "application/json" },
|
|
178
187
|
body: JSON.stringify(notification),
|
|
179
188
|
signal: controller.signal,
|
|
180
189
|
});
|
|
181
|
-
clearTimeout(timeoutId);
|
|
182
190
|
if (!response.ok) {
|
|
183
191
|
throw new Error(`Webhook returned ${response.status}`);
|
|
184
192
|
}
|
|
185
193
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
this.addToRetryQueue(notification);
|
|
194
|
+
finally {
|
|
195
|
+
clearTimeout(timeoutId);
|
|
189
196
|
}
|
|
190
197
|
}
|
|
191
198
|
/**
|
|
@@ -204,25 +211,32 @@ export class AuditNotifier {
|
|
|
204
211
|
if (this.retryQueue.length === 0 || this.isProcessing)
|
|
205
212
|
return;
|
|
206
213
|
this.isProcessing = true;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
214
|
+
try {
|
|
215
|
+
while (this.retryQueue.length > 0) {
|
|
216
|
+
const item = this.retryQueue[0];
|
|
217
|
+
if (item.retries >= (this.config.maxRetries ?? 3)) {
|
|
218
|
+
defaultOutputBus.log("error", "audit", `[AuditNotifier] Max retries exceeded for notification: ${JSON.stringify(item.entry)}`, { persist: true });
|
|
219
|
+
this.retryQueue.shift();
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
await this.sendToWebhook(item.entry);
|
|
224
|
+
this.retryQueue.shift();
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
// sendToWebhook no longer queues copies itself — the retry counter
|
|
228
|
+
// actually increments and maxRetries is reachable.
|
|
229
|
+
item.retries++;
|
|
230
|
+
defaultOutputBus.log("warn", "audit", `[AuditNotifier] Webhook retry ${item.retries} failed: ${error instanceof Error ? error.message : String(error)}`, { persist: true });
|
|
231
|
+
// Exponential backoff before the next attempt on the same head item.
|
|
232
|
+
const delay = backoffDelay(item.retries, 1000);
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
234
|
+
}
|
|
223
235
|
}
|
|
224
236
|
}
|
|
225
|
-
|
|
237
|
+
finally {
|
|
238
|
+
this.isProcessing = false;
|
|
239
|
+
}
|
|
226
240
|
}
|
|
227
241
|
/**
|
|
228
242
|
* Read notifications from file
|
|
@@ -236,7 +250,8 @@ export class AuditNotifier {
|
|
|
236
250
|
const lines = content.split("\n").filter(Boolean);
|
|
237
251
|
return lines.slice(-limit).map((line) => JSON.parse(line));
|
|
238
252
|
}
|
|
239
|
-
catch {
|
|
253
|
+
catch (error) {
|
|
254
|
+
defaultOutputBus.log("error", "audit", `[AuditNotifier] Failed to read notifications: ${error instanceof Error ? error.message : String(error)}`, { persist: true });
|
|
240
255
|
return [];
|
|
241
256
|
}
|
|
242
257
|
}
|
|
@@ -19,7 +19,7 @@ const FALLBACK_BASH_CONFIG = {
|
|
|
19
19
|
whitelist: [],
|
|
20
20
|
blockDangerousFlags: false,
|
|
21
21
|
dangerousFlags: ["--force", "-rf", "--no-preserve-root"],
|
|
22
|
-
dangerousOperators: [">", ">>", "2>", "2>>", "|", "&&", "||", ";", "`"],
|
|
22
|
+
dangerousOperators: [">", ">>", "2>", "2>>", "|", "&&", "||", ";", "`", "$("],
|
|
23
23
|
logCommands: true,
|
|
24
24
|
};
|
|
25
25
|
export const DEFAULT_BASH_CONFIG = DEFAULT_SECURITY_CONFIG?.bash || FALLBACK_BASH_CONFIG;
|
|
@@ -54,8 +54,9 @@ function extractBaseCommand(trimmed) {
|
|
|
54
54
|
}
|
|
55
55
|
if (i >= tokens.length)
|
|
56
56
|
return tokens[0] ?? "";
|
|
57
|
-
// Extract basename from path (e.g. /usr/bin/rm → rm)
|
|
58
|
-
|
|
57
|
+
// Extract basename from path (e.g. /usr/bin/rm → rm). Strip surrounding
|
|
58
|
+
// quotes first — otherwise `'rm'` / `"rm"` evade the blacklist entirely.
|
|
59
|
+
const raw = tokens[i].replace(/^["']+|["']+$/g, "");
|
|
59
60
|
const parts = raw.split(/[\\/]/);
|
|
60
61
|
const basename = parts[parts.length - 1] || raw;
|
|
61
62
|
// Strip .exe/.cmd/.bat extensions for blacklist matching (e.g. powershell.exe → powershell)
|
|
@@ -116,8 +117,11 @@ export function isCommandAllowed(command, securityConfig) {
|
|
|
116
117
|
};
|
|
117
118
|
}
|
|
118
119
|
// Interpreters with -c/-e execute arbitrary code and bypass the blacklist.
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
// Quotes are stripped first (`'bash' -c`), and the name may follow a path
|
|
121
|
+
// separator (`/bin/bash -c`), so match those too.
|
|
122
|
+
const unquotedCommand = trimmedCommand.replace(/["']/g, "");
|
|
123
|
+
const INTERPRETER_FLAGS = /(?:^|[\\/\s])(?:bash|sh|zsh|dash|python|python3|node|bun|perl|ruby)\s+(?:-c|-e|--command|--eval)\b/;
|
|
124
|
+
if (INTERPRETER_FLAGS.test(unquotedCommand)) {
|
|
121
125
|
return { allowed: false, reason: "Interpreter -c/-e invocation is blocked" };
|
|
122
126
|
}
|
|
123
127
|
// Multi-line commands can hide blacklisted commands after a newline.
|
|
@@ -143,9 +147,13 @@ export function isCommandAllowed(command, securityConfig) {
|
|
|
143
147
|
}
|
|
144
148
|
// Extract actual base command (handles paths, env vars, sudo)
|
|
145
149
|
const baseCommand = extractBaseCommand(trimmedCommand);
|
|
150
|
+
// Case-insensitive matching: win32/macOS exec is case-insensitive, so
|
|
151
|
+
// `RM -rf x` must hit the blacklist just like `rm`.
|
|
152
|
+
const blacklistLower = new Set((config.blacklist || []).map((c) => c.toLowerCase()));
|
|
153
|
+
const whitelistLower = (config.whitelist || []).map((c) => c.toLowerCase());
|
|
146
154
|
// Check whitelist first (if non-empty, only whitelisted commands are allowed)
|
|
147
|
-
if (
|
|
148
|
-
if (!
|
|
155
|
+
if (whitelistLower.length > 0) {
|
|
156
|
+
if (!whitelistLower.includes(baseCommand.toLowerCase())) {
|
|
149
157
|
return {
|
|
150
158
|
allowed: false,
|
|
151
159
|
reason: `Command "${baseCommand}" is not in the whitelist`,
|
|
@@ -153,15 +161,18 @@ export function isCommandAllowed(command, securityConfig) {
|
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
// Check blacklist (against the extracted basename, not the raw token)
|
|
156
|
-
if (
|
|
164
|
+
if (blacklistLower.has(baseCommand.toLowerCase())) {
|
|
157
165
|
return {
|
|
158
166
|
allowed: false,
|
|
159
167
|
reason: `Command "${baseCommand}" is blacklisted`,
|
|
160
168
|
};
|
|
161
169
|
}
|
|
162
170
|
// Also check the raw first token in case it's a simple name
|
|
163
|
-
const rawFirst = trimmedCommand
|
|
164
|
-
|
|
171
|
+
const rawFirst = trimmedCommand
|
|
172
|
+
.split(/\s+/)[0]
|
|
173
|
+
.replace(/^["']+|["']+$/g, "")
|
|
174
|
+
.replace(/\.(exe|cmd|bat)$/i, "");
|
|
175
|
+
if (rawFirst !== baseCommand && blacklistLower.has(rawFirst.toLowerCase())) {
|
|
165
176
|
return {
|
|
166
177
|
allowed: false,
|
|
167
178
|
reason: `Command "${rawFirst}" is blacklisted`,
|
|
@@ -204,16 +215,10 @@ export function sanitizeCommandForLog(command) {
|
|
|
204
215
|
];
|
|
205
216
|
let sanitized = command;
|
|
206
217
|
for (const pattern of patterns) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
// For simple patterns, just replace with [REDACTED]
|
|
215
|
-
sanitized = sanitized.replace(pattern, "[REDACTED]");
|
|
216
|
-
}
|
|
218
|
+
// Whole-match replacement for every pattern: capture-group-aware
|
|
219
|
+
// replacement would re-emit the secret itself for patterns whose only
|
|
220
|
+
// group IS the value (e.g. "Bearer <token>", sk-...).
|
|
221
|
+
sanitized = sanitized.replace(pattern, "[REDACTED]");
|
|
217
222
|
}
|
|
218
223
|
return sanitized;
|
|
219
224
|
}
|
|
@@ -36,7 +36,7 @@ export function getOrCreateEncryptionKey(config) {
|
|
|
36
36
|
catch (e) {
|
|
37
37
|
// If we can't persist the key, encrypted data would be unrecoverable
|
|
38
38
|
// on the next run — fail loudly instead of silently degrading.
|
|
39
|
-
throw new Error(`Cannot write encryption key to ${keyPath}: ${
|
|
39
|
+
throw new Error(`Cannot write encryption key to ${keyPath}: ${errMsg(e)}`);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
try {
|
|
@@ -112,6 +112,7 @@ export function decryptString(encryptedText, key) {
|
|
|
112
112
|
return decrypted;
|
|
113
113
|
}
|
|
114
114
|
import { createHmac } from "node:crypto";
|
|
115
|
+
import { errMsg } from "../../utils";
|
|
115
116
|
/**
|
|
116
117
|
* Check if a string looks like it's encrypted
|
|
117
118
|
*/
|
|
@@ -121,17 +122,10 @@ export function isEncryptedString(value) {
|
|
|
121
122
|
const parts = value.split(":");
|
|
122
123
|
if (parts.length !== 4)
|
|
123
124
|
return false;
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
Buffer.from(parts[2], "base64");
|
|
129
|
-
Buffer.from(parts[3], "base64");
|
|
130
|
-
return true;
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
125
|
+
// Strict base64 shape check: Buffer.from(value, "base64") NEVER throws
|
|
126
|
+
// (lenient decode), so it cannot distinguish plaintext from base64.
|
|
127
|
+
const isB64 = (s) => s.length > 0 && s.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(s);
|
|
128
|
+
return parts.every(isB64);
|
|
135
129
|
}
|
|
136
130
|
/**
|
|
137
131
|
* Sensitive field names that should be encrypted
|
|
@@ -1,13 +1,84 @@
|
|
|
1
1
|
import { sanitizeLogMessage } from "./data-sanitizer";
|
|
2
|
+
/**
|
|
3
|
+
* Parse a numeric IP component honoring decimal, hex (0x…) and octal (0…)
|
|
4
|
+
* notations. Returns null when the token is not purely numeric.
|
|
5
|
+
*/
|
|
6
|
+
function parseIpNumber(s) {
|
|
7
|
+
if (/^0x[0-9a-f]+$/.test(s))
|
|
8
|
+
return parseInt(s.slice(2), 16);
|
|
9
|
+
if (/^0[0-7]+$/.test(s))
|
|
10
|
+
return parseInt(s.slice(1), 8);
|
|
11
|
+
if (/^\d+$/.test(s))
|
|
12
|
+
return parseInt(s, 10);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
/** Check whether four decoded octets form a private/reserved IPv4 address. */
|
|
16
|
+
function isPrivateIpv4(o) {
|
|
17
|
+
const [a, b = 0, c = 0, d = 0] = o;
|
|
18
|
+
return (a === 0 ||
|
|
19
|
+
a === 10 ||
|
|
20
|
+
a === 127 ||
|
|
21
|
+
(a === 169 && b === 254) ||
|
|
22
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
23
|
+
(a === 192 && b === 168));
|
|
24
|
+
}
|
|
2
25
|
/**
|
|
3
26
|
* Check if a hostname is a private/link-local address (SSRF protection).
|
|
27
|
+
*
|
|
28
|
+
* Handles more than just dotted quads: bare integer / hex / octal encodings
|
|
29
|
+
* (every HTTP client decodes them as a 32-bit IPv4), shorthand quads
|
|
30
|
+
* ("127.1" → 127.0.0.1) and IPv6-mapped IPv4 ("::ffff:7f00:1").
|
|
4
31
|
*/
|
|
5
32
|
export function isIpPrivate(hostname) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
(h.
|
|
33
|
+
let h = hostname.toLowerCase().trim().replace(/^\[|\]$/g, "");
|
|
34
|
+
if (h.includes(":")) {
|
|
35
|
+
// IPv6-mapped IPv4 (::ffff:127.0.0.1, ::ffff:7f00:1) — decode and fall
|
|
36
|
+
// through to the IPv4 checks below.
|
|
37
|
+
if (h.startsWith("::ffff:")) {
|
|
38
|
+
const mapped = h.slice(7);
|
|
39
|
+
if (!mapped.includes(":")) {
|
|
40
|
+
h = mapped; // dotted quad — fall through
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
// Hex-colon encoding of the low 32 bits, e.g. "7f00:1".
|
|
44
|
+
const segs = mapped.split(":");
|
|
45
|
+
const hi = parseInt(segs[0], 16);
|
|
46
|
+
const lo = parseInt(segs[1] ?? "", 16);
|
|
47
|
+
if (segs.length !== 2 || Number.isNaN(hi) || Number.isNaN(lo))
|
|
48
|
+
return false;
|
|
49
|
+
return isPrivateIpv4([(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
return h === "::1" || /^(fc|fd)/.test(h) || h.startsWith("fe80");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (h === "localhost")
|
|
57
|
+
return true;
|
|
58
|
+
// Bare integer/hex/octal: interpreted as one 32-bit IPv4 word.
|
|
59
|
+
if (!h.includes(".")) {
|
|
60
|
+
const n = parseIpNumber(h);
|
|
61
|
+
if (n === null)
|
|
62
|
+
return false; // a domain name, not an IP
|
|
63
|
+
if (n < 0 || n > 0xffffffff)
|
|
64
|
+
return false;
|
|
65
|
+
return isPrivateIpv4([(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]);
|
|
66
|
+
}
|
|
67
|
+
// Dotted form: decode each part (hex/octal aware); shorthand like
|
|
68
|
+
// "127.1" pads the missing groups before the last.
|
|
69
|
+
const parts = h.split(".");
|
|
70
|
+
const nums = [];
|
|
71
|
+
for (let i = 0; i < parts.length; i++) {
|
|
72
|
+
const n = parseIpNumber(parts[i]);
|
|
73
|
+
if (n === null || n > 255)
|
|
74
|
+
return false; // domain or malformed — not our concern
|
|
75
|
+
nums.push(n);
|
|
76
|
+
}
|
|
77
|
+
while (nums.length < 4)
|
|
78
|
+
nums.splice(nums.length - 1, 0, 0);
|
|
79
|
+
if (nums.length !== 4)
|
|
80
|
+
return false;
|
|
81
|
+
return isPrivateIpv4(nums);
|
|
11
82
|
}
|
|
12
83
|
/**
|
|
13
84
|
* Check if a URL is allowed based on security configuration
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import { resolve, normalize } from "path";
|
|
2
|
-
import {
|
|
2
|
+
import { realpathSync } from "fs";
|
|
3
|
+
import { isInsideDir, matchesScopeEntry, toForwardSlash } from "../../tools/path-utils";
|
|
4
|
+
/**
|
|
5
|
+
* Symlink containment: resolve() does NOT follow symlinks, so a symlink
|
|
6
|
+
* inside baseDir can point outside it. When the target exists on disk its
|
|
7
|
+
* real path must also stay inside the (real) base directory. Returns true if
|
|
8
|
+
* the check cannot be performed (target missing) — creation is validated by
|
|
9
|
+
* the resolved path itself.
|
|
10
|
+
*/
|
|
11
|
+
function escapesViaSymlink(baseResolved, targetResolved) {
|
|
12
|
+
try {
|
|
13
|
+
const realTarget = realpathSync(targetResolved);
|
|
14
|
+
let realBase = baseResolved;
|
|
15
|
+
try {
|
|
16
|
+
realBase = realpathSync(baseResolved);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Keep the resolved baseDir when it cannot be canonicalized.
|
|
20
|
+
}
|
|
21
|
+
return !isInsideDir(realTarget, realBase);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// ENOENT etc — target does not exist yet, nothing to follow.
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
3
28
|
/**
|
|
4
29
|
* Check if a path is within the allowed scope
|
|
5
30
|
* This combines global security settings with per-tool scope
|
|
@@ -15,6 +40,12 @@ export function isPathInScope(baseDir, targetPath, scope, securityConfig) {
|
|
|
15
40
|
reason: "Path is outside the base working directory",
|
|
16
41
|
};
|
|
17
42
|
}
|
|
43
|
+
if (escapesViaSymlink(baseResolved, targetResolved)) {
|
|
44
|
+
return {
|
|
45
|
+
allowed: false,
|
|
46
|
+
reason: "Path resolves outside the base working directory via symlink",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
18
49
|
// If security is disabled, skip pattern validation
|
|
19
50
|
if (!securityConfig?.enabled) {
|
|
20
51
|
// Still enforce subagent scope if present
|
|
@@ -148,50 +179,62 @@ export function isPathWritable(baseDir, targetPath, scope, securityConfig) {
|
|
|
148
179
|
function matchesGlobPattern(targetPath, baseDir, pattern) {
|
|
149
180
|
const resolvedBaseDir = resolve(baseDir);
|
|
150
181
|
const resolvedTargetPath = resolve(targetPath);
|
|
151
|
-
//
|
|
182
|
+
// Relative target path used for segment matching
|
|
183
|
+
const relPath = resolvedTargetPath.startsWith(resolvedBaseDir)
|
|
184
|
+
? resolvedTargetPath.slice(resolvedBaseDir.length).replace(/^\//, "")
|
|
185
|
+
: resolvedTargetPath;
|
|
186
|
+
// Handle patterns with ** (recursive directory matching)
|
|
187
|
+
if (pattern.includes("**")) {
|
|
188
|
+
// Split on ** to get prefix/suffix segments
|
|
189
|
+
const cleaned = pattern.replace(/\/$/, "");
|
|
190
|
+
const [rawPrefix, rawSuffix] = cleaned.split("**");
|
|
191
|
+
const prefix = rawPrefix.replace(/\/$/, "");
|
|
192
|
+
const suffix = rawSuffix.replace(/^\//, "");
|
|
193
|
+
// Check prefix: everything before ** must match the start of the path
|
|
194
|
+
if (prefix) {
|
|
195
|
+
const prefixParts = prefix.split("/").filter(Boolean);
|
|
196
|
+
const relParts = relPath.split("/");
|
|
197
|
+
for (let i = 0; i < prefixParts.length; i++) {
|
|
198
|
+
if (relParts[i] !== prefixParts[i])
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// ** at the end matches everything
|
|
203
|
+
if (!suffix)
|
|
204
|
+
return true;
|
|
205
|
+
// Check suffix against remaining path segments
|
|
206
|
+
const suffixParts = suffix.split("/").filter(Boolean);
|
|
207
|
+
const relParts = relPath.split("/");
|
|
208
|
+
// Search for suffix match anywhere in remaining path
|
|
209
|
+
for (let start = (prefix ? prefix.split("/").filter(Boolean).length : 0); start <= relParts.length - suffixParts.length; start++) {
|
|
210
|
+
let match = true;
|
|
211
|
+
for (let j = 0; j < suffixParts.length; j++) {
|
|
212
|
+
if (suffixParts[j] === "*")
|
|
213
|
+
continue;
|
|
214
|
+
if (relParts[start + j] !== suffixParts[j]) {
|
|
215
|
+
match = false;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (match)
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
// Handle directory patterns (ending with /) without **
|
|
152
225
|
if (pattern.endsWith("/")) {
|
|
153
226
|
const dirPattern = pattern.slice(0, -1);
|
|
154
227
|
const resolvedDirPattern = resolve(baseDir, dirPattern);
|
|
155
228
|
return isInsideDir(resolvedTargetPath, resolvedDirPattern);
|
|
156
229
|
}
|
|
157
|
-
// Handle
|
|
158
|
-
if (pattern.includes("**/")) {
|
|
159
|
-
const parts = pattern.split("**/");
|
|
160
|
-
const prefix = parts[0];
|
|
161
|
-
const suffix = parts[1];
|
|
162
|
-
const resolvedPrefix = resolve(baseDir, prefix);
|
|
163
|
-
if (!isInsideDir(resolvedTargetPath, resolvedPrefix)) {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
const remainingPath = resolvedTargetPath.slice(resolvedPrefix.length);
|
|
167
|
-
if (suffix === "*" || suffix === "") {
|
|
168
|
-
return true; // Any path after prefix
|
|
169
|
-
}
|
|
170
|
-
// Check if remaining path contains the suffix
|
|
171
|
-
return remainingPath.includes(suffix.replace(/\*/g, ""));
|
|
172
|
-
}
|
|
173
|
-
// Handle ** pattern (recursive any)
|
|
174
|
-
if (pattern.includes("**")) {
|
|
175
|
-
const parts = pattern.split("**");
|
|
176
|
-
const prefix = parts[0];
|
|
177
|
-
const suffix = parts[1];
|
|
178
|
-
const resolvedPrefix = resolve(baseDir, prefix);
|
|
179
|
-
if (!isInsideDir(resolvedTargetPath, resolvedPrefix)) {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
if (!suffix) {
|
|
183
|
-
return true; // ** at the end matches anything after prefix
|
|
184
|
-
}
|
|
185
|
-
return resolvedTargetPath.includes(suffix.replace(/\//g, ""));
|
|
186
|
-
}
|
|
187
|
-
// Handle * pattern (single level)
|
|
230
|
+
// Handle * pattern (single level) without **
|
|
188
231
|
if (pattern.includes("*")) {
|
|
189
232
|
const regexPattern = pattern
|
|
190
233
|
.replace(/\*/g, "[^/\\\\]*")
|
|
191
234
|
.replace(/\?/g, ".")
|
|
192
235
|
.replace(/\./g, "\\.");
|
|
193
236
|
const regex = new RegExp(`^${regexPattern}$`);
|
|
194
|
-
const relativePath = resolvedTargetPath.slice(resolvedBaseDir.length + 1)
|
|
237
|
+
const relativePath = toForwardSlash(resolvedTargetPath.slice(resolvedBaseDir.length + 1));
|
|
195
238
|
return regex.test(relativePath);
|
|
196
239
|
}
|
|
197
240
|
// Exact match (case-insensitive on win32, where the FS is case-insensitive)
|
|
@@ -72,6 +72,17 @@ export class RateLimiter {
|
|
|
72
72
|
this.cleanupOldRequests();
|
|
73
73
|
return Math.max(0, this.config.maxRequestsPerMinute - this.state.requests.length);
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Milliseconds until a request slot frees up (the oldest request ages out of
|
|
77
|
+
* the 1-minute window). Returns 0 when a request can be made immediately.
|
|
78
|
+
*/
|
|
79
|
+
msUntilRequest() {
|
|
80
|
+
this.cleanupOldRequests();
|
|
81
|
+
if (this.state.requests.length < this.config.maxRequestsPerMinute)
|
|
82
|
+
return 0;
|
|
83
|
+
const oldest = this.state.requests[0];
|
|
84
|
+
return Math.max(0, 60_000 - (Date.now() - oldest));
|
|
85
|
+
}
|
|
75
86
|
/**
|
|
76
87
|
* Clean up old requests (older than 1 minute)
|
|
77
88
|
*/
|
|
@@ -70,7 +70,7 @@ export const STRICT_POLICY = {
|
|
|
70
70
|
"-R",
|
|
71
71
|
"--no-clobber",
|
|
72
72
|
],
|
|
73
|
-
dangerousOperators: [">", ">>", "2>", "2>>", "|", "&&", "||", ";", "&", "`"],
|
|
73
|
+
dangerousOperators: [">", ">>", "2>", "2>>", "|", "&&", "||", ";", "&", "`", "$("],
|
|
74
74
|
logCommands: true,
|
|
75
75
|
},
|
|
76
76
|
paths: {
|
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { encryptString, decryptString, isEncryptedString, ConfigEncryptor, } from "./encryption";
|
|
5
|
+
import { defaultOutputBus } from "../../output";
|
|
5
6
|
/**
|
|
6
7
|
* Default session encryption configuration
|
|
7
8
|
*/
|
|
@@ -78,12 +79,22 @@ export class SessionFileEncryptor {
|
|
|
78
79
|
return lines.map((line) => this.encryptFileContent(line));
|
|
79
80
|
}
|
|
80
81
|
/**
|
|
81
|
-
* Decrypt a JSONL file
|
|
82
|
+
* Decrypt a JSONL file. A line that fails to decrypt (corrupted, truncated
|
|
83
|
+
* by a crash, or foreign plaintext) is logged and SKIPPED — one bad line
|
|
84
|
+
* must not make the whole session history unreadable.
|
|
82
85
|
*/
|
|
83
86
|
decryptJSONL(lines) {
|
|
84
87
|
if (!this.config.enabled)
|
|
85
88
|
return lines;
|
|
86
|
-
return lines.
|
|
89
|
+
return lines.flatMap((line) => {
|
|
90
|
+
try {
|
|
91
|
+
return [this.decryptFileContent(line)];
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
defaultOutputBus.log("error", "security", `[session-encryption] failed to decrypt JSONL line — skipping it: ${e instanceof Error ? e.message : String(e)}`, { persist: true });
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
});
|
|
87
98
|
}
|
|
88
99
|
/**
|
|
89
100
|
* Read and decrypt a session file
|
|
@@ -2,6 +2,7 @@ import { join, resolve } from "path";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { mkdirSync, existsSync } from "fs";
|
|
4
4
|
import { DEFAULT_SECURITY_CONFIG } from "../../config/security";
|
|
5
|
+
import { isInsideDir } from "../../tools/path-utils";
|
|
5
6
|
/**
|
|
6
7
|
* Default session isolation configuration
|
|
7
8
|
*/
|
|
@@ -83,13 +84,5 @@ export function isPathInSessionScope(sessionContext, path) {
|
|
|
83
84
|
const resolvedPath = resolve(path);
|
|
84
85
|
const workingDir = resolve(sessionContext.workingDir);
|
|
85
86
|
const tempDir = resolve(sessionContext.tempDir);
|
|
86
|
-
|
|
87
|
-
if (resolvedPath.startsWith(workingDir + "/") || resolvedPath.startsWith(workingDir + "\\")) {
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
// Check if path is within temp directory
|
|
91
|
-
if (resolvedPath.startsWith(tempDir + "/") || resolvedPath.startsWith(tempDir + "\\")) {
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
|
-
return false;
|
|
87
|
+
return isInsideDir(resolvedPath, workingDir) || isInsideDir(resolvedPath, tempDir);
|
|
95
88
|
}
|
|
@@ -101,6 +101,15 @@ export class SessionManager {
|
|
|
101
101
|
return;
|
|
102
102
|
this.store.appendSessionLog(this.activeId, entry);
|
|
103
103
|
}
|
|
104
|
+
/** Read the full session.jsonl of any session (used by `session show`). */
|
|
105
|
+
loadSessionLog(id) {
|
|
106
|
+
try {
|
|
107
|
+
return this.store.loadSessionLog(id);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
104
113
|
loadHistory() {
|
|
105
114
|
if (!this.activeId)
|
|
106
115
|
return [];
|
|
@@ -161,6 +170,8 @@ export class SessionManager {
|
|
|
161
170
|
}
|
|
162
171
|
enforceMaxSessions() {
|
|
163
172
|
const max = this.options.maxSessions ?? 50;
|
|
173
|
+
if (max <= 0)
|
|
174
|
+
return;
|
|
164
175
|
const sessions = this.store.listSessions();
|
|
165
176
|
if (sessions.length > max) {
|
|
166
177
|
const toDelete = sessions.slice(max);
|
|
@@ -7,13 +7,21 @@ export class SessionModule {
|
|
|
7
7
|
}
|
|
8
8
|
getSystemPromptBlock() {
|
|
9
9
|
const meta = this.manager.getActiveMeta();
|
|
10
|
-
if (!meta
|
|
10
|
+
if (!meta)
|
|
11
11
|
return null;
|
|
12
|
+
// Stable per session: id/name/model/contextWindow do not change between
|
|
13
|
+
// turns, so the system prompt is not mutated (KV-cache, rule #10). Live
|
|
14
|
+
// values (message count) are exposed through the session_info tool.
|
|
12
15
|
return {
|
|
13
|
-
content: t("session.
|
|
16
|
+
content: t("session.info_full", {
|
|
17
|
+
name: meta.name,
|
|
18
|
+
id: meta.id,
|
|
19
|
+
model: meta.model,
|
|
20
|
+
contextWindow: meta.contextWindow,
|
|
21
|
+
}),
|
|
14
22
|
priority: "low",
|
|
15
23
|
essential: false,
|
|
16
|
-
estimatedTokens:
|
|
24
|
+
estimatedTokens: 30,
|
|
17
25
|
};
|
|
18
26
|
}
|
|
19
27
|
getPlugin() {
|