musonda 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IMPLEMENTATION.md +432 -0
- package/README.md +119 -0
- package/dist/agent/harness.d.ts +25 -0
- package/dist/agent/harness.js +215 -0
- package/dist/agent/harness.js.map +1 -0
- package/dist/agent/openaiResponsesClient.d.ts +35 -0
- package/dist/agent/openaiResponsesClient.js +250 -0
- package/dist/agent/openaiResponsesClient.js.map +1 -0
- package/dist/agent/systemPrompt.d.ts +9 -0
- package/dist/agent/systemPrompt.js +100 -0
- package/dist/agent/systemPrompt.js.map +1 -0
- package/dist/agent/types.d.ts +53 -0
- package/dist/agent/types.js +2 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/cli/commands/chat.d.ts +6 -0
- package/dist/cli/commands/chat.js +8 -0
- package/dist/cli/commands/chat.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +4 -0
- package/dist/cli/commands/doctor.js +336 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/gateway.d.ts +20 -0
- package/dist/cli/commands/gateway.js +135 -0
- package/dist/cli/commands/gateway.js.map +1 -0
- package/dist/cli/commands/help.d.ts +4 -0
- package/dist/cli/commands/help.js +159 -0
- package/dist/cli/commands/help.js.map +1 -0
- package/dist/cli/commands/plugins.d.ts +18 -0
- package/dist/cli/commands/plugins.js +250 -0
- package/dist/cli/commands/plugins.js.map +1 -0
- package/dist/cli/commands/service.d.ts +16 -0
- package/dist/cli/commands/service.js +136 -0
- package/dist/cli/commands/service.js.map +1 -0
- package/dist/cli/commands/setup.d.ts +4 -0
- package/dist/cli/commands/setup.js +8 -0
- package/dist/cli/commands/setup.js.map +1 -0
- package/dist/cli/commands/status.d.ts +4 -0
- package/dist/cli/commands/status.js +73 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/update.d.ts +22 -0
- package/dist/cli/commands/update.js +226 -0
- package/dist/cli/commands/update.js.map +1 -0
- package/dist/cli/daemon.d.ts +33 -0
- package/dist/cli/daemon.js +208 -0
- package/dist/cli/daemon.js.map +1 -0
- package/dist/cli/interactiveMenu.d.ts +5 -0
- package/dist/cli/interactiveMenu.js +225 -0
- package/dist/cli/interactiveMenu.js.map +1 -0
- package/dist/cli/musondaCli.d.ts +30 -0
- package/dist/cli/musondaCli.js +269 -0
- package/dist/cli/musondaCli.js.map +1 -0
- package/dist/cli/ui.d.ts +49 -0
- package/dist/cli/ui.js +90 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +315 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/loader.d.ts +12 -0
- package/dist/config/loader.js +217 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.js +18 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/types.d.ts +246 -0
- package/dist/config/types.js +155 -0
- package/dist/config/types.js.map +1 -0
- package/dist/gateway/channels/cli.d.ts +37 -0
- package/dist/gateway/channels/cli.js +298 -0
- package/dist/gateway/channels/cli.js.map +1 -0
- package/dist/gateway/channels/discord.d.ts +18 -0
- package/dist/gateway/channels/discord.js +419 -0
- package/dist/gateway/channels/discord.js.map +1 -0
- package/dist/gateway/channels/slack.d.ts +26 -0
- package/dist/gateway/channels/slack.js +470 -0
- package/dist/gateway/channels/slack.js.map +1 -0
- package/dist/gateway/channels/webhook.d.ts +18 -0
- package/dist/gateway/channels/webhook.js +49 -0
- package/dist/gateway/channels/webhook.js.map +1 -0
- package/dist/gateway/http.d.ts +33 -0
- package/dist/gateway/http.js +605 -0
- package/dist/gateway/http.js.map +1 -0
- package/dist/gateway/server.d.ts +38 -0
- package/dist/gateway/server.js +233 -0
- package/dist/gateway/server.js.map +1 -0
- package/dist/gateway/types.d.ts +28 -0
- package/dist/gateway/types.js +2 -0
- package/dist/gateway/types.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/memory/memoryManager.d.ts +22 -0
- package/dist/memory/memoryManager.js +200 -0
- package/dist/memory/memoryManager.js.map +1 -0
- package/dist/memory/storage.d.ts +24 -0
- package/dist/memory/storage.js +187 -0
- package/dist/memory/storage.js.map +1 -0
- package/dist/memory/types.d.ts +43 -0
- package/dist/memory/types.js +2 -0
- package/dist/memory/types.js.map +1 -0
- package/dist/plugins/manifest.d.ts +23 -0
- package/dist/plugins/manifest.js +185 -0
- package/dist/plugins/manifest.js.map +1 -0
- package/dist/plugins/mcp/mcpBridge.d.ts +31 -0
- package/dist/plugins/mcp/mcpBridge.js +115 -0
- package/dist/plugins/mcp/mcpBridge.js.map +1 -0
- package/dist/plugins/mcp/mcpClient.d.ts +43 -0
- package/dist/plugins/mcp/mcpClient.js +309 -0
- package/dist/plugins/mcp/mcpClient.js.map +1 -0
- package/dist/plugins/mcp/mcpConfig.d.ts +34 -0
- package/dist/plugins/mcp/mcpConfig.js +326 -0
- package/dist/plugins/mcp/mcpConfig.js.map +1 -0
- package/dist/plugins/mcp/types.d.ts +48 -0
- package/dist/plugins/mcp/types.js +2 -0
- package/dist/plugins/mcp/types.js.map +1 -0
- package/dist/plugins/pluginManager.d.ts +164 -0
- package/dist/plugins/pluginManager.js +1016 -0
- package/dist/plugins/pluginManager.js.map +1 -0
- package/dist/plugins/skills/frontmatterParser.d.ts +28 -0
- package/dist/plugins/skills/frontmatterParser.js +189 -0
- package/dist/plugins/skills/frontmatterParser.js.map +1 -0
- package/dist/plugins/skills/skillDiscovery.d.ts +15 -0
- package/dist/plugins/skills/skillDiscovery.js +148 -0
- package/dist/plugins/skills/skillDiscovery.js.map +1 -0
- package/dist/plugins/skills/skillTools.d.ts +8 -0
- package/dist/plugins/skills/skillTools.js +195 -0
- package/dist/plugins/skills/skillTools.js.map +1 -0
- package/dist/plugins/skills/types.d.ts +25 -0
- package/dist/plugins/skills/types.js +2 -0
- package/dist/plugins/skills/types.js.map +1 -0
- package/dist/plugins/types.d.ts +30 -0
- package/dist/plugins/types.js +2 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +25 -0
- package/dist/scheduler/scheduler.js +239 -0
- package/dist/scheduler/scheduler.js.map +1 -0
- package/dist/scheduler/types.d.ts +39 -0
- package/dist/scheduler/types.js +2 -0
- package/dist/scheduler/types.js.map +1 -0
- package/dist/security/childProcessEnv.d.ts +5 -0
- package/dist/security/childProcessEnv.js +42 -0
- package/dist/security/childProcessEnv.js.map +1 -0
- package/dist/security/fileSandbox.d.ts +153 -0
- package/dist/security/fileSandbox.js +779 -0
- package/dist/security/fileSandbox.js.map +1 -0
- package/dist/service/index.d.ts +30 -0
- package/dist/service/index.js +41 -0
- package/dist/service/index.js.map +1 -0
- package/dist/service/systemd.d.ts +36 -0
- package/dist/service/systemd.js +409 -0
- package/dist/service/systemd.js.map +1 -0
- package/dist/service/types.d.ts +118 -0
- package/dist/service/types.js +6 -0
- package/dist/service/types.js.map +1 -0
- package/dist/tools/builtin/calcTools.d.ts +2 -0
- package/dist/tools/builtin/calcTools.js +51 -0
- package/dist/tools/builtin/calcTools.js.map +1 -0
- package/dist/tools/builtin/fileTools.d.ts +11 -0
- package/dist/tools/builtin/fileTools.js +352 -0
- package/dist/tools/builtin/fileTools.js.map +1 -0
- package/dist/tools/builtin/memoryTools.d.ts +4 -0
- package/dist/tools/builtin/memoryTools.js +123 -0
- package/dist/tools/builtin/memoryTools.js.map +1 -0
- package/dist/tools/builtin/noteTools.d.ts +4 -0
- package/dist/tools/builtin/noteTools.js +91 -0
- package/dist/tools/builtin/noteTools.js.map +1 -0
- package/dist/tools/builtin/pluginTools.d.ts +29 -0
- package/dist/tools/builtin/pluginTools.js +406 -0
- package/dist/tools/builtin/pluginTools.js.map +1 -0
- package/dist/tools/builtin/scheduleTools.d.ts +4 -0
- package/dist/tools/builtin/scheduleTools.js +156 -0
- package/dist/tools/builtin/scheduleTools.js.map +1 -0
- package/dist/tools/builtin/timeTools.d.ts +2 -0
- package/dist/tools/builtin/timeTools.js +41 -0
- package/dist/tools/builtin/timeTools.js.map +1 -0
- package/dist/tools/builtin/webTools.d.ts +12 -0
- package/dist/tools/builtin/webTools.js +325 -0
- package/dist/tools/builtin/webTools.js.map +1 -0
- package/dist/tools/registry.d.ts +15 -0
- package/dist/tools/registry.js +111 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/types.d.ts +41 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/utils/logger.d.ts +29 -0
- package/dist/utils/logger.js +124 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/wizard/onboarding.d.ts +3 -0
- package/dist/wizard/onboarding.js +408 -0
- package/dist/wizard/onboarding.js.map +1 -0
- package/package.json +69 -0
- package/plugins/example-calc/mcp.json +11 -0
- package/plugins/example-calc/plugin.json +12 -0
- package/plugins/example-calc/server.mjs +92 -0
- package/plugins/example-calc/skills/math-assistant/SKILL.md +15 -0
- package/plugins/plugin-builder/plugin.json +12 -0
- package/plugins/plugin-builder/skills/create-plugin/SKILL.md +149 -0
- package/plugins/plugin-builder/skills/manage-plugins/SKILL.md +157 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getMusondaHomeDir } from '../config/paths.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
export class SandboxSecurityError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
path;
|
|
8
|
+
details;
|
|
9
|
+
constructor(message, code = 'SANDBOX_SECURITY_VIOLATION', extra) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'SandboxSecurityError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.path = extra?.path;
|
|
14
|
+
this.details = extra?.details;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Match a string against a glob pattern.
|
|
19
|
+
* Supports `**` (recursive directories), `*` (any chars within path segment), and `?`.
|
|
20
|
+
*/
|
|
21
|
+
export function matchGlob(str, pattern) {
|
|
22
|
+
const normStr = str.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
23
|
+
const normPattern = pattern.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
24
|
+
let regexStr = '^';
|
|
25
|
+
let i = 0;
|
|
26
|
+
while (i < normPattern.length) {
|
|
27
|
+
const c = normPattern[i];
|
|
28
|
+
if (c === '*' && normPattern[i + 1] === '*') {
|
|
29
|
+
if (normPattern[i + 2] === '/') {
|
|
30
|
+
regexStr += '(?:.*\\/)?';
|
|
31
|
+
i += 3;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
regexStr += '.*';
|
|
35
|
+
i += 2;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (c === '*') {
|
|
39
|
+
regexStr += '[^/]*';
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
else if (c === '?') {
|
|
43
|
+
regexStr += '[^/]';
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
else if (['.', '+', '^', '$', '(', ')', '[', ']', '{', '}', '|'].includes(c)) {
|
|
47
|
+
regexStr += '\\' + c;
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
regexStr += c;
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
regexStr += '$';
|
|
56
|
+
return new RegExp(regexStr, 'i').test(normStr);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Normalize a path for platform-accurate comparison.
|
|
60
|
+
* On Windows, paths are case-insensitive.
|
|
61
|
+
*/
|
|
62
|
+
function normalizeForComparison(p) {
|
|
63
|
+
const norm = path.normalize(p);
|
|
64
|
+
return process.platform === 'win32' ? norm.toLowerCase() : norm;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Check if child is equal to parent or inside parent directory.
|
|
68
|
+
*/
|
|
69
|
+
export function isSubPath(child, parent) {
|
|
70
|
+
const childNorm = normalizeForComparison(child);
|
|
71
|
+
const parentNorm = normalizeForComparison(parent);
|
|
72
|
+
if (childNorm === parentNorm)
|
|
73
|
+
return true;
|
|
74
|
+
const rel = path.relative(parentNorm, childNorm);
|
|
75
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Canonical realpath with Windows long-path prefix stripping.
|
|
79
|
+
*/
|
|
80
|
+
export function getRealPath(p) {
|
|
81
|
+
let real = fs.realpathSync(p);
|
|
82
|
+
if (real.startsWith('\\\\?\\')) {
|
|
83
|
+
real = real.substring(4);
|
|
84
|
+
}
|
|
85
|
+
return path.normalize(real);
|
|
86
|
+
}
|
|
87
|
+
export class FileSandbox {
|
|
88
|
+
config;
|
|
89
|
+
normalizedRoots = [];
|
|
90
|
+
defaultRootPath = '';
|
|
91
|
+
constructor(config) {
|
|
92
|
+
this.config = config;
|
|
93
|
+
this.initRoots();
|
|
94
|
+
}
|
|
95
|
+
initRoots() {
|
|
96
|
+
const roots = this.config.allowedRoots.length > 0 ? this.config.allowedRoots : ['./data/workspace'];
|
|
97
|
+
for (const item of roots) {
|
|
98
|
+
let rule;
|
|
99
|
+
if (typeof item === 'string') {
|
|
100
|
+
rule = { path: item, readOnly: false };
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
rule = item;
|
|
104
|
+
}
|
|
105
|
+
const absPath = path.resolve(process.cwd(), rule.path);
|
|
106
|
+
if (!fs.existsSync(absPath)) {
|
|
107
|
+
try {
|
|
108
|
+
fs.mkdirSync(absPath, { recursive: true });
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
logger.warn(`Could not create sandbox root directory ${absPath}: ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
let canonical = absPath;
|
|
115
|
+
if (fs.existsSync(absPath)) {
|
|
116
|
+
try {
|
|
117
|
+
canonical = getRealPath(absPath);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
canonical = path.normalize(absPath);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.normalizedRoots.push({
|
|
124
|
+
rawPath: rule.path,
|
|
125
|
+
resolvedPath: canonical,
|
|
126
|
+
readOnly: Boolean(rule.readOnly),
|
|
127
|
+
description: rule.description,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (this.config.defaultRoot) {
|
|
131
|
+
const defaultAbs = path.resolve(process.cwd(), this.config.defaultRoot);
|
|
132
|
+
this.defaultRootPath = fs.existsSync(defaultAbs) ? getRealPath(defaultAbs) : path.normalize(defaultAbs);
|
|
133
|
+
}
|
|
134
|
+
else if (this.normalizedRoots.length > 0) {
|
|
135
|
+
this.defaultRootPath = this.normalizedRoots[0].resolvedPath;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const fallback = path.resolve(process.cwd(), 'data/workspace');
|
|
139
|
+
if (!fs.existsSync(fallback))
|
|
140
|
+
fs.mkdirSync(fallback, { recursive: true });
|
|
141
|
+
this.defaultRootPath = getRealPath(fallback);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
getAllowedRoots() {
|
|
145
|
+
return [...this.normalizedRoots];
|
|
146
|
+
}
|
|
147
|
+
getDefaultRootPath() {
|
|
148
|
+
return this.defaultRootPath;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Validate path syntax to block malicious tricks:
|
|
152
|
+
* - Null bytes
|
|
153
|
+
* - ASCII control characters
|
|
154
|
+
* - UNC paths
|
|
155
|
+
* - Windows Alternate Data Streams (colon in segments)
|
|
156
|
+
* - Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
|
|
157
|
+
*/
|
|
158
|
+
validatePathSyntax(rawPath) {
|
|
159
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) {
|
|
160
|
+
throw new SandboxSecurityError('File path must be a non-empty string.', 'EMPTY_PATH');
|
|
161
|
+
}
|
|
162
|
+
// Check for null bytes
|
|
163
|
+
if (rawPath.includes('\0')) {
|
|
164
|
+
throw new SandboxSecurityError('Path contains invalid null byte.', 'NULL_BYTE_DETECTED', { path: rawPath });
|
|
165
|
+
}
|
|
166
|
+
// Check for ASCII control characters (0x01 - 0x1F)
|
|
167
|
+
if (/[\x01-\x1F]/.test(rawPath)) {
|
|
168
|
+
throw new SandboxSecurityError('Path contains illegal control characters.', 'CONTROL_CHAR_DETECTED', { path: rawPath });
|
|
169
|
+
}
|
|
170
|
+
// Reject UNC network paths
|
|
171
|
+
if (rawPath.startsWith('\\\\') || rawPath.startsWith('//')) {
|
|
172
|
+
throw new SandboxSecurityError('UNC network paths are not permitted in sandbox.', 'UNC_PATH_BLOCKED', { path: rawPath });
|
|
173
|
+
}
|
|
174
|
+
// Inspect individual path segments
|
|
175
|
+
const segments = rawPath.split(/[/\\]+/).filter(Boolean);
|
|
176
|
+
for (let idx = 0; idx < segments.length; idx++) {
|
|
177
|
+
const segment = segments[idx];
|
|
178
|
+
// Check Windows reserved device names
|
|
179
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(segment)) {
|
|
180
|
+
throw new SandboxSecurityError(`Path contains reserved system device name "${segment}".`, 'RESERVED_DEVICE_NAME', { path: rawPath });
|
|
181
|
+
}
|
|
182
|
+
// Check for Alternate Data Streams or unexpected colons
|
|
183
|
+
// Index 0 in Windows absolute paths like "C:" is permitted
|
|
184
|
+
if (segment.includes(':')) {
|
|
185
|
+
const isWindowsDrive = idx === 0 && /^[a-zA-Z]:$/.test(segment);
|
|
186
|
+
if (!isWindowsDrive) {
|
|
187
|
+
throw new SandboxSecurityError(`Path contains illegal colon in component "${segment}" (Alternate Data Streams prohibited).`, 'ADS_STREAM_BLOCKED', { path: rawPath });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Validates that a path is fully contained within the allowed sandbox roots,
|
|
194
|
+
* does not escape via traversal or symlinks, does not match blocked patterns,
|
|
195
|
+
* and respects permission controls (e.g. readOnly).
|
|
196
|
+
*/
|
|
197
|
+
validatePath(rawPath, operation) {
|
|
198
|
+
if (!this.config.enabled) {
|
|
199
|
+
// If sandboxing is explicitly disabled by config, still prevent null bytes and resolve path
|
|
200
|
+
this.validatePathSyntax(rawPath);
|
|
201
|
+
const res = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(this.defaultRootPath, rawPath);
|
|
202
|
+
return {
|
|
203
|
+
resolvedPath: res,
|
|
204
|
+
rootRule: {
|
|
205
|
+
rawPath: '.',
|
|
206
|
+
resolvedPath: process.cwd(),
|
|
207
|
+
readOnly: false,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// 1. Validate syntax guardrails
|
|
212
|
+
this.validatePathSyntax(rawPath);
|
|
213
|
+
// 2. Resolve to absolute path
|
|
214
|
+
const resolved = path.isAbsolute(rawPath)
|
|
215
|
+
? path.resolve(rawPath)
|
|
216
|
+
: path.resolve(this.defaultRootPath, rawPath);
|
|
217
|
+
// Guard against accessing system user security directory (~/.musonda)
|
|
218
|
+
const musondaHome = getMusondaHomeDir();
|
|
219
|
+
const isTargetingMusonda = isSubPath(resolved, musondaHome) ||
|
|
220
|
+
(fs.existsSync(musondaHome) && isSubPath(resolved, getRealPath(musondaHome)));
|
|
221
|
+
if (isTargetingMusonda) {
|
|
222
|
+
logger.warn(`[SANDBOX_BLOCKED] Attempted access to user security directory "${resolved}".`);
|
|
223
|
+
throw new SandboxSecurityError(`Access denied: System user security directory "${musondaHome}" is protected by security guardrails.`, 'SECURITY_ROOT_PROTECTED', { path: rawPath });
|
|
224
|
+
}
|
|
225
|
+
// 3. Find matching allowed root
|
|
226
|
+
let matchedRoot;
|
|
227
|
+
for (const root of this.normalizedRoots) {
|
|
228
|
+
if (isSubPath(resolved, root.resolvedPath)) {
|
|
229
|
+
matchedRoot = root;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!matchedRoot) {
|
|
234
|
+
logger.warn(`[SANDBOX_BLOCKED] Path "${rawPath}" resolves to "${resolved}" which is outside allowed sandbox roots.`);
|
|
235
|
+
throw new SandboxSecurityError(`Access denied: Path "${rawPath}" resolves outside of permitted sandbox directories.`, 'PATH_OUTSIDE_SANDBOX', {
|
|
236
|
+
path: rawPath,
|
|
237
|
+
details: {
|
|
238
|
+
resolved,
|
|
239
|
+
allowedRoots: this.normalizedRoots.map((r) => r.rawPath),
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
// 4. Symlink resolution protection
|
|
244
|
+
if (fs.existsSync(resolved)) {
|
|
245
|
+
let realTarget;
|
|
246
|
+
try {
|
|
247
|
+
realTarget = getRealPath(resolved);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
throw new SandboxSecurityError(`Unable to resolve canonical real path: ${err.message}`, 'REALPATH_RESOLUTION_FAILED', { path: rawPath });
|
|
251
|
+
}
|
|
252
|
+
// Verify that canonical real target is inside an allowed root
|
|
253
|
+
const insideAnyAllowed = this.normalizedRoots.some((r) => isSubPath(realTarget, r.resolvedPath));
|
|
254
|
+
if (!insideAnyAllowed) {
|
|
255
|
+
logger.warn(`[SANDBOX_BLOCKED] Symlink escape: "${resolved}" targets "${realTarget}".`);
|
|
256
|
+
throw new SandboxSecurityError(`Access denied: Target resolves via symbolic link to "${realTarget}", which is outside allowed sandbox roots.`, 'SYMLINK_ESCAPE_DETECTED', { path: rawPath, details: { realTarget } });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
// Path does not exist yet (e.g. creating a new file).
|
|
261
|
+
// Find deepest existing ancestor and ensure its real path does not escape
|
|
262
|
+
let current = path.dirname(resolved);
|
|
263
|
+
while (!fs.existsSync(current)) {
|
|
264
|
+
const parent = path.dirname(current);
|
|
265
|
+
if (parent === current)
|
|
266
|
+
break;
|
|
267
|
+
current = parent;
|
|
268
|
+
}
|
|
269
|
+
if (fs.existsSync(current)) {
|
|
270
|
+
try {
|
|
271
|
+
const realAncestor = getRealPath(current);
|
|
272
|
+
const ancestorInside = this.normalizedRoots.some((r) => isSubPath(realAncestor, r.resolvedPath));
|
|
273
|
+
if (!ancestorInside) {
|
|
274
|
+
logger.warn(`[SANDBOX_BLOCKED] Parent directory resolves to "${realAncestor}" outside sandbox.`);
|
|
275
|
+
throw new SandboxSecurityError(`Access denied: Parent directory resolves via symbolic link outside allowed sandbox roots.`, 'SYMLINK_ESCAPE_DETECTED', { path: rawPath });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
throw new SandboxSecurityError(`Unable to verify ancestor path: ${err.message}`, 'REALPATH_RESOLUTION_FAILED', { path: rawPath });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// 5. Blocked patterns protection
|
|
284
|
+
const basename = path.basename(resolved);
|
|
285
|
+
const relativeFromRoot = path.relative(matchedRoot.resolvedPath, resolved).replace(/\\/g, '/');
|
|
286
|
+
const relativeFromRaw = rawPath.replace(/\\/g, '/');
|
|
287
|
+
for (const pattern of this.config.blockedPatterns) {
|
|
288
|
+
if (matchGlob(basename, pattern) ||
|
|
289
|
+
matchGlob(relativeFromRoot, pattern) ||
|
|
290
|
+
matchGlob(relativeFromRaw, pattern)) {
|
|
291
|
+
logger.warn(`[SANDBOX_BLOCKED] Path "${rawPath}" matches restricted pattern "${pattern}".`);
|
|
292
|
+
throw new SandboxSecurityError(`Access denied: Path "${rawPath}" matches protected file pattern "${pattern}".`, 'RESTRICTED_PATTERN', { path: rawPath, details: { pattern } });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// 6. Permission & Operation guardrails
|
|
296
|
+
if (operation === 'write' || operation === 'delete') {
|
|
297
|
+
if (matchedRoot.readOnly) {
|
|
298
|
+
throw new SandboxSecurityError(`Access denied: Directory "${matchedRoot.rawPath}" is mounted as read-only. Operation "${operation}" is forbidden.`, 'READ_ONLY_VIOLATION', { path: rawPath, details: { root: matchedRoot.rawPath } });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (operation === 'delete') {
|
|
302
|
+
if (!this.config.allowDelete) {
|
|
303
|
+
throw new SandboxSecurityError('Access denied: File deletion operations are disabled by policy.', 'DELETE_DISABLED', { path: rawPath });
|
|
304
|
+
}
|
|
305
|
+
// Guard against deleting the root sandbox directory itself
|
|
306
|
+
if (normalizeForComparison(resolved) === normalizeForComparison(matchedRoot.resolvedPath)) {
|
|
307
|
+
throw new SandboxSecurityError(`Access denied: Cannot delete root sandbox directory "${rawPath}".`, 'ROOT_DELETION_PROHIBITED', { path: rawPath });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { resolvedPath: resolved, rootRule: matchedRoot };
|
|
311
|
+
}
|
|
312
|
+
// =========================================================================
|
|
313
|
+
// File Operations (Guarded)
|
|
314
|
+
// =========================================================================
|
|
315
|
+
readFile(rawPath, options) {
|
|
316
|
+
const { resolvedPath } = this.validatePath(rawPath, 'read');
|
|
317
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
318
|
+
throw new SandboxSecurityError(`File not found: "${rawPath}".`, 'FILE_NOT_FOUND', { path: rawPath });
|
|
319
|
+
}
|
|
320
|
+
const stat = fs.statSync(resolvedPath);
|
|
321
|
+
if (stat.isDirectory()) {
|
|
322
|
+
throw new SandboxSecurityError(`Path "${rawPath}" is a directory, not a file. Use file_list to inspect directories.`, 'IS_DIRECTORY', { path: rawPath });
|
|
323
|
+
}
|
|
324
|
+
const effectiveMaxBytes = options?.maxBytes
|
|
325
|
+
? Math.min(options.maxBytes, this.config.maxReadSizeBytes)
|
|
326
|
+
: this.config.maxReadSizeBytes;
|
|
327
|
+
if (stat.size > effectiveMaxBytes && !options?.startLine && !options?.endLine) {
|
|
328
|
+
throw new SandboxSecurityError(`File size (${stat.size} bytes) exceeds maximum read limit (${effectiveMaxBytes} bytes). Provide startLine and endLine to paginate through this file.`, 'FILE_TOO_LARGE', { path: rawPath, details: { size: stat.size, maxAllowed: effectiveMaxBytes } });
|
|
329
|
+
}
|
|
330
|
+
const encoding = options?.encoding || 'utf-8';
|
|
331
|
+
if (encoding === 'base64') {
|
|
332
|
+
const buffer = fs.readFileSync(resolvedPath);
|
|
333
|
+
return {
|
|
334
|
+
path: rawPath,
|
|
335
|
+
content: buffer.toString('base64'),
|
|
336
|
+
totalBytes: stat.size,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const fullContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
340
|
+
if (options?.startLine !== undefined || options?.endLine !== undefined) {
|
|
341
|
+
const lines = fullContent.split(/\r?\n/);
|
|
342
|
+
const totalLines = lines.length;
|
|
343
|
+
const start = Math.max(1, options.startLine || 1);
|
|
344
|
+
const end = options.endLine ? Math.min(totalLines, options.endLine) : totalLines;
|
|
345
|
+
if (start > totalLines) {
|
|
346
|
+
return {
|
|
347
|
+
path: rawPath,
|
|
348
|
+
content: '',
|
|
349
|
+
startLine: start,
|
|
350
|
+
endLine: end,
|
|
351
|
+
totalLines,
|
|
352
|
+
totalBytes: stat.size,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const slicedLines = lines.slice(start - 1, end);
|
|
356
|
+
return {
|
|
357
|
+
path: rawPath,
|
|
358
|
+
content: slicedLines.join('\n'),
|
|
359
|
+
startLine: start,
|
|
360
|
+
endLine: end,
|
|
361
|
+
totalLines,
|
|
362
|
+
totalBytes: stat.size,
|
|
363
|
+
truncated: start > 1 || end < totalLines,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
path: rawPath,
|
|
368
|
+
content: fullContent,
|
|
369
|
+
totalBytes: stat.size,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
writeFile(rawPath, content, options) {
|
|
373
|
+
const { resolvedPath } = this.validatePath(rawPath, 'write');
|
|
374
|
+
const encoding = options?.encoding || 'utf-8';
|
|
375
|
+
const buffer = Buffer.from(content, encoding);
|
|
376
|
+
if (buffer.length > this.config.maxWriteSizeBytes) {
|
|
377
|
+
throw new SandboxSecurityError(`Content size (${buffer.length} bytes) exceeds maximum write limit (${this.config.maxWriteSizeBytes} bytes).`, 'CONTENT_TOO_LARGE', { path: rawPath, details: { size: buffer.length, maxAllowed: this.config.maxWriteSizeBytes } });
|
|
378
|
+
}
|
|
379
|
+
if (fs.existsSync(resolvedPath)) {
|
|
380
|
+
if (options?.overwrite === false) {
|
|
381
|
+
throw new SandboxSecurityError(`File "${rawPath}" already exists and overwrite is set to false.`, 'FILE_EXISTS', { path: rawPath });
|
|
382
|
+
}
|
|
383
|
+
const stat = fs.statSync(resolvedPath);
|
|
384
|
+
if (stat.isDirectory()) {
|
|
385
|
+
throw new SandboxSecurityError(`Cannot write to "${rawPath}" because it is an existing directory.`, 'IS_DIRECTORY', { path: rawPath });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const parentDir = path.dirname(resolvedPath);
|
|
389
|
+
if (!fs.existsSync(parentDir)) {
|
|
390
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
391
|
+
}
|
|
392
|
+
fs.writeFileSync(resolvedPath, buffer);
|
|
393
|
+
logger.info(`[FILE_SANDBOX] Wrote ${buffer.length} bytes to "${rawPath}"`);
|
|
394
|
+
return {
|
|
395
|
+
success: true,
|
|
396
|
+
path: rawPath,
|
|
397
|
+
bytesWritten: buffer.length,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
editFile(rawPath, target, replacement, allowMultiple = false) {
|
|
401
|
+
const { resolvedPath } = this.validatePath(rawPath, 'write');
|
|
402
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
403
|
+
throw new SandboxSecurityError(`File not found for edit: "${rawPath}".`, 'FILE_NOT_FOUND', { path: rawPath });
|
|
404
|
+
}
|
|
405
|
+
const stat = fs.statSync(resolvedPath);
|
|
406
|
+
if (stat.isDirectory()) {
|
|
407
|
+
throw new SandboxSecurityError(`Cannot edit "${rawPath}" because it is a directory.`, 'IS_DIRECTORY', { path: rawPath });
|
|
408
|
+
}
|
|
409
|
+
const content = fs.readFileSync(resolvedPath, 'utf-8');
|
|
410
|
+
if (!target) {
|
|
411
|
+
throw new SandboxSecurityError('Target string to replace cannot be empty.', 'EMPTY_TARGET');
|
|
412
|
+
}
|
|
413
|
+
// Count occurrences
|
|
414
|
+
let count = 0;
|
|
415
|
+
let pos = 0;
|
|
416
|
+
while ((pos = content.indexOf(target, pos)) !== -1) {
|
|
417
|
+
count++;
|
|
418
|
+
pos += target.length;
|
|
419
|
+
}
|
|
420
|
+
if (count === 0) {
|
|
421
|
+
throw new SandboxSecurityError(`Target text snippet was not found in file "${rawPath}". Please check the text and try again.`, 'TARGET_NOT_FOUND', { path: rawPath });
|
|
422
|
+
}
|
|
423
|
+
if (count > 1 && !allowMultiple) {
|
|
424
|
+
throw new SandboxSecurityError(`Target snippet was found ${count} times in "${rawPath}". Provide more surrounding lines to uniquely identify the section, or set allowMultiple: true.`, 'AMBIGUOUS_TARGET', { path: rawPath, details: { occurrences: count } });
|
|
425
|
+
}
|
|
426
|
+
let newContent;
|
|
427
|
+
if (allowMultiple) {
|
|
428
|
+
newContent = content.split(target).join(replacement);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
newContent = content.replace(target, replacement);
|
|
432
|
+
}
|
|
433
|
+
const buffer = Buffer.from(newContent, 'utf-8');
|
|
434
|
+
if (buffer.length > this.config.maxWriteSizeBytes) {
|
|
435
|
+
throw new SandboxSecurityError(`Edited file size (${buffer.length} bytes) exceeds maximum write limit (${this.config.maxWriteSizeBytes} bytes).`, 'CONTENT_TOO_LARGE', { path: rawPath });
|
|
436
|
+
}
|
|
437
|
+
fs.writeFileSync(resolvedPath, buffer);
|
|
438
|
+
logger.info(`[FILE_SANDBOX] Edited "${rawPath}": replaced ${count} occurrence(s)`);
|
|
439
|
+
return {
|
|
440
|
+
success: true,
|
|
441
|
+
path: rawPath,
|
|
442
|
+
occurrencesReplaced: count,
|
|
443
|
+
bytesWritten: buffer.length,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
appendFile(rawPath, content) {
|
|
447
|
+
const { resolvedPath } = this.validatePath(rawPath, 'write');
|
|
448
|
+
const buffer = Buffer.from(content, 'utf-8');
|
|
449
|
+
const existingSize = fs.existsSync(resolvedPath) ? fs.statSync(resolvedPath).size : 0;
|
|
450
|
+
if (existingSize + buffer.length > this.config.maxWriteSizeBytes) {
|
|
451
|
+
throw new SandboxSecurityError(`Resulting file size (${existingSize + buffer.length} bytes) exceeds maximum write limit (${this.config.maxWriteSizeBytes} bytes).`, 'CONTENT_TOO_LARGE', { path: rawPath });
|
|
452
|
+
}
|
|
453
|
+
const parentDir = path.dirname(resolvedPath);
|
|
454
|
+
if (!fs.existsSync(parentDir)) {
|
|
455
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
456
|
+
}
|
|
457
|
+
fs.appendFileSync(resolvedPath, buffer);
|
|
458
|
+
logger.info(`[FILE_SANDBOX] Appended ${buffer.length} bytes to "${rawPath}"`);
|
|
459
|
+
return {
|
|
460
|
+
success: true,
|
|
461
|
+
path: rawPath,
|
|
462
|
+
bytesAppended: buffer.length,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
listFiles(rawPath = '.', options) {
|
|
466
|
+
const { resolvedPath, rootRule } = this.validatePath(rawPath, 'read');
|
|
467
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
468
|
+
throw new SandboxSecurityError(`Directory not found: "${rawPath}".`, 'DIRECTORY_NOT_FOUND', { path: rawPath });
|
|
469
|
+
}
|
|
470
|
+
const stat = fs.statSync(resolvedPath);
|
|
471
|
+
if (!stat.isDirectory()) {
|
|
472
|
+
throw new SandboxSecurityError(`Path "${rawPath}" is a file, not a directory.`, 'NOT_A_DIRECTORY', { path: rawPath });
|
|
473
|
+
}
|
|
474
|
+
const entries = [];
|
|
475
|
+
const maxEntries = this.config.maxListEntries;
|
|
476
|
+
let truncated = false;
|
|
477
|
+
const recursive = Boolean(options?.recursive);
|
|
478
|
+
const maxDepth = options?.maxDepth !== undefined ? options.maxDepth : 2;
|
|
479
|
+
const traverse = (currentDir, depth) => {
|
|
480
|
+
if (entries.length >= maxEntries) {
|
|
481
|
+
truncated = true;
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
let dirents;
|
|
485
|
+
try {
|
|
486
|
+
dirents = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
logger.warn(`Could not read directory ${currentDir}: ${err.message}`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
for (const dirent of dirents) {
|
|
493
|
+
if (entries.length >= maxEntries) {
|
|
494
|
+
truncated = true;
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const fullPath = path.join(currentDir, dirent.name);
|
|
498
|
+
const relFromTarget = path.relative(resolvedPath, fullPath).replace(/\\/g, '/');
|
|
499
|
+
const relFromRoot = path.relative(rootRule.resolvedPath, fullPath).replace(/\\/g, '/');
|
|
500
|
+
// Check if item matches blocked patterns
|
|
501
|
+
const isBlocked = this.config.blockedPatterns.some((p) => matchGlob(dirent.name, p) || matchGlob(relFromRoot, p));
|
|
502
|
+
if (isBlocked) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
let isDir = dirent.isDirectory();
|
|
506
|
+
let isFile = dirent.isFile();
|
|
507
|
+
let size = 0;
|
|
508
|
+
let modifiedAt = '';
|
|
509
|
+
try {
|
|
510
|
+
const s = fs.statSync(fullPath);
|
|
511
|
+
size = s.size;
|
|
512
|
+
modifiedAt = s.mtime.toISOString();
|
|
513
|
+
isDir = s.isDirectory();
|
|
514
|
+
isFile = s.isFile();
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// In case of broken link or permission
|
|
518
|
+
}
|
|
519
|
+
entries.push({
|
|
520
|
+
name: dirent.name,
|
|
521
|
+
relativePath: relFromTarget,
|
|
522
|
+
isDirectory: isDir,
|
|
523
|
+
isFile: isFile,
|
|
524
|
+
sizeBytes: size,
|
|
525
|
+
modifiedAt,
|
|
526
|
+
});
|
|
527
|
+
if (isDir && recursive && depth < maxDepth) {
|
|
528
|
+
traverse(fullPath, depth + 1);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
traverse(resolvedPath, 1);
|
|
533
|
+
return {
|
|
534
|
+
directory: rawPath,
|
|
535
|
+
totalEntries: entries.length,
|
|
536
|
+
truncated,
|
|
537
|
+
entries,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
deletePath(rawPath, recursive = false) {
|
|
541
|
+
const { resolvedPath, rootRule } = this.validatePath(rawPath, 'delete');
|
|
542
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
543
|
+
throw new SandboxSecurityError(`Path not found for deletion: "${rawPath}".`, 'FILE_NOT_FOUND', { path: rawPath });
|
|
544
|
+
}
|
|
545
|
+
const stat = fs.statSync(resolvedPath);
|
|
546
|
+
if (stat.isDirectory()) {
|
|
547
|
+
const children = fs.readdirSync(resolvedPath);
|
|
548
|
+
if (children.length > 0 && !recursive) {
|
|
549
|
+
throw new SandboxSecurityError(`Directory "${rawPath}" is not empty. Set recursive: true to delete non-empty directories.`, 'DIRECTORY_NOT_EMPTY', { path: rawPath, details: { itemCount: children.length } });
|
|
550
|
+
}
|
|
551
|
+
// Verify no blocked files exist inside before recursive deletion
|
|
552
|
+
if (recursive) {
|
|
553
|
+
this.verifyNoBlockedFilesInTree(resolvedPath, rootRule.resolvedPath);
|
|
554
|
+
}
|
|
555
|
+
fs.rmSync(resolvedPath, { recursive: true, force: true });
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
fs.unlinkSync(resolvedPath);
|
|
559
|
+
}
|
|
560
|
+
logger.info(`[FILE_SANDBOX] Deleted "${rawPath}" (recursive: ${recursive})`);
|
|
561
|
+
return {
|
|
562
|
+
success: true,
|
|
563
|
+
path: rawPath,
|
|
564
|
+
deleted: true,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
verifyNoBlockedFilesInTree(dir, rootCanonical) {
|
|
568
|
+
const dirents = fs.readdirSync(dir, { withFileTypes: true });
|
|
569
|
+
for (const d of dirents) {
|
|
570
|
+
const full = path.join(dir, d.name);
|
|
571
|
+
const rel = path.relative(rootCanonical, full).replace(/\\/g, '/');
|
|
572
|
+
for (const pattern of this.config.blockedPatterns) {
|
|
573
|
+
if (matchGlob(d.name, pattern) || matchGlob(rel, pattern)) {
|
|
574
|
+
throw new SandboxSecurityError(`Cannot recursively delete directory containing protected file "${d.name}" (pattern "${pattern}").`, 'PROTECTED_FILE_IN_TREE', { path: full, details: { pattern } });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (d.isDirectory()) {
|
|
578
|
+
this.verifyNoBlockedFilesInTree(full, rootCanonical);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
getFileInfo(rawPath) {
|
|
583
|
+
const { resolvedPath, rootRule } = this.validatePath(rawPath, 'read');
|
|
584
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
585
|
+
return {
|
|
586
|
+
exists: false,
|
|
587
|
+
path: rawPath,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
const stat = fs.statSync(resolvedPath);
|
|
591
|
+
return {
|
|
592
|
+
exists: true,
|
|
593
|
+
path: rawPath,
|
|
594
|
+
isFile: stat.isFile(),
|
|
595
|
+
isDirectory: stat.isDirectory(),
|
|
596
|
+
isSymbolicLink: stat.isSymbolicLink(),
|
|
597
|
+
sizeBytes: stat.size,
|
|
598
|
+
createdAt: stat.birthtime.toISOString(),
|
|
599
|
+
modifiedAt: stat.mtime.toISOString(),
|
|
600
|
+
readOnly: rootRule.readOnly,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
createDirectory(rawPath) {
|
|
604
|
+
const { resolvedPath } = this.validatePath(rawPath, 'write');
|
|
605
|
+
if (fs.existsSync(resolvedPath)) {
|
|
606
|
+
const stat = fs.statSync(resolvedPath);
|
|
607
|
+
if (stat.isDirectory()) {
|
|
608
|
+
return { success: true, path: rawPath, created: false };
|
|
609
|
+
}
|
|
610
|
+
throw new SandboxSecurityError(`Path "${rawPath}" already exists and is a file.`, 'PATH_EXISTS_AS_FILE', { path: rawPath });
|
|
611
|
+
}
|
|
612
|
+
fs.mkdirSync(resolvedPath, { recursive: true });
|
|
613
|
+
logger.info(`[FILE_SANDBOX] Created directory "${rawPath}"`);
|
|
614
|
+
return {
|
|
615
|
+
success: true,
|
|
616
|
+
path: rawPath,
|
|
617
|
+
created: true,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
searchFiles(pattern, rawPath = '.', maxResults = 50) {
|
|
621
|
+
const { resolvedPath, rootRule } = this.validatePath(rawPath, 'read');
|
|
622
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
623
|
+
throw new SandboxSecurityError(`Search path not found: "${rawPath}".`, 'DIRECTORY_NOT_FOUND', { path: rawPath });
|
|
624
|
+
}
|
|
625
|
+
const matches = [];
|
|
626
|
+
const limit = Math.min(maxResults, this.config.maxListEntries);
|
|
627
|
+
let truncated = false;
|
|
628
|
+
const traverse = (currentDir, depth) => {
|
|
629
|
+
if (matches.length >= limit || depth > 10) {
|
|
630
|
+
if (matches.length >= limit)
|
|
631
|
+
truncated = true;
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
let dirents;
|
|
635
|
+
try {
|
|
636
|
+
dirents = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
for (const d of dirents) {
|
|
642
|
+
if (matches.length >= limit) {
|
|
643
|
+
truncated = true;
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const fullPath = path.join(currentDir, d.name);
|
|
647
|
+
const relFromRoot = path.relative(rootRule.resolvedPath, fullPath).replace(/\\/g, '/');
|
|
648
|
+
const relFromSearch = path.relative(resolvedPath, fullPath).replace(/\\/g, '/');
|
|
649
|
+
// Check blocked patterns
|
|
650
|
+
const isBlocked = this.config.blockedPatterns.some((p) => matchGlob(d.name, p) || matchGlob(relFromRoot, p));
|
|
651
|
+
if (isBlocked)
|
|
652
|
+
continue;
|
|
653
|
+
// Check match
|
|
654
|
+
if (matchGlob(d.name, pattern) || d.name.toLowerCase().includes(pattern.toLowerCase())) {
|
|
655
|
+
let size = 0;
|
|
656
|
+
try {
|
|
657
|
+
size = fs.statSync(fullPath).size;
|
|
658
|
+
}
|
|
659
|
+
catch { }
|
|
660
|
+
matches.push({
|
|
661
|
+
name: d.name,
|
|
662
|
+
relativePath: relFromSearch,
|
|
663
|
+
isDirectory: d.isDirectory(),
|
|
664
|
+
sizeBytes: size,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
if (d.isDirectory()) {
|
|
668
|
+
traverse(fullPath, depth + 1);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
traverse(resolvedPath, 1);
|
|
673
|
+
return {
|
|
674
|
+
pattern,
|
|
675
|
+
totalMatches: matches.length,
|
|
676
|
+
truncated,
|
|
677
|
+
matches,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
grepFiles(query, rawPath = '.', options) {
|
|
681
|
+
const { resolvedPath, rootRule } = this.validatePath(rawPath, 'read');
|
|
682
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
683
|
+
throw new SandboxSecurityError(`Path not found: "${rawPath}".`, 'FILE_NOT_FOUND', { path: rawPath });
|
|
684
|
+
}
|
|
685
|
+
const matches = [];
|
|
686
|
+
const limit = Math.min(options?.maxResults || 50, this.config.maxListEntries);
|
|
687
|
+
const caseInsensitive = options?.caseInsensitive ?? true;
|
|
688
|
+
const lowerQuery = query.toLowerCase();
|
|
689
|
+
let truncated = false;
|
|
690
|
+
const searchFile = (filePath) => {
|
|
691
|
+
if (matches.length >= limit) {
|
|
692
|
+
truncated = true;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
let content;
|
|
696
|
+
try {
|
|
697
|
+
const stat = fs.statSync(filePath);
|
|
698
|
+
if (stat.size > this.config.maxReadSizeBytes)
|
|
699
|
+
return; // Skip oversized files
|
|
700
|
+
// Check for binary file by reading first 512 bytes
|
|
701
|
+
const fd = fs.openSync(filePath, 'r');
|
|
702
|
+
const buf = Buffer.alloc(512);
|
|
703
|
+
const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
|
|
704
|
+
fs.closeSync(fd);
|
|
705
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
706
|
+
if (buf[i] === 0)
|
|
707
|
+
return; // Binary file, skip
|
|
708
|
+
}
|
|
709
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const lines = content.split(/\r?\n/);
|
|
715
|
+
const relFromSearch = path.relative(resolvedPath, filePath).replace(/\\/g, '/');
|
|
716
|
+
for (let i = 0; i < lines.length; i++) {
|
|
717
|
+
if (matches.length >= limit) {
|
|
718
|
+
truncated = true;
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const line = lines[i];
|
|
722
|
+
const hasMatch = caseInsensitive
|
|
723
|
+
? line.toLowerCase().includes(lowerQuery)
|
|
724
|
+
: line.includes(query);
|
|
725
|
+
if (hasMatch) {
|
|
726
|
+
matches.push({
|
|
727
|
+
file: relFromSearch,
|
|
728
|
+
line: i + 1,
|
|
729
|
+
text: line.trimEnd(),
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
const traverse = (dir) => {
|
|
735
|
+
if (matches.length >= limit) {
|
|
736
|
+
truncated = true;
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
let dirents;
|
|
740
|
+
try {
|
|
741
|
+
dirents = fs.readdirSync(dir, { withFileTypes: true });
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
for (const d of dirents) {
|
|
747
|
+
if (matches.length >= limit) {
|
|
748
|
+
truncated = true;
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const fullPath = path.join(dir, d.name);
|
|
752
|
+
const relFromRoot = path.relative(rootRule.resolvedPath, fullPath).replace(/\\/g, '/');
|
|
753
|
+
const isBlocked = this.config.blockedPatterns.some((p) => matchGlob(d.name, p) || matchGlob(relFromRoot, p));
|
|
754
|
+
if (isBlocked)
|
|
755
|
+
continue;
|
|
756
|
+
if (d.isDirectory()) {
|
|
757
|
+
traverse(fullPath);
|
|
758
|
+
}
|
|
759
|
+
else if (d.isFile()) {
|
|
760
|
+
searchFile(fullPath);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
const stat = fs.statSync(resolvedPath);
|
|
765
|
+
if (stat.isFile()) {
|
|
766
|
+
searchFile(resolvedPath);
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
traverse(resolvedPath);
|
|
770
|
+
}
|
|
771
|
+
return {
|
|
772
|
+
query,
|
|
773
|
+
totalMatches: matches.length,
|
|
774
|
+
truncated,
|
|
775
|
+
matches,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
//# sourceMappingURL=fileSandbox.js.map
|