micro-models-agent 0.63.3 → 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.
Files changed (185) hide show
  1. package/CHANGELOG.md +148 -1
  2. package/dist/cli/cache-line.js +30 -0
  3. package/dist/cli/command-suggest.js +38 -0
  4. package/dist/cli/commands.js +285 -60
  5. package/dist/cli/completer.js +16 -16
  6. package/dist/cli/json-payload.js +32 -0
  7. package/dist/cli/main.js +165 -77
  8. package/dist/cli/plugin-commands.js +5 -4
  9. package/dist/cli/relaunch.js +37 -0
  10. package/dist/cli/repl-commands.js +441 -307
  11. package/dist/cli/repl.js +360 -83
  12. package/dist/cli/run-result.js +12 -6
  13. package/dist/cli/security-commands.js +64 -60
  14. package/dist/cli/setup-order.js +57 -0
  15. package/dist/cli/setup-prompt.js +49 -0
  16. package/dist/cli/setup.js +52 -48
  17. package/dist/config/budget.js +48 -0
  18. package/dist/config/config.js +132 -70
  19. package/dist/config/defaults.js +37 -11
  20. package/dist/config/domains.js +9 -50
  21. package/dist/config/utils.js +56 -0
  22. package/dist/core/agent/audit-gate.js +49 -0
  23. package/dist/core/agent/compaction.js +89 -0
  24. package/dist/core/agent/constants.js +61 -0
  25. package/dist/core/agent/context-renderer.js +40 -0
  26. package/dist/core/agent/hallucination-gate.js +87 -0
  27. package/dist/core/agent/loop-state.js +53 -0
  28. package/dist/core/agent/prefix-monitor.js +101 -0
  29. package/dist/core/agent/reasoning-resolver.js +56 -0
  30. package/dist/core/agent/token-tracker.js +96 -0
  31. package/dist/core/agent/tool-batch.js +237 -0
  32. package/dist/core/agent/tool-output.js +62 -0
  33. package/dist/core/agent-moe.js +214 -69
  34. package/dist/core/agent.js +506 -546
  35. package/dist/core/bootstrap.js +297 -98
  36. package/dist/core/crash-handler.js +2 -1
  37. package/dist/core/prompt-builder.js +3 -0
  38. package/dist/core/prompt-overflow.js +307 -0
  39. package/dist/core/session-logger.js +34 -2
  40. package/dist/i18n/en.json +7 -4
  41. package/dist/i18n/ru.json +7 -4
  42. package/dist/index.js +5 -1
  43. package/dist/llm/cache-usage.js +76 -0
  44. package/dist/llm/image-utils.js +20 -16
  45. package/dist/llm/llm-errors.js +41 -0
  46. package/dist/llm/model-loader.js +30 -0
  47. package/dist/llm/openai-compat.js +287 -101
  48. package/dist/llm/orchestrator.js +140 -68
  49. package/dist/llm/provider-budget.js +68 -0
  50. package/dist/llm/provider.js +0 -1
  51. package/dist/llm/stream-state.js +26 -0
  52. package/dist/llm/token-counter.js +28 -0
  53. package/dist/logger/app-logger.js +12 -15
  54. package/dist/main.js +1606 -800
  55. package/dist/migration/detect.js +3 -1
  56. package/dist/modules/browser/actions.js +0 -3
  57. package/dist/modules/browser/bridge-client.js +2 -0
  58. package/dist/modules/browser/driver.js +46 -4
  59. package/dist/modules/certification/cli.js +85 -42
  60. package/dist/modules/certification/loader.js +15 -1
  61. package/dist/modules/certification/manifest.js +126 -15
  62. package/dist/modules/certification/runner.js +4 -26
  63. package/dist/modules/certification/scenarios.js +184 -5
  64. package/dist/modules/certification/syntax-scenarios.js +51 -0
  65. package/dist/modules/context/chunk-query.js +25 -5
  66. package/dist/modules/context/fact-extractor.js +6 -2
  67. package/dist/modules/context/manager.js +23 -7
  68. package/dist/modules/execution/audit-runners.js +7 -1
  69. package/dist/modules/execution/auditor.js +3 -3
  70. package/dist/modules/execution/execution-plugin.js +22 -15
  71. package/dist/modules/execution/input-from.js +46 -0
  72. package/dist/modules/execution/module.js +107 -18
  73. package/dist/modules/execution/moe-executor.js +166 -54
  74. package/dist/modules/execution/plan-actions.js +524 -0
  75. package/dist/modules/execution/plan-steps.js +23 -0
  76. package/dist/modules/execution/plan-store.js +15 -3
  77. package/dist/modules/execution/plan-tool.js +6 -488
  78. package/dist/modules/execution/plan-validator.js +24 -0
  79. package/dist/modules/execution/stuck-detector.js +3 -18
  80. package/dist/modules/execution/tracker.js +14 -5
  81. package/dist/modules/execution/transient-error.js +30 -0
  82. package/dist/modules/execution/verifier.js +94 -7
  83. package/dist/modules/execution/windows-commands.js +11 -0
  84. package/dist/modules/hallucination/confidence.js +36 -23
  85. package/dist/modules/hallucination/consistency.js +3 -0
  86. package/dist/modules/hallucination/detector.js +8 -3
  87. package/dist/modules/hallucination/factual.js +26 -7
  88. package/dist/modules/hallucination/llm-judge.js +12 -2
  89. package/dist/modules/indexer/map-command.js +35 -0
  90. package/dist/modules/indexer/map-select.js +87 -0
  91. package/dist/modules/indexer/module.js +34 -22
  92. package/dist/modules/indexer/symbols.js +189 -0
  93. package/dist/modules/indexer/walker.js +96 -42
  94. package/dist/modules/lsp/check-tool.js +2 -1
  95. package/dist/modules/lsp/client.js +49 -32
  96. package/dist/modules/lsp/config.js +55 -2
  97. package/dist/modules/lsp/module.js +38 -5
  98. package/dist/modules/lsp/probe.js +4 -3
  99. package/dist/modules/lsp/project-root.js +41 -1
  100. package/dist/modules/lsp/startup-check.js +12 -4
  101. package/dist/modules/mcp/client.js +153 -104
  102. package/dist/modules/mcp/module.js +165 -41
  103. package/dist/modules/memory/module.js +4 -3
  104. package/dist/modules/plugins/builtin/lint-on-write.js +36 -6
  105. package/dist/modules/plugins/manager.js +47 -84
  106. package/dist/modules/pricing/index.js +17 -7
  107. package/dist/modules/pricing/prices.js +30 -12
  108. package/dist/modules/processes/index.js +1 -0
  109. package/dist/modules/processes/kill-tree.js +56 -0
  110. package/dist/modules/processes/registry.js +2 -54
  111. package/dist/modules/providers/cache.js +23 -0
  112. package/dist/modules/providers/factory.js +28 -0
  113. package/dist/modules/providers/fallback.js +7 -5
  114. package/dist/modules/providers/health.js +2 -1
  115. package/dist/modules/providers/index.js +1 -0
  116. package/dist/modules/providers/manager.js +17 -2
  117. package/dist/modules/providers/presets.js +79 -6
  118. package/dist/modules/reasoning/policy.js +40 -0
  119. package/dist/modules/reasoning/probe.js +111 -0
  120. package/dist/modules/security/audit-notifier.js +42 -27
  121. package/dist/modules/security/command-validator.js +25 -20
  122. package/dist/modules/security/encryption.js +6 -12
  123. package/dist/modules/security/network-validator.js +76 -5
  124. package/dist/modules/security/path-validator.js +77 -34
  125. package/dist/modules/security/rate-limiter.js +11 -0
  126. package/dist/modules/security/security-policies.js +1 -1
  127. package/dist/modules/security/session-encryption.js +13 -2
  128. package/dist/modules/security/session-isolation.js +2 -9
  129. package/dist/modules/session/manager.js +11 -0
  130. package/dist/modules/session/module.js +11 -3
  131. package/dist/modules/session/store.js +41 -5
  132. package/dist/modules/skills/loader.js +7 -1
  133. package/dist/modules/skills/module.js +2 -1
  134. package/dist/modules/updater/changelog-reader.js +94 -0
  135. package/dist/modules/updater/dev-detect.js +17 -0
  136. package/dist/modules/updater/index.js +1 -0
  137. package/dist/modules/updater/module.js +14 -3
  138. package/dist/output/bus.js +32 -0
  139. package/dist/output/channel.js +233 -0
  140. package/dist/output/format.js +14 -0
  141. package/dist/output/index.js +7 -0
  142. package/dist/output/json-sink.js +22 -0
  143. package/dist/output/machine.js +8 -0
  144. package/dist/output/session-sink.js +27 -0
  145. package/dist/output/types.js +1 -0
  146. package/dist/tools/approve.js +6 -2
  147. package/dist/tools/attach-image.js +11 -11
  148. package/dist/tools/auto-fixer.js +198 -0
  149. package/dist/tools/bash.js +142 -89
  150. package/dist/tools/chunk-query.js +10 -6
  151. package/dist/tools/download-file.js +1 -1
  152. package/dist/tools/edit-file.js +20 -2
  153. package/dist/tools/executor.js +54 -9
  154. package/dist/tools/glob-tool.js +7 -0
  155. package/dist/tools/grep-tool.js +15 -1
  156. package/dist/tools/index.js +3 -1
  157. package/dist/tools/list-dir.js +3 -1
  158. package/dist/tools/load-skill.js +2 -1
  159. package/dist/tools/mcp-call.js +1 -1
  160. package/dist/tools/move-file.js +5 -4
  161. package/dist/tools/path-utils.js +7 -0
  162. package/dist/tools/pipeline-run.js +1 -1
  163. package/dist/tools/prompt-io.js +28 -0
  164. package/dist/tools/question.js +12 -12
  165. package/dist/tools/scope-request.js +91 -0
  166. package/dist/tools/session-info.js +44 -0
  167. package/dist/tools/set-thinking.js +71 -0
  168. package/dist/tools/subagent.js +50 -9
  169. package/dist/tools/syntax-validator.js +177 -0
  170. package/dist/tools/user-input.js +16 -9
  171. package/dist/tools/write-file.js +17 -1
  172. package/dist/ui/diff.js +10 -0
  173. package/dist/ui/line-editor.js +179 -26
  174. package/dist/ui/line-math.js +20 -3
  175. package/dist/ui/md-formatter.js +100 -10
  176. package/dist/ui/output.js +5 -4
  177. package/dist/ui/plan-view.js +2 -7
  178. package/dist/ui/renderer.js +89 -85
  179. package/dist/ui/spinner.js +14 -4
  180. package/dist/utils/error.js +4 -0
  181. package/dist/utils/index.js +4 -0
  182. package/dist/utils/retry.js +17 -0
  183. package/dist/utils/sleep.js +23 -0
  184. package/dist/utils/truncate.js +9 -0
  185. 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
- await this.sendToWebhook(notification);
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
- console.error("[AuditNotifier] Failed to write to file:", error);
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
- catch (error) {
187
- console.error("[AuditNotifier] Webhook failed, adding to retry queue:", error);
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
- while (this.retryQueue.length > 0) {
208
- const item = this.retryQueue[0];
209
- if (item.retries >= (this.config.maxRetries ?? 3)) {
210
- console.error("[AuditNotifier] Max retries exceeded for notification:", item.entry);
211
- this.retryQueue.shift();
212
- continue;
213
- }
214
- try {
215
- await this.sendToWebhook(item.entry);
216
- this.retryQueue.shift();
217
- }
218
- catch (error) {
219
- item.retries++;
220
- // Exponential backoff
221
- const delay = Math.pow(2, item.retries) * 1000;
222
- await new Promise((resolve) => setTimeout(resolve, delay));
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
- this.isProcessing = false;
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
- const raw = tokens[i];
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
- const INTERPRETER_FLAGS = /(?:^|\s)(?:bash|sh|zsh|dash|python|python3|node|bun|perl|ruby)\s+(?:-c|-e|--command|--eval)\b/;
120
- if (INTERPRETER_FLAGS.test(trimmedCommand)) {
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 (config.whitelist.length > 0) {
148
- if (!config.whitelist.includes(baseCommand)) {
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 (config.blacklist.includes(baseCommand)) {
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.split(/\s+/)[0].replace(/\.(exe|cmd|bat)$/i, "");
164
- if (rawFirst !== baseCommand && config.blacklist.includes(rawFirst)) {
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
- if (pattern.toString().includes("($1")) {
208
- // For patterns with capture groups, use the group in replacement
209
- sanitized = sanitized.replace(pattern, (match, p1) => {
210
- return `${p1}=[REDACTED]`;
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}: ${e instanceof Error ? e.message : String(e)}`);
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
- // Check if all parts are valid base64
125
- try {
126
- Buffer.from(parts[0], "base64");
127
- Buffer.from(parts[1], "base64");
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
- const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
7
- return (/^(localhost|127\.0\.0\.1|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h) ||
8
- h === "::1" ||
9
- /^(fc|fd)/.test(h) ||
10
- (h.includes(":") && h.startsWith("fe80")));
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 { isInsideDir, matchesScopeEntry } from "../../tools/path-utils";
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
- // Handle directory patterns (ending with /)
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 **/* pattern (recursive)
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).replace(/\\/g, "/");
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.map((line) => this.decryptFileContent(line));
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
- // Check if path is within working directory
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 || meta.messageCount === 0)
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.info", { name: meta.name, id: meta.id, count: meta.messageCount }),
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: 20,
24
+ estimatedTokens: 30,
17
25
  };
18
26
  }
19
27
  getPlugin() {