filemayor 2.1.0 → 4.0.5
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/LICENSE +90 -90
- package/README.md +96 -96
- package/core/ai/planner.js +42 -0
- package/core/ai/sentry.js +92 -0
- package/core/ai/strategist.js +186 -0
- package/core/ai/validator.js +116 -0
- package/core/analyzer.js +163 -152
- package/core/categories.js +235 -235
- package/core/cleaner.js +527 -527
- package/core/config.js +562 -562
- package/core/emergency-halt.js +104 -0
- package/core/engine/apply-engine.js +69 -0
- package/core/engine/cure-engine.js +70 -0
- package/core/engine/dedupe-engine.js +77 -0
- package/core/engine/explain-engine.js +114 -0
- package/core/engine/preview-engine.js +49 -0
- package/core/fs-abstraction.js +271 -0
- package/core/guardrail.js +115 -0
- package/core/index.js +135 -79
- package/core/intent-interpreter.js +301 -0
- package/core/jailer.js +151 -0
- package/core/license.js +403 -337
- package/core/metadata-store.js +104 -0
- package/core/organizer.js +414 -528
- package/core/reporter.js +783 -653
- package/core/scanner.js +466 -436
- package/core/security.js +370 -317
- package/core/sop-parser.js +564 -564
- package/core/telemetry.js +74 -0
- package/core/vault.js +179 -0
- package/core/watcher.js +478 -478
- package/index.js +895 -685
- package/package.json +3 -3
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
5
|
+
* FILEMAYOR v3.0 — INTENT INTERPRETER
|
|
6
|
+
* AI Logic Layer: Bridges fuzzy human language and atomic execution.
|
|
7
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const { getLicenseInfo } = require('./license');
|
|
16
|
+
|
|
17
|
+
const GEMINI_MODEL = 'gemini-2.0-flash';
|
|
18
|
+
const GEMINI_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
|
19
|
+
const PROXY_URL = process.env.FM_AI_PROXY_URL || 'https://filemayor.com/api/ai/plan';
|
|
20
|
+
|
|
21
|
+
// Stable device ID based on hostname + username (no PII sent to server)
|
|
22
|
+
function getDeviceId() {
|
|
23
|
+
const raw = `${os.hostname()}-${os.userInfo().username}`;
|
|
24
|
+
return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 32);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getSystemPrompt() {
|
|
28
|
+
return `You are FileMayor's AI planning engine. Given a description of a messy folder and a user's intent, output a structured JSON plan.
|
|
29
|
+
|
|
30
|
+
Respond ONLY with valid JSON in this exact format:
|
|
31
|
+
{
|
|
32
|
+
"summary": "one sentence describing what the plan does",
|
|
33
|
+
"operations": [
|
|
34
|
+
{ "action": "move", "from": "relative/path/file.ext", "to": "Category/file.ext" },
|
|
35
|
+
{ "action": "rename", "from": "old_name.ext", "to": "new_name.ext" },
|
|
36
|
+
{ "action": "delete", "path": "junk_file.tmp" }
|
|
37
|
+
],
|
|
38
|
+
"warnings": ["optional list of things user should review"]
|
|
39
|
+
}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function callAi(prompt, context) {
|
|
43
|
+
const provider = process.env.FM_AI_PROVIDER || 'proxy';
|
|
44
|
+
const apiKey = process.env.FM_AI_API_KEY || process.env.GEMINI_API_KEY || '';
|
|
45
|
+
|
|
46
|
+
if (provider === 'gemini' && apiKey) {
|
|
47
|
+
return callGeminiDirect(prompt, context, apiKey);
|
|
48
|
+
}
|
|
49
|
+
if (provider === 'openai' && apiKey) {
|
|
50
|
+
return callOpenAI(prompt, context, apiKey);
|
|
51
|
+
}
|
|
52
|
+
if (provider === 'anthropic' && apiKey) {
|
|
53
|
+
return callAnthropic(prompt, context, apiKey);
|
|
54
|
+
}
|
|
55
|
+
if (provider === 'ollama') {
|
|
56
|
+
return callOllama(prompt, context);
|
|
57
|
+
}
|
|
58
|
+
// Default: use the FileMayor proxy
|
|
59
|
+
return callProxy(prompt, context);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function callProxy(prompt, context) {
|
|
63
|
+
const licenseInfo = getLicenseInfo();
|
|
64
|
+
const token = licenseInfo?.key || getDeviceId();
|
|
65
|
+
|
|
66
|
+
const res = await fetch(PROXY_URL, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
'Authorization': `Bearer ${token}`,
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
prompt,
|
|
74
|
+
context,
|
|
75
|
+
deviceId: getDeviceId(),
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (res.status === 429) {
|
|
80
|
+
const body = await res.json();
|
|
81
|
+
throw new Error(body.error || 'Monthly AI limit reached. Upgrade to Pro for unlimited access.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const body = await res.json().catch(() => ({}));
|
|
86
|
+
throw new Error(body.error || `AI service error (${res.status})`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const body = await res.json();
|
|
90
|
+
if (body.error && !body.plan) throw new Error(body.error);
|
|
91
|
+
return body.plan;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function callGeminiDirect(prompt, context, apiKey) {
|
|
95
|
+
const systemPrompt = getSystemPrompt();
|
|
96
|
+
|
|
97
|
+
const body = {
|
|
98
|
+
contents: [{
|
|
99
|
+
parts: [{
|
|
100
|
+
text: `${systemPrompt}\n\nUser request: ${prompt}\n\nFolder context: ${JSON.stringify(context)}`
|
|
101
|
+
}]
|
|
102
|
+
}],
|
|
103
|
+
generationConfig: {
|
|
104
|
+
temperature: 0.1,
|
|
105
|
+
topP: 0.95,
|
|
106
|
+
maxOutputTokens: 2048,
|
|
107
|
+
responseMimeType: 'application/json',
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const url = `${GEMINI_ENDPOINT}?key=${apiKey}`;
|
|
112
|
+
const response = await fetch(url, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
body: JSON.stringify(body),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const err = await response.text();
|
|
120
|
+
throw new Error(`Gemini API Error: ${response.status} - ${err}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const data = await response.json();
|
|
124
|
+
const rawText = data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
125
|
+
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
|
|
126
|
+
if (!jsonMatch) throw new Error('No JSON found in AI response');
|
|
127
|
+
return JSON.parse(jsonMatch[0]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function callOpenAI(prompt, context, apiKey) {
|
|
131
|
+
const systemPrompt = getSystemPrompt();
|
|
132
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
model: 'gpt-4o',
|
|
137
|
+
response_format: { type: 'json_object' },
|
|
138
|
+
messages: [
|
|
139
|
+
{ role: 'system', content: systemPrompt },
|
|
140
|
+
{ role: 'user', content: `User request: ${prompt}\n\nFolder context: ${JSON.stringify(context)}` }
|
|
141
|
+
]
|
|
142
|
+
})
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) throw new Error(`OpenAI error: ${res.status}`);
|
|
145
|
+
const data = await res.json();
|
|
146
|
+
return JSON.parse(data.choices[0].message.content);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function callAnthropic(prompt, context, apiKey) {
|
|
150
|
+
const systemPrompt = getSystemPrompt();
|
|
151
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: {
|
|
154
|
+
'Content-Type': 'application/json',
|
|
155
|
+
'x-api-key': apiKey,
|
|
156
|
+
'anthropic-version': '2023-06-01'
|
|
157
|
+
},
|
|
158
|
+
body: JSON.stringify({
|
|
159
|
+
model: 'claude-3-5-haiku-20241022',
|
|
160
|
+
max_tokens: 1024,
|
|
161
|
+
system: systemPrompt,
|
|
162
|
+
messages: [{ role: 'user', content: `User request: ${prompt}\n\nFolder context: ${JSON.stringify(context)}` }]
|
|
163
|
+
})
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) throw new Error(`Anthropic error: ${res.status}`);
|
|
166
|
+
const data = await res.json();
|
|
167
|
+
const text = data.content[0].text;
|
|
168
|
+
return JSON.parse(text.replace(/```json\n?|\n?```/g, '').trim());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function callOllama(prompt, context) {
|
|
172
|
+
const model = process.env.FM_OLLAMA_MODEL || 'llama3';
|
|
173
|
+
const baseUrl = process.env.FM_OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
174
|
+
const systemPrompt = getSystemPrompt();
|
|
175
|
+
const res = await fetch(`${baseUrl}/api/generate`, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({
|
|
179
|
+
model,
|
|
180
|
+
prompt: `${systemPrompt}\n\nUser request: ${prompt}\n\nFolder context: ${JSON.stringify(context)}`,
|
|
181
|
+
stream: false,
|
|
182
|
+
format: 'json'
|
|
183
|
+
})
|
|
184
|
+
});
|
|
185
|
+
if (!res.ok) throw new Error(`Ollama error: ${res.status}`);
|
|
186
|
+
const data = await res.json();
|
|
187
|
+
return JSON.parse(data.response);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
class IntentInterpreter {
|
|
191
|
+
constructor(apiKey) {
|
|
192
|
+
this.apiKey = apiKey || process.env.GEMINI_API_KEY;
|
|
193
|
+
// No warning needed — proxy works without a key
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Interpret a user prompt against clustered telemetry
|
|
198
|
+
* @param {string} userPrompt
|
|
199
|
+
* @param {Object} clusters - From MetadataSentry
|
|
200
|
+
* @param {Object} context
|
|
201
|
+
* @returns {Promise<Object>} The Curative Plan
|
|
202
|
+
*/
|
|
203
|
+
async interpret(userPrompt, clusters, context = {}) {
|
|
204
|
+
const telemetry = this._summarizeFiles(clusters);
|
|
205
|
+
const promptWithTelemetry = `USER INTENT: "${userPrompt}"\n\nFILES:\n${JSON.stringify(telemetry, null, 2)}`;
|
|
206
|
+
|
|
207
|
+
const proxyContext = {
|
|
208
|
+
...context,
|
|
209
|
+
fileCount: Object.values(clusters).reduce((sum, cat) => sum + (cat.count || 0), 0),
|
|
210
|
+
categories: Object.keys(clusters),
|
|
211
|
+
topFiles: Object.values(clusters).flatMap(cat => (cat.samples || []).slice(0, 3).map(s => s.name)),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// If apiKey was explicitly passed (non-env), inject it temporarily for the direct path
|
|
215
|
+
const prevKey = process.env.GEMINI_API_KEY;
|
|
216
|
+
const prevAiKey = process.env.FM_AI_API_KEY;
|
|
217
|
+
if (this.apiKey && this.apiKey !== prevKey) {
|
|
218
|
+
process.env.FM_AI_API_KEY = this.apiKey;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let curativePlan;
|
|
222
|
+
try {
|
|
223
|
+
curativePlan = await callAi(promptWithTelemetry, proxyContext);
|
|
224
|
+
} finally {
|
|
225
|
+
// Restore original env key if we temporarily set one
|
|
226
|
+
if (this.apiKey && this.apiKey !== prevKey) {
|
|
227
|
+
if (prevAiKey === undefined) {
|
|
228
|
+
delete process.env.FM_AI_API_KEY;
|
|
229
|
+
} else {
|
|
230
|
+
process.env.FM_AI_API_KEY = prevAiKey;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// If the proxy returned a raw plan object rather than a full curative plan shape,
|
|
236
|
+
// wrap it so downstream code always sees { narrative, plan, confidence }
|
|
237
|
+
if (curativePlan && !curativePlan.plan && !curativePlan.narrative) {
|
|
238
|
+
curativePlan = { narrative: '', plan: curativePlan, confidence: 80 };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// [Production Grade] Confidence Gating
|
|
242
|
+
if (curativePlan.confidence < 60) {
|
|
243
|
+
curativePlan.status = 'low_confidence';
|
|
244
|
+
curativePlan.message = 'I am not entirely sure about this request. Could you clarify your intent?';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// [Production Grade] Plan Validation
|
|
248
|
+
curativePlan.plan = this._validatePlan(curativePlan.plan);
|
|
249
|
+
|
|
250
|
+
return curativePlan;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Summarize clustered file list for AI ingestion
|
|
255
|
+
*/
|
|
256
|
+
_summarizeFiles(clusters) {
|
|
257
|
+
const summary = {};
|
|
258
|
+
|
|
259
|
+
for (const cat in clusters) {
|
|
260
|
+
summary[cat] = {
|
|
261
|
+
count: clusters[cat].count,
|
|
262
|
+
samples: clusters[cat].samples.map(s => ({
|
|
263
|
+
name: s.name,
|
|
264
|
+
path: s.path,
|
|
265
|
+
ancestry: s.ancestry,
|
|
266
|
+
bundleId: s.bundleId
|
|
267
|
+
}))
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return summary;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Validate plan entries from AI response
|
|
276
|
+
* Filters out any entries missing required fields
|
|
277
|
+
*/
|
|
278
|
+
_validatePlan(plan) {
|
|
279
|
+
if (!Array.isArray(plan)) return [];
|
|
280
|
+
return plan.filter(entry => {
|
|
281
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
282
|
+
if (!entry.source || typeof entry.source !== 'string') return false;
|
|
283
|
+
if (!entry.destination || typeof entry.destination !== 'string') return false;
|
|
284
|
+
if (!entry.reason) entry.reason = 'AI suggested move';
|
|
285
|
+
return true;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
_parseResponse(rawText) {
|
|
290
|
+
try {
|
|
291
|
+
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
|
|
292
|
+
if (!jsonMatch) throw new Error('No JSON found in AI response');
|
|
293
|
+
return JSON.parse(jsonMatch[0]);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
console.error('[FAIL] Failed to parse AI response:', rawText);
|
|
296
|
+
throw new Error(`AI Parsing Error: ${err.message}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = IntentInterpreter;
|
package/core/jailer.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
5
|
+
* FILEMAYOR CORE — THE JAILER (IMMUTABLE SECURITY LAYER)
|
|
6
|
+
*
|
|
7
|
+
* This is the final firewall between FileMayor's AI "intuition" and
|
|
8
|
+
* the actual operating system. Even if Gemini suggests moving files
|
|
9
|
+
* into System32, this module kills the operation cold.
|
|
10
|
+
*
|
|
11
|
+
* CRITICAL: This file must NEVER be modified by AI assistants.
|
|
12
|
+
* It is the immutable source of truth for filesystem safety.
|
|
13
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
|
|
22
|
+
// ─── Forbidden Zones (OS-Agnostic) ───────────────────────────────
|
|
23
|
+
const FORBIDDEN_ZONES = [
|
|
24
|
+
// Windows
|
|
25
|
+
'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)',
|
|
26
|
+
'C:\\ProgramData', 'C:\\Users\\Public', 'C:\\Users\\Default',
|
|
27
|
+
// Linux / macOS
|
|
28
|
+
'/etc', '/usr', '/bin', '/sbin', '/var', '/root',
|
|
29
|
+
'/boot', '/dev', '/proc', '/sys', '/run',
|
|
30
|
+
// macOS specific
|
|
31
|
+
'/System', '/Library', '/private'
|
|
32
|
+
].map(p => path.resolve(p).toLowerCase());
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* THE JAILER: Final security check for ALL FileMayor operations.
|
|
36
|
+
* Use this before ANY fs.rename, fs.unlink, or fs.copy operation.
|
|
37
|
+
*/
|
|
38
|
+
class FileMayorJailer {
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} rootDirectory - The user's working directory (the "jail")
|
|
41
|
+
*/
|
|
42
|
+
constructor(rootDirectory = process.cwd()) {
|
|
43
|
+
// Resolve the root to its absolute, REAL path (no symlinks)
|
|
44
|
+
try {
|
|
45
|
+
this.safeRoot = fs.realpathSync(path.resolve(rootDirectory)).toLowerCase();
|
|
46
|
+
} catch {
|
|
47
|
+
this.safeRoot = path.resolve(rootDirectory).toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate that a target path is safe for FileMayor to operate on.
|
|
53
|
+
* Resolves symlinks to their TRUE destination before checking.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} targetPath - The path to validate
|
|
56
|
+
* @returns {{ safe: boolean, realPath: string, error?: string }}
|
|
57
|
+
*/
|
|
58
|
+
validate(targetPath) {
|
|
59
|
+
try {
|
|
60
|
+
if (!targetPath || typeof targetPath !== 'string') {
|
|
61
|
+
return { safe: false, realPath: '', error: 'Empty or invalid path' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Reject null bytes (classic injection vector)
|
|
65
|
+
if (targetPath.includes('\0')) {
|
|
66
|
+
return { safe: false, realPath: '', error: 'Null byte injection detected' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const absoluteTarget = path.resolve(targetPath);
|
|
70
|
+
|
|
71
|
+
// [ANTI-SYMLINK] Resolve the REAL path (follows symlinks to true source)
|
|
72
|
+
// This defeats CVE-2025-55130 style symlink race conditions
|
|
73
|
+
let realTarget;
|
|
74
|
+
try {
|
|
75
|
+
realTarget = fs.existsSync(absoluteTarget)
|
|
76
|
+
? fs.realpathSync(absoluteTarget)
|
|
77
|
+
: absoluteTarget;
|
|
78
|
+
} catch {
|
|
79
|
+
realTarget = absoluteTarget;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const normalizedTarget = realTarget.toLowerCase();
|
|
83
|
+
|
|
84
|
+
// [JAIL CHECK] Is the target inside our allowed project folder?
|
|
85
|
+
if (!normalizedTarget.startsWith(this.safeRoot + path.sep) &&
|
|
86
|
+
normalizedTarget !== this.safeRoot) {
|
|
87
|
+
return {
|
|
88
|
+
safe: false,
|
|
89
|
+
realPath: realTarget,
|
|
90
|
+
error: `JAIL BREACH: "${realTarget}" is outside the safe root "${this.safeRoot}"`
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// [SYSTEM CHECK] Is the target a protected OS zone?
|
|
95
|
+
for (const zone of FORBIDDEN_ZONES) {
|
|
96
|
+
if (normalizedTarget.startsWith(zone + path.sep) || normalizedTarget === zone) {
|
|
97
|
+
return {
|
|
98
|
+
safe: false,
|
|
99
|
+
realPath: realTarget,
|
|
100
|
+
error: `SYSTEM PROTECTION: "${realTarget}" is within forbidden zone "${zone}"`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// [EXTENSION CHECK] Reject system-critical file types
|
|
106
|
+
const ext = path.extname(realTarget).toLowerCase();
|
|
107
|
+
const dangerousExts = new Set(['.sys', '.drv', '.dll', '.so', '.dylib', '.kext', '.plist', '.reg']);
|
|
108
|
+
if (dangerousExts.has(ext)) {
|
|
109
|
+
return {
|
|
110
|
+
safe: false,
|
|
111
|
+
realPath: realTarget,
|
|
112
|
+
error: `DANGEROUS FILE: "${ext}" files are system-critical and cannot be moved`
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { safe: true, realPath: realTarget };
|
|
117
|
+
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { safe: false, realPath: '', error: `Jailer error: ${err.message}` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate a move operation (both source AND destination must be safe)
|
|
125
|
+
* @param {string} source - Source file path
|
|
126
|
+
* @param {string} destination - Destination file path
|
|
127
|
+
* @returns {{ safe: boolean, error?: string }}
|
|
128
|
+
*/
|
|
129
|
+
validateMove(source, destination) {
|
|
130
|
+
const srcCheck = this.validate(source);
|
|
131
|
+
if (!srcCheck.safe) return { safe: false, error: `Source: ${srcCheck.error}` };
|
|
132
|
+
|
|
133
|
+
const dstCheck = this.validate(destination);
|
|
134
|
+
if (!dstCheck.safe) return { safe: false, error: `Destination: ${dstCheck.error}` };
|
|
135
|
+
|
|
136
|
+
return { safe: true };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Root/Sudo Kill-Switch ────────────────────────────────────────
|
|
141
|
+
// If running as root on Unix, exit immediately. FileMayor should
|
|
142
|
+
// NEVER have elevated privileges to limit blast radius.
|
|
143
|
+
function enforceUserSpace() {
|
|
144
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
145
|
+
console.error('❌ FILEMAYOR SECURITY ERROR: Running as root/sudo is strictly forbidden.');
|
|
146
|
+
console.error(' FileMayor is designed to run in User Space only.');
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { FileMayorJailer, enforceUserSpace, FORBIDDEN_ZONES };
|