From 16f00a5e820050a64005ca52cbf074f91b806484 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 19 May 2026 12:29:25 +0800 Subject: [PATCH] Add controlled Reasonix local file tools --- scripts/reasonix-acp-wrapper.mjs | 118 ++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/scripts/reasonix-acp-wrapper.mjs b/scripts/reasonix-acp-wrapper.mjs index 34aa6105..ec4389c0 100644 --- a/scripts/reasonix-acp-wrapper.mjs +++ b/scripts/reasonix-acp-wrapper.mjs @@ -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({