Add controlled Reasonix local file tools
This commit is contained in:
@@ -23,12 +23,17 @@ import { createInterface } from 'node:readline';
|
||||
import { stdin, stdout } from 'node:process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
|
||||
const DEBUG = process.env.MNOTE_REASONIX_ACP_DEBUG === '1';
|
||||
const REASONIX_ALLOWED_FS_ROOTS = (process.env.REASONIX_ALLOWED_FS_ROOTS || '')
|
||||
.split(':')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map((value) => resolve(value));
|
||||
|
||||
function debugLog(message) {
|
||||
if (DEBUG) process.stderr.write(`${message}\n`);
|
||||
@@ -48,6 +53,64 @@ function stableIdPart(value, fallback) {
|
||||
return text || String(fallback || 'unknown');
|
||||
}
|
||||
|
||||
function isPathWithinRoot(targetPath, rootPath) {
|
||||
const normalizedRoot = rootPath.endsWith('/') ? rootPath : `${rootPath}/`;
|
||||
const normalizedTarget = targetPath.endsWith('/') ? targetPath : targetPath;
|
||||
return normalizedTarget === rootPath || normalizedTarget.startsWith(normalizedRoot);
|
||||
}
|
||||
|
||||
function assertAllowedFsPath(targetPath) {
|
||||
const resolved = resolve(targetPath);
|
||||
if (!REASONIX_ALLOWED_FS_ROOTS.length) {
|
||||
throw new Error(
|
||||
'reasonix local fs tools are disabled; set REASONIX_ALLOWED_FS_ROOTS to enable them',
|
||||
);
|
||||
}
|
||||
const matchedRoot = REASONIX_ALLOWED_FS_ROOTS.find((rootPath) => isPathWithinRoot(resolved, rootPath));
|
||||
if (!matchedRoot) {
|
||||
throw new Error(
|
||||
`path not allowed: ${resolved}. allowed roots: ${REASONIX_ALLOWED_FS_ROOTS.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function readAllowedFsFile(targetPath) {
|
||||
const resolved = assertAllowedFsPath(targetPath);
|
||||
return {
|
||||
path: resolved,
|
||||
content: readFileSync(resolved, 'utf-8'),
|
||||
};
|
||||
}
|
||||
|
||||
function writeAllowedFsFile(targetPath, content) {
|
||||
const resolved = assertAllowedFsPath(targetPath);
|
||||
writeFileSync(resolved, String(content), 'utf-8');
|
||||
return {
|
||||
path: resolved,
|
||||
written: true,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceAllowedFsFile(targetPath, search, replace) {
|
||||
const resolved = assertAllowedFsPath(targetPath);
|
||||
const current = readFileSync(resolved, 'utf-8');
|
||||
const next = String(current).split(String(search)).join(String(replace));
|
||||
if (next === current) {
|
||||
return {
|
||||
path: resolved,
|
||||
written: false,
|
||||
replaced: false,
|
||||
};
|
||||
}
|
||||
writeFileSync(resolved, next, 'utf-8');
|
||||
return {
|
||||
path: resolved,
|
||||
written: true,
|
||||
replaced: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
|
||||
const {
|
||||
actorId,
|
||||
@@ -383,6 +446,54 @@ tools.register({
|
||||
parallelSafe: false,
|
||||
});
|
||||
|
||||
if (REASONIX_ALLOWED_FS_ROOTS.length) {
|
||||
tools.register({
|
||||
name: 'reasonix_file_read',
|
||||
description: '读取允许根目录中的本地 UTF-8 文件。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '要读取的绝对路径文件' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
readOnly: true,
|
||||
fn: async (args) => readAllowedFsFile(args.path),
|
||||
parallelSafe: true,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'reasonix_file_write',
|
||||
description: '写入允许根目录中的本地 UTF-8 文件。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '要写入的绝对路径文件' },
|
||||
content: { type: 'string', description: '完整文件内容' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
fn: async (args) => writeAllowedFsFile(args.path, args.content),
|
||||
parallelSafe: false,
|
||||
});
|
||||
|
||||
tools.register({
|
||||
name: 'reasonix_file_replace',
|
||||
description: '对允许根目录中的本地 UTF-8 文件做字符串替换。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '要修改的绝对路径文件' },
|
||||
search: { type: 'string', description: '要查找的旧文本' },
|
||||
replace: { type: 'string', description: '要替换成的新文本' },
|
||||
},
|
||||
required: ['path', 'search', 'replace'],
|
||||
},
|
||||
fn: async (args) => replaceAllowedFsFile(args.path, args.search, args.replace),
|
||||
parallelSafe: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Session Store ────────────────────────────────────
|
||||
|
||||
const sessions = new Map();
|
||||
@@ -414,6 +525,9 @@ onRequest('session/new', async (params) => {
|
||||
'Use mnote_doc_fetch to read the current document.',
|
||||
'Use mnote_doc_markdown_edit to apply precise search/replace edits.',
|
||||
'Always use mnote_doc_fetch first to understand the document content before editing.',
|
||||
REASONIX_ALLOWED_FS_ROOTS.length
|
||||
? `Local filesystem tools are enabled for these roots: ${REASONIX_ALLOWED_FS_ROOTS.join(', ')}. Use reasonix_file_read, reasonix_file_replace, or reasonix_file_write for files inside those roots.`
|
||||
: 'Local filesystem tools are disabled.',
|
||||
].join('\n');
|
||||
|
||||
const loop = new CacheFirstLoop({
|
||||
|
||||
Reference in New Issue
Block a user