micro-models-agent 0.51.1 → 0.52.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 (107) hide show
  1. package/dist/cli/commands.js +162 -38
  2. package/dist/cli/completer.js +5 -5
  3. package/dist/cli/main.js +42 -54
  4. package/dist/cli/repl-commands.js +138 -38
  5. package/dist/cli/repl.js +175 -89
  6. package/dist/cli/run-result.js +11 -0
  7. package/dist/cli/security-commands.js +6 -6
  8. package/dist/cli/setup.js +21 -15
  9. package/dist/config/config.js +54 -27
  10. package/dist/config/defaults.js +17 -0
  11. package/dist/config/domains.js +179 -0
  12. package/dist/config/index.js +2 -1
  13. package/dist/config/security.js +28 -8
  14. package/dist/core/agent.js +162 -30
  15. package/dist/core/bootstrap.js +94 -17
  16. package/dist/core/crash-handler.js +51 -0
  17. package/dist/core/environment.js +199 -0
  18. package/dist/core/session-logger.js +60 -6
  19. package/dist/core/version.js +2 -0
  20. package/dist/i18n/en.json +120 -39
  21. package/dist/i18n/ru.json +91 -10
  22. package/dist/llm/openai-compat.js +191 -53
  23. package/dist/llm/orchestrator.js +5 -3
  24. package/dist/logger/app-logger.js +50 -4
  25. package/dist/main.js +1288 -635
  26. package/dist/modules/browser/session.js +4 -0
  27. package/dist/modules/certification/cli.js +58 -19
  28. package/dist/modules/certification/loader.js +2 -1
  29. package/dist/modules/certification/manifest.js +22 -14
  30. package/dist/modules/certification/runner.js +91 -5
  31. package/dist/modules/certification/scenarios.js +290 -7
  32. package/dist/modules/context/fact-extractor.js +6 -0
  33. package/dist/modules/context/manager.js +19 -2
  34. package/dist/modules/execution/audit-runners.js +61 -7
  35. package/dist/modules/execution/execution-plugin.js +219 -60
  36. package/dist/modules/execution/module.js +207 -18
  37. package/dist/modules/execution/moe-executor.js +33 -20
  38. package/dist/modules/execution/plan-store.js +39 -0
  39. package/dist/modules/execution/plan-tool.js +188 -19
  40. package/dist/modules/execution/planner.js +27 -23
  41. package/dist/modules/execution/stuck-detector.js +244 -8
  42. package/dist/modules/execution/tracker.js +8 -6
  43. package/dist/modules/execution/verifier.js +15 -2
  44. package/dist/modules/hallucination/detector.js +4 -0
  45. package/dist/modules/hallucination/factual.js +45 -5
  46. package/dist/modules/indexer/module.js +1 -0
  47. package/dist/modules/lsp/client.js +123 -12
  48. package/dist/modules/lsp/index.js +1 -1
  49. package/dist/modules/lsp/module.js +30 -2
  50. package/dist/modules/lsp/probe.js +11 -1
  51. package/dist/modules/lsp/startup-check.js +5 -2
  52. package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
  53. package/dist/modules/plugins/manager.js +57 -13
  54. package/dist/modules/pricing/index.js +61 -0
  55. package/dist/modules/pricing/prices.js +129 -0
  56. package/dist/modules/providers/create.js +22 -0
  57. package/dist/modules/providers/fallback.js +79 -0
  58. package/dist/modules/providers/health.js +46 -0
  59. package/dist/modules/providers/index.js +5 -0
  60. package/dist/modules/providers/manager.js +161 -0
  61. package/dist/modules/providers/presets.js +128 -0
  62. package/dist/modules/providers/registry.js +22 -0
  63. package/dist/modules/providers/types.js +1 -0
  64. package/dist/modules/registry.js +1 -0
  65. package/dist/modules/security/command-validator.js +14 -0
  66. package/dist/modules/security/encryption.js +6 -6
  67. package/dist/modules/security/network-validator.js +17 -0
  68. package/dist/modules/security/path-validator.js +22 -26
  69. package/dist/modules/session/store.js +10 -10
  70. package/dist/tools/approve.js +1 -0
  71. package/dist/tools/attach-image.js +12 -0
  72. package/dist/tools/bash.js +27 -4
  73. package/dist/tools/browser.js +1 -0
  74. package/dist/tools/chunk-query.js +1 -0
  75. package/dist/tools/create-dir.js +1 -0
  76. package/dist/tools/delete-file.js +1 -0
  77. package/dist/tools/download-file.js +1 -0
  78. package/dist/tools/edit-file.js +2 -1
  79. package/dist/tools/enable-tools.js +1 -0
  80. package/dist/tools/executor.js +17 -7
  81. package/dist/tools/file-info.js +1 -0
  82. package/dist/tools/glob-tool.js +1 -0
  83. package/dist/tools/grep-tool.js +54 -13
  84. package/dist/tools/list-dir.js +1 -0
  85. package/dist/tools/load-skill.js +1 -0
  86. package/dist/tools/mcp-call.js +1 -0
  87. package/dist/tools/move-file.js +1 -0
  88. package/dist/tools/path-utils.js +51 -1
  89. package/dist/tools/pipeline-run.js +1 -0
  90. package/dist/tools/process-kill.js +11 -0
  91. package/dist/tools/process-list.js +1 -0
  92. package/dist/tools/process-log.js +9 -0
  93. package/dist/tools/question.js +1 -0
  94. package/dist/tools/read-file.js +94 -6
  95. package/dist/tools/recall.js +1 -0
  96. package/dist/tools/remember.js +1 -0
  97. package/dist/tools/scope-check.js +7 -5
  98. package/dist/tools/search-history.js +1 -0
  99. package/dist/tools/subagent.js +4 -4
  100. package/dist/tools/web-browse.js +1 -0
  101. package/dist/tools/web-fetch.js +27 -6
  102. package/dist/tools/web-search.js +70 -43
  103. package/dist/tools/write-file.js +1 -0
  104. package/dist/ui/line-editor.js +142 -23
  105. package/dist/ui/line-math.js +8 -4
  106. package/dist/ui/renderer.js +57 -7
  107. package/package.json +50 -48
@@ -0,0 +1,128 @@
1
+ // src/modules/providers/presets.ts
2
+ import { OpenAICompatProvider } from "../../llm/openai-compat";
3
+ /** Build an OpenAI-compatible provider (default for most backends). */
4
+ function openaiCompat(opts) {
5
+ const retry = (opts.retry ?? {
6
+ maxRetries: 3,
7
+ baseDelay: 1000,
8
+ maxDelay: 30000,
9
+ });
10
+ const rateLimits = opts.rateLimits;
11
+ return new OpenAICompatProvider({
12
+ model: opts.model,
13
+ baseUrl: opts.baseUrl,
14
+ apiKey: opts.apiKey,
15
+ contextWindow: opts.contextWindow,
16
+ retry,
17
+ rateLimits,
18
+ maxCompletionTokens: opts.maxCompletionTokens,
19
+ });
20
+ }
21
+ const OPENAI_COMPAT = {
22
+ type: "openai-compat",
23
+ label: "OpenAI-compatible (LM Studio, Ollama, vLLM, etc.)",
24
+ baseUrl: "",
25
+ capabilities: {
26
+ streaming: true,
27
+ tools: true,
28
+ vision: false,
29
+ reasoningEffort: false,
30
+ listModels: true,
31
+ requiresKey: false,
32
+ auth: "bearer",
33
+ },
34
+ create: openaiCompat,
35
+ };
36
+ const OPENROUTER = {
37
+ type: "openrouter",
38
+ label: "OpenRouter (openrouter.ai)",
39
+ baseUrl: "https://openrouter.ai/api/v1",
40
+ capabilities: {
41
+ streaming: true,
42
+ tools: true,
43
+ vision: true,
44
+ reasoningEffort: false,
45
+ listModels: true,
46
+ requiresKey: true,
47
+ auth: "bearer",
48
+ },
49
+ create: openaiCompat,
50
+ };
51
+ const OPENAI = {
52
+ type: "openai",
53
+ label: "OpenAI API",
54
+ baseUrl: "https://api.openai.com/v1",
55
+ capabilities: {
56
+ streaming: true,
57
+ tools: true,
58
+ vision: true,
59
+ reasoningEffort: true,
60
+ listModels: true,
61
+ requiresKey: true,
62
+ auth: "bearer",
63
+ },
64
+ create: openaiCompat,
65
+ };
66
+ const ANTHROPIC = {
67
+ type: "anthropic",
68
+ label: "Anthropic Claude",
69
+ baseUrl: "https://api.anthropic.com",
70
+ capabilities: {
71
+ streaming: false,
72
+ tools: false,
73
+ vision: true,
74
+ reasoningEffort: false,
75
+ listModels: false,
76
+ requiresKey: true,
77
+ auth: "header",
78
+ },
79
+ // The Anthropic messages API is NOT OpenAI-compatible. This preset is a
80
+ // placeholder until a real AnthropicProvider lands; the factory still uses
81
+ // the OpenAI-compatible code path today (broken), flagged via capabilities.
82
+ create: openaiCompat,
83
+ };
84
+ const OPENCODE_ZEN = {
85
+ type: "opencode-zen",
86
+ label: "OpenCode Zen (opencode.ai/zen)",
87
+ baseUrl: "https://opencode.ai/zen/v1",
88
+ capabilities: {
89
+ streaming: true,
90
+ tools: true,
91
+ vision: false,
92
+ reasoningEffort: false,
93
+ listModels: true,
94
+ requiresKey: false,
95
+ auth: "bearer",
96
+ },
97
+ create: openaiCompat,
98
+ };
99
+ const OPENCODE_GO = {
100
+ type: "opencode-go",
101
+ label: "OpenCode Go (opencode.ai/go)",
102
+ baseUrl: "https://opencode.ai/zen/go/v1",
103
+ capabilities: {
104
+ streaming: true,
105
+ tools: true,
106
+ vision: false,
107
+ reasoningEffort: false,
108
+ listModels: true,
109
+ requiresKey: false,
110
+ auth: "bearer",
111
+ },
112
+ create: openaiCompat,
113
+ };
114
+ /** All builtin provider presets. */
115
+ export const BUILTIN_PROVIDERS = [
116
+ OPENAI_COMPAT,
117
+ OPENROUTER,
118
+ OPENAI,
119
+ ANTHROPIC,
120
+ OPENCODE_ZEN,
121
+ OPENCODE_GO,
122
+ ];
123
+ /** Fixed base URLs for hosted providers (no port scan / model autodetect). */
124
+ export const HOSTED_BASE_URLS = {};
125
+ for (const spec of BUILTIN_PROVIDERS) {
126
+ if (spec.baseUrl)
127
+ HOSTED_BASE_URLS[spec.type] = spec.baseUrl;
128
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Single source of truth mapping provider `type` → factory + capabilities.
3
+ * Adding a provider = one `register()` call; no changes to the agent loop.
4
+ */
5
+ export class ProviderRegistry {
6
+ specs = new Map();
7
+ register(spec) {
8
+ this.specs.set(spec.type, spec);
9
+ }
10
+ get(type) {
11
+ return this.specs.get(type);
12
+ }
13
+ has(type) {
14
+ return this.specs.has(type);
15
+ }
16
+ list() {
17
+ return [...this.specs.values()];
18
+ }
19
+ types() {
20
+ return [...this.specs.keys()];
21
+ }
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -2,6 +2,7 @@ export class ModuleRegistry {
2
2
  modules = new Map();
3
3
  register(mod) {
4
4
  this.modules.set(mod.name, mod);
5
+ return this;
5
6
  }
6
7
  get(name) {
7
8
  return this.modules.get(name);
@@ -115,6 +115,20 @@ export function isCommandAllowed(command, securityConfig) {
115
115
  reason: `Shell wrapper "${baseForShellCheck}" is blocked — use the bash tool directly`,
116
116
  };
117
117
  }
118
+ // 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)) {
121
+ return { allowed: false, reason: "Interpreter -c/-e invocation is blocked" };
122
+ }
123
+ // Multi-line commands can hide blacklisted commands after a newline.
124
+ if (/\r?\n/.test(trimmedCommand)) {
125
+ return { allowed: false, reason: "Multi-line commands are blocked" };
126
+ }
127
+ // Chained commands bypass the base-command check (e.g. "echo hi; rm -rf /").
128
+ if (/[;&|]/.test(trimmedCommand) &&
129
+ /(^|[;&|\s])(rm|del|rd|dd|chmod|chown|wget|curl|nc|netcat|shutdown|reboot|mkfs|fdisk)\b/.test(trimmedCommand)) {
130
+ return { allowed: false, reason: "Command chaining to a dangerous command is blocked" };
131
+ }
118
132
  // Check dangerous operators FIRST — before parsing the base command.
119
133
  // e.g. "curl http://x | bash" must be caught even though curl isn't blacklisted.
120
134
  if (config.blockDangerousFlags) {
@@ -1,4 +1,4 @@
1
- import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
4
  import { readFileSync, writeFileSync, existsSync, copyFileSync } from "fs";
@@ -33,10 +33,10 @@ export function getOrCreateEncryptionKey(config) {
33
33
  writeFileSync(keyPath, key.toString("base64"), { mode: 0o600 });
34
34
  return key;
35
35
  }
36
- catch {
37
- // If we can't write the key file, return the key anyway
38
- // The caller can handle the error
39
- return key;
36
+ catch (e) {
37
+ // If we can't persist the key, encrypted data would be unrecoverable
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)}`);
40
40
  }
41
41
  }
42
42
  try {
@@ -102,7 +102,7 @@ export function decryptString(encryptedText, key) {
102
102
  hmac.update(iv);
103
103
  hmac.update(Buffer.from(encrypted, "base64"));
104
104
  const computedTag = hmac.digest();
105
- if (!computedTag.equals(authTag)) {
105
+ if (computedTag.length !== authTag.length || !timingSafeEqual(computedTag, authTag)) {
106
106
  throw new Error("Authentication failed: invalid tag");
107
107
  }
108
108
  // Decrypt with AES-256-CBC
@@ -1,4 +1,14 @@
1
1
  import { sanitizeLogMessage } from "./data-sanitizer";
2
+ /**
3
+ * Check if a hostname is a private/link-local address (SSRF protection).
4
+ */
5
+ 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")));
11
+ }
2
12
  /**
3
13
  * Check if a URL is allowed based on security configuration
4
14
  */
@@ -10,6 +20,13 @@ export function isUrlAllowed(url, securityConfig) {
10
20
  try {
11
21
  const urlObj = new URL(url);
12
22
  const domain = urlObj.hostname.toLowerCase();
23
+ const scheme = urlObj.protocol.toLowerCase();
24
+ if (scheme !== "http:" && scheme !== "https:") {
25
+ return { allowed: false, reason: `Scheme "${scheme}" is not allowed` };
26
+ }
27
+ if (isIpPrivate(domain)) {
28
+ return { allowed: false, reason: `Private address "${domain}" is blocked (SSRF)` };
29
+ }
13
30
  // Check denied domains first
14
31
  if (securityConfig.deniedDomains?.length) {
15
32
  for (const deniedDomain of securityConfig.deniedDomains) {
@@ -1,4 +1,5 @@
1
1
  import { resolve, normalize } from "path";
2
+ import { isInsideDir, matchesScopeEntry } from "../../tools/path-utils";
2
3
  /**
3
4
  * Check if a path is within the allowed scope
4
5
  * This combines global security settings with per-tool scope
@@ -6,8 +7,9 @@ import { resolve, normalize } from "path";
6
7
  export function isPathInScope(baseDir, targetPath, scope, securityConfig) {
7
8
  const baseResolved = resolve(baseDir);
8
9
  const targetResolved = resolve(baseDir, normalize(targetPath));
9
- // First, check that path is within baseDir
10
- if (!targetResolved.startsWith(baseResolved)) {
10
+ // First, check that path is within baseDir (separator- and case-safe: a
11
+ // sibling directory sharing the prefix must not pass).
12
+ if (!isInsideDir(targetResolved, baseResolved)) {
11
13
  return {
12
14
  allowed: false,
13
15
  reason: "Path is outside the base working directory",
@@ -17,8 +19,8 @@ export function isPathInScope(baseDir, targetPath, scope, securityConfig) {
17
19
  if (!securityConfig?.enabled) {
18
20
  // Still enforce subagent scope if present
19
21
  if (scope) {
20
- const isReadScope = scope.read_only_files.some((f) => targetResolved.startsWith(resolve(baseDir, f)));
21
- const isWriteScope = scope.allowed_files.some((f) => targetResolved.startsWith(resolve(baseDir, f)));
22
+ const isReadScope = scope.read_only_files.some((f) => matchesScopeEntry(targetResolved, resolve(baseDir, f)));
23
+ const isWriteScope = scope.allowed_files.some((f) => matchesScopeEntry(targetResolved, resolve(baseDir, f)));
22
24
  if (isWriteScope)
23
25
  return { allowed: true };
24
26
  if (isReadScope)
@@ -61,8 +63,8 @@ export function isPathInScope(baseDir, targetPath, scope, securityConfig) {
61
63
  if (!scope) {
62
64
  return { allowed: true };
63
65
  }
64
- const isRead = scope.read_only_files.some((f) => targetResolved.startsWith(resolve(baseDir, f)));
65
- const isWrite = scope.allowed_files.some((f) => targetResolved.startsWith(resolve(baseDir, f)));
66
+ const isRead = scope.read_only_files.some((f) => matchesScopeEntry(targetResolved, resolve(baseDir, f)));
67
+ const isWrite = scope.allowed_files.some((f) => matchesScopeEntry(targetResolved, resolve(baseDir, f)));
66
68
  if (isWrite)
67
69
  return { allowed: true };
68
70
  if (isRead)
@@ -78,8 +80,9 @@ export function isPathInScope(baseDir, targetPath, scope, securityConfig) {
78
80
  export function isPathWritable(baseDir, targetPath, scope, securityConfig) {
79
81
  const baseResolved = resolve(baseDir);
80
82
  const targetResolved = resolve(baseDir, normalize(targetPath));
81
- // First, check that path is within baseDir
82
- if (!targetResolved.startsWith(baseResolved)) {
83
+ // First, check that path is within baseDir (separator- and case-safe: a
84
+ // sibling directory sharing the prefix must not pass).
85
+ if (!isInsideDir(targetResolved, baseResolved)) {
83
86
  return {
84
87
  allowed: false,
85
88
  reason: "Path is outside the base working directory",
@@ -89,7 +92,7 @@ export function isPathWritable(baseDir, targetPath, scope, securityConfig) {
89
92
  if (!securityConfig?.enabled) {
90
93
  // Still enforce subagent scope if present
91
94
  if (scope) {
92
- const isWriteScope = scope.allowed_files.some((f) => targetResolved.startsWith(resolve(baseDir, f)));
95
+ const isWriteScope = scope.allowed_files.some((f) => matchesScopeEntry(targetResolved, resolve(baseDir, f)));
93
96
  if (isWriteScope)
94
97
  return { allowed: true };
95
98
  return {
@@ -130,7 +133,7 @@ export function isPathWritable(baseDir, targetPath, scope, securityConfig) {
130
133
  if (!scope) {
131
134
  return { allowed: true };
132
135
  }
133
- const isWrite = scope.allowed_files.some((f) => targetResolved.startsWith(resolve(baseDir, f)));
136
+ const isWrite = scope.allowed_files.some((f) => matchesScopeEntry(targetResolved, resolve(baseDir, f)));
134
137
  if (isWrite)
135
138
  return { allowed: true };
136
139
  return {
@@ -149,16 +152,7 @@ function matchesGlobPattern(targetPath, baseDir, pattern) {
149
152
  if (pattern.endsWith("/")) {
150
153
  const dirPattern = pattern.slice(0, -1);
151
154
  const resolvedDirPattern = resolve(baseDir, dirPattern);
152
- // Exact match
153
- if (resolvedTargetPath === resolvedDirPattern) {
154
- return true;
155
- }
156
- // Check if target is inside the directory
157
- if (resolvedTargetPath.startsWith(resolvedDirPattern + "/") ||
158
- resolvedTargetPath.startsWith(resolvedDirPattern + "\\")) {
159
- return true;
160
- }
161
- return false;
155
+ return isInsideDir(resolvedTargetPath, resolvedDirPattern);
162
156
  }
163
157
  // Handle **/* pattern (recursive)
164
158
  if (pattern.includes("**/")) {
@@ -166,8 +160,7 @@ function matchesGlobPattern(targetPath, baseDir, pattern) {
166
160
  const prefix = parts[0];
167
161
  const suffix = parts[1];
168
162
  const resolvedPrefix = resolve(baseDir, prefix);
169
- if (!resolvedTargetPath.startsWith(resolvedPrefix + "/") &&
170
- !resolvedTargetPath.startsWith(resolvedPrefix + "\\")) {
163
+ if (!isInsideDir(resolvedTargetPath, resolvedPrefix)) {
171
164
  return false;
172
165
  }
173
166
  const remainingPath = resolvedTargetPath.slice(resolvedPrefix.length);
@@ -183,8 +176,7 @@ function matchesGlobPattern(targetPath, baseDir, pattern) {
183
176
  const prefix = parts[0];
184
177
  const suffix = parts[1];
185
178
  const resolvedPrefix = resolve(baseDir, prefix);
186
- if (!resolvedTargetPath.startsWith(resolvedPrefix + "/") &&
187
- !resolvedTargetPath.startsWith(resolvedPrefix + "\\")) {
179
+ if (!isInsideDir(resolvedTargetPath, resolvedPrefix)) {
188
180
  return false;
189
181
  }
190
182
  if (!suffix) {
@@ -202,6 +194,10 @@ function matchesGlobPattern(targetPath, baseDir, pattern) {
202
194
  const relativePath = resolvedTargetPath.slice(resolvedBaseDir.length + 1).replace(/\\/g, "/");
203
195
  return regex.test(relativePath);
204
196
  }
205
- // Exact match
206
- return resolvedTargetPath === resolve(baseDir, pattern);
197
+ // Exact match (case-insensitive on win32, where the FS is case-insensitive)
198
+ const exactTarget = resolve(baseDir, pattern);
199
+ if (process.platform === "win32") {
200
+ return resolvedTargetPath.toLowerCase() === exactTarget.toLowerCase();
201
+ }
202
+ return resolvedTargetPath === exactTarget;
207
203
  }
@@ -34,7 +34,7 @@ export class SessionStore {
34
34
  return this.encryptor?.isEnabled() ?? false;
35
35
  }
36
36
  init() {
37
- mkdirSync(this.baseDir, { recursive: true });
37
+ mkdirSync(this.baseDir, { recursive: true, mode: 0o700 });
38
38
  }
39
39
  sessionDir(id) {
40
40
  return join(this.baseDir, id);
@@ -54,13 +54,13 @@ export class SessionStore {
54
54
  saveMeta(id, meta) {
55
55
  this._metaCache.set(id, meta);
56
56
  const dir = this.sessionDir(id);
57
- mkdirSync(dir, { recursive: true });
57
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
58
58
  const content = JSON.stringify(meta, null, 2);
59
59
  if (this.encryptor) {
60
- writeFileSync(this.metaPath(id), this.encryptor.encryptFileContent(content), "utf-8");
60
+ writeFileSync(this.metaPath(id), this.encryptor.encryptFileContent(content), { encoding: "utf-8", mode: 0o600 });
61
61
  }
62
62
  else {
63
- writeFileSync(this.metaPath(id), content, "utf-8");
63
+ writeFileSync(this.metaPath(id), content, { encoding: "utf-8", mode: 0o600 });
64
64
  }
65
65
  }
66
66
  loadMeta(id) {
@@ -83,13 +83,13 @@ export class SessionStore {
83
83
  }
84
84
  appendMessage(id, msg) {
85
85
  const dir = this.sessionDir(id);
86
- mkdirSync(dir, { recursive: true });
86
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
87
87
  const line = JSON.stringify(msg);
88
88
  if (this.encryptor?.isEnabled()) {
89
- appendFileSync(this.historyPath(id), this.encryptor.encryptFileContent(line) + "\n", "utf-8");
89
+ appendFileSync(this.historyPath(id), this.encryptor.encryptFileContent(line) + "\n", { encoding: "utf-8", mode: 0o600 });
90
90
  }
91
91
  else {
92
- appendFileSync(this.historyPath(id), line + "\n", "utf-8");
92
+ appendFileSync(this.historyPath(id), line + "\n", { encoding: "utf-8", mode: 0o600 });
93
93
  }
94
94
  const meta = this.loadMeta(id);
95
95
  if (meta) {
@@ -135,13 +135,13 @@ export class SessionStore {
135
135
  }
136
136
  appendSessionLog(id, entry) {
137
137
  const dir = this.sessionDir(id);
138
- mkdirSync(dir, { recursive: true });
138
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
139
139
  const line = JSON.stringify(entry);
140
140
  if (this.encryptor?.isEnabled()) {
141
- appendFileSync(this.sessionLogPath(id), this.encryptor.encryptFileContent(line) + "\n", "utf-8");
141
+ appendFileSync(this.sessionLogPath(id), this.encryptor.encryptFileContent(line) + "\n", { encoding: "utf-8", mode: 0o600 });
142
142
  }
143
143
  else {
144
- appendFileSync(this.sessionLogPath(id), line + "\n", "utf-8");
144
+ appendFileSync(this.sessionLogPath(id), line + "\n", { encoding: "utf-8", mode: 0o600 });
145
145
  }
146
146
  }
147
147
  loadSessionLog(id) {
@@ -2,6 +2,7 @@ import { t } from "../i18n/index";
2
2
  import { askChoice } from "./user-input";
3
3
  export const approveTool = {
4
4
  name: "approve",
5
+ icon: "🙋",
5
6
  description: "Request user approval for an action. The user picks Yes or No from a menu.",
6
7
  tags: ["core"],
7
8
  interactive: true,
@@ -5,6 +5,7 @@ import { loadFileAsDataUrl, loadUrlAsDataUrl, readClipboardImage, bufferToDataUr
5
5
  import { logSecurityBlock } from "../modules/security/audit-log";
6
6
  export const attachImageTool = {
7
7
  name: "attach_image",
8
+ icon: "đŸ–ŧī¸",
8
9
  description: "Attach an image to the conversation from a file path, URL, or clipboard. The image will be included in the next message sent to the model. Supports PNG, JPEG, GIF, WebP.",
9
10
  tags: ["vision", "image"],
10
11
  parameters: {
@@ -36,6 +37,17 @@ export const attachImageTool = {
36
37
  dataUrl = result.dataUrl;
37
38
  }
38
39
  else if (source.startsWith("http://") || source.startsWith("https://")) {
40
+ // Security: validate URL against the network policy (SSRF).
41
+ const { isUrlAllowed } = await import("../modules/security/network-validator");
42
+ const { getSessionSecurityConfig } = await import("../modules/security/session-isolation");
43
+ const networkConfig = ctx.sessionContext
44
+ ? getSessionSecurityConfig(ctx.config, ctx.sessionContext).network
45
+ : ctx.config?.security?.network;
46
+ const urlCheck = isUrlAllowed(source, networkConfig);
47
+ if (!urlCheck.allowed) {
48
+ logSecurityBlock(ctx.sessionId, "network_request", urlCheck.reason ?? "attach_image URL blocked", source);
49
+ return { success: false, output: `[SECURITY BLOCKED] ${urlCheck.reason}` };
50
+ }
39
51
  const result = await loadUrlAsDataUrl(source);
40
52
  dataUrl = result.dataUrl;
41
53
  }
@@ -298,6 +298,8 @@ export function emptyCliRunHint(command, output, code) {
298
298
  * the same failing `npx`/`bunx`/`npm run` command).
299
299
  */
300
300
  const NPM_EXEC_RE = /could not determine executable to run/i;
301
+ /** process-management tools hidden behind enable_tools (tag "shell"). */
302
+ const HIDDEN_PROCESS_TOOLS = ["process_list", "process_log", "process_kill"];
301
303
  export function npmExecHint(output) {
302
304
  if (NPM_EXEC_RE.test(output)) {
303
305
  return `${output}\n\nHint: ${t("exec.npm_exec_hint")}`;
@@ -306,6 +308,7 @@ export function npmExecHint(output) {
306
308
  }
307
309
  export const bashTool = {
308
310
  name: "bash",
311
+ icon: "đŸ’ģ",
309
312
  description: "Execute a shell command and return its output. Use for running tests, build, git, and shell operations. Commands that are still running after a few seconds are automatically moved to the background and return a process id — manage them with process_list, process_log, process_kill. Set background=true to return a process id immediately for commands you know are long-running (dev servers, watchers).\n\nWindows notes: the shell is cmd.exe — PowerShell cmdlets (Write-Host, Get-Content, Select-String, Out-File) and bash heredocs (cat << EOF) do NOT work there. Use the read_file/write_file tools instead of cat/echo redirection. Sequential commands: use && (a leading ; is auto-converted to &).",
310
313
  tags: ["shell", "code"],
311
314
  parameters: {
@@ -340,6 +343,19 @@ export const bashTool = {
340
343
  };
341
344
  }
342
345
  const command = adaptCommandForWindows(originalCommand);
346
+ // Hidden-tool discovery (ses_mt4fn58c): with tool-set narrowing the
347
+ // process_* tools are invisible to the model, so running them through
348
+ // cmd.exe only yields "is not recognized" — three wasted attempts in
349
+ // that session. Point the model at enable_tools. (k=v forms are handled
350
+ // by the redirect above and reach the real tool.)
351
+ const baseCmd = originalCommand.trim().split(/\s+/)[0];
352
+ if (HIDDEN_PROCESS_TOOLS.includes(baseCmd) &&
353
+ ctx.toolExecutor?.hasTool?.(baseCmd)) {
354
+ return {
355
+ success: false,
356
+ output: t("exec.hidden_tool_hint", { tool: baseCmd }),
357
+ };
358
+ }
343
359
  // echo/printf redirection to a file is unreliable in cmd.exe (single
344
360
  // quotes don't group, multi-line commands split, embedded double quotes
345
361
  // break the command). Steer the model to write_file instead — it
@@ -359,7 +375,10 @@ export const bashTool = {
359
375
  const fullSecurityConfig = ctx.sessionContext
360
376
  ? getSessionSecurityConfig(appConfig, ctx.sessionContext)
361
377
  : appConfig.security || DEFAULT_SECURITY_CONFIG;
362
- const securityConfig = fullSecurityConfig.bash || DEFAULT_SECURITY_CONFIG.bash;
378
+ // Master switch off → all sub-modules off, even when defaults are enabled.
379
+ const securityConfig = fullSecurityConfig.enabled === false
380
+ ? { ...(fullSecurityConfig.bash || DEFAULT_SECURITY_CONFIG.bash), enabled: false }
381
+ : fullSecurityConfig.bash || DEFAULT_SECURITY_CONFIG.bash;
363
382
  const validation = isCommandAllowed(command, securityConfig);
364
383
  if (!validation.allowed) {
365
384
  // Log security block
@@ -405,11 +424,15 @@ export const bashTool = {
405
424
  const testRun = detectTestResults(output);
406
425
  if (testRun && testRun.failed > 0) {
407
426
  output =
408
- `[test-runner] ${testRun.framework}: ${testRun.failed} test(s) FAILING, ${testRun.passed} passing — do NOT mark verification steps as done while tests fail. Investigate the failures, fix the code, then re-run the tests.\n\n` +
409
- output;
427
+ t("exec.test_runner_fail", {
428
+ framework: testRun.framework,
429
+ failed: String(testRun.failed),
430
+ passed: String(testRun.passed),
431
+ }) +
432
+ `\n\n${output}`;
410
433
  }
411
434
  else if (testRun && testRun.failed === 0 && testRun.passed > 0) {
412
- output = `[test-runner] ${testRun.framework}: all ${testRun.passed} test(s) passing.\n\n${output}`;
435
+ output = `${t("exec.test_runner_pass", { framework: testRun.framework, passed: String(testRun.passed) })}\n\n${output}`;
413
436
  }
414
437
  if (!output && code !== 0) {
415
438
  output = `(exit code ${code})`;
@@ -34,6 +34,7 @@ export function formatScreenshotResult(snapshot, screenshot, supportsVision) {
34
34
  export function createBrowserTool() {
35
35
  return {
36
36
  name: "browser",
37
+ icon: "🌐",
37
38
  tags: ["browser", "vision"],
38
39
  description: [
39
40
  "Control a headless browser. Navigate pages, click elements, type text, scroll, take screenshots.",
@@ -5,6 +5,7 @@ import { isPathInScope } from "./scope-check";
5
5
  import { runChunkQuery } from "../modules/context/chunk-query";
6
6
  export const chunkQueryTool = {
7
7
  name: "chunk_query",
8
+ icon: "🧩",
8
9
  description: "Answer a query over a large text (file or inline) by splitting it into chunks and querying the model per chunk in parallel, then optionally synthesizing a final answer. Use when the input is too large for the context window.",
9
10
  tags: ["research", "code"],
10
11
  /** Multiple LLM calls (parallel chunk queries + synthesis) need a longer budget. */
@@ -6,6 +6,7 @@ import { getSessionSecurityConfig } from "../modules/security/session-isolation"
6
6
  import { safeResolvePath } from "./path-utils";
7
7
  export const createDirTool = {
8
8
  name: "create_dir",
9
+ icon: "📁",
9
10
  description: "Create a directory (and any intermediate directories).",
10
11
  tags: ["file"],
11
12
  parameters: {
@@ -7,6 +7,7 @@ import { generateDeleteDiff } from "../ui/diff";
7
7
  import { safeResolvePath } from "./path-utils";
8
8
  export const deleteFileTool = {
9
9
  name: "delete_file",
10
+ icon: "đŸ—‘ī¸",
10
11
  description: "Delete a file from the filesystem.",
11
12
  tags: ["file"],
12
13
  parameters: {
@@ -12,6 +12,7 @@ import { safeResolvePath } from "./path-utils";
12
12
  export const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
13
13
  export const downloadFileTool = {
14
14
  name: "download_file",
15
+ icon: "âŦ‡ī¸",
15
16
  description: "Download a file (image, archive, binary, font, etc.) from a URL and save it to disk. Returns only the path, size and content type — read the saved file with read_file/attach_image afterwards. Use this for binary files; web_fetch/web_browse return text only and cannot save bytes.",
16
17
  tags: ["file", "research"],
17
18
  parameters: {
@@ -8,6 +8,7 @@ import { generateDiff } from "../ui/diff";
8
8
  import { safeResolvePath } from "./path-utils";
9
9
  export const editFileTool = {
10
10
  name: "edit_file",
11
+ icon: "âœī¸",
11
12
  description: "Find and replace text in an existing file. Uses exact string match (not regex).",
12
13
  tags: ["file", "code"],
13
14
  parameters: {
@@ -53,7 +54,7 @@ export const editFileTool = {
53
54
  if (!content.includes(oldStr)) {
54
55
  return {
55
56
  success: false,
56
- output: t("file.string_not_found", { str: oldStr }),
57
+ output: t("file.string_not_found", { str: oldStr, path }),
57
58
  };
58
59
  }
59
60
  const updated = content.replace(oldStr, newStr);
@@ -9,6 +9,7 @@ import { t } from "../i18n/index";
9
9
  */
10
10
  export const enableToolsTool = {
11
11
  name: "enable_tools",
12
+ icon: "🔌",
12
13
  description: "Enable hidden tool categories for the current session. Only the essential tools (file/code/shell) are active by default. Call this with the tags you need and they become available from the next turn. Available tags: file, code, shell, research, browser, vision, image, memory, core. Returns the newly available tools. To run a task in a separate context instead, use subagent with tool_tags.",
13
14
  alwaysOn: true,
14
15
  parameters: {
@@ -114,13 +114,23 @@ export class ToolExecutor {
114
114
  toolCallId: call.id,
115
115
  };
116
116
  }
117
- for (const plugin of this.pluginManager.getAllPlugins()) {
118
- if (plugin.onAfterTool) {
119
- try {
120
- await plugin.onAfterTool(this.ctx, call, result);
121
- }
122
- catch (e) {
123
- this.ctx.logger.warn(`Plugin onAfterTool error: ${e.message}`);
117
+ // After an abort the tool's work is discarded (the agent loop breaks
118
+ // without rendering or logging it), so skip the slow post-tool plugins
119
+ // (LSP checks, lint, tsc) entirely. When the abort lands mid-plugin the
120
+ // signal in the hook context lets the plugin kill its child processes.
121
+ if (signal?.aborted) {
122
+ this.ctx.logger.debug(`Tool ${call.name}: abort, skipping onAfterTool plugins`);
123
+ }
124
+ else {
125
+ const pluginCtx = signal ? { ...this.ctx, signal } : this.ctx;
126
+ for (const plugin of this.pluginManager.getAllPlugins()) {
127
+ if (plugin.onAfterTool) {
128
+ try {
129
+ await plugin.onAfterTool(pluginCtx, call, result);
130
+ }
131
+ catch (e) {
132
+ this.ctx.logger.warn(`Plugin onAfterTool error: ${e.message}`);
133
+ }
124
134
  }
125
135
  }
126
136
  }
@@ -4,6 +4,7 @@ import { isPathInScope } from "../modules/security/path-validator";
4
4
  import { safeResolvePath } from "./path-utils";
5
5
  export const fileInfoTool = {
6
6
  name: "file_info",
7
+ icon: "â„šī¸",
7
8
  description: "Get metadata about a file or directory (size, creation date, modification date).",
8
9
  tags: ["file"],
9
10
  boundedOutput: true,
@@ -3,6 +3,7 @@ import { t } from "../i18n/index";
3
3
  import { MAX_PREVIEW_LINES } from "./preview";
4
4
  export const globTool = {
5
5
  name: "glob",
6
+ icon: "🔎",
6
7
  description: `Search for files matching a glob pattern. Shows up to ${MAX_PREVIEW_LINES} results by default. Uses standard glob syntax (e.g., **/*.ts, src/**/*.test.ts).`,
7
8
  tags: ["file", "code", "research"],
8
9
  parameters: {