97 lines
3.9 KiB
JavaScript
97 lines
3.9 KiB
JavaScript
import path from "path";
|
||
import { FilesystemBackend } from "deepagents";
|
||
|
||
/**
|
||
* 受限文件系统后端:在 FilesystemBackend 基础上收紧 LLM 文件工具的访问范围。
|
||
*
|
||
* 相比原生非 virtualMode 模式的两个安全缺口:
|
||
* 1. 原生 resolvePath 对绝对路径原样放行(可读磁盘任意文件),此处强制锁定在 rootDir 内;
|
||
* 2. 沙箱根下的敏感文件(conf.json、.env*、logs、dataDir)默认禁止访问,
|
||
* 防止 LLM 通过 read_file/grep 读取密钥、数据库连接串及其他用户记忆。
|
||
*
|
||
* 注意:memory(AGENTS.md)与 skills(SKILL.md)也经由 backend 加载,
|
||
* 均不在 deny 列表中,不受影响。
|
||
*
|
||
* 注:1.11+ 的 createDeepAgent permissions 机制对真实磁盘后端不适用——
|
||
* 非虚拟模式下 ls/glob/grep 结果是 Windows 绝对路径,而权限规则强制以 "/" 开头,
|
||
* 永远匹配不上(grep 会泄露 conf.json 内容)。敏感路径拦截必须保持在后端层实现。
|
||
*/
|
||
export class RestrictedFilesystemBackend extends FilesystemBackend {
|
||
constructor(options = {}) {
|
||
super(options);
|
||
this.denyPaths = (options.deny ?? ["conf.json", "logs"]).map(d => String(d).toLowerCase().replace(/\/+$/, ""));
|
||
}
|
||
|
||
// 判断已解析的绝对路径是否命中 deny 列表(目录条目连同其子路径一并命中)
|
||
_isDenied(fullPath) {
|
||
const rel = path.relative(this.cwd, fullPath);
|
||
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
|
||
const norm = rel.split(path.sep).join("/").toLowerCase();
|
||
// 根目录下的 .env / .env.local 等
|
||
if (norm === ".env" || norm.startsWith(".env.")) return true;
|
||
return this.denyPaths.some(d => norm === d || norm.startsWith(d + "/"));
|
||
}
|
||
|
||
/**
|
||
* 覆写路径解析:一律锁定在 rootDir 内。
|
||
* - "/" 或空串视为沙箱根(LLM 工具以 / 为根)
|
||
* - 相对路径按 rootDir 解析
|
||
* - 绝对路径仅接受位于 rootDir 内的(非 virtualMode 的 ls/glob 返回的是绝对路径,需支持回传)
|
||
* - 禁止 .. 与 ~ 穿越
|
||
* - 命中 deny 列表直接抛错
|
||
*/
|
||
resolvePath(key) {
|
||
const k = String(key);
|
||
if (k.includes("..") || k.startsWith("~")) {
|
||
throw new Error(`Path traversal not allowed: ${key}`);
|
||
}
|
||
let full;
|
||
if (/^\/+$/.test(k)) {
|
||
full = this.cwd;
|
||
} else if (path.isAbsolute(k)) {
|
||
full = path.resolve(k);
|
||
} else {
|
||
full = path.resolve(this.cwd, k);
|
||
}
|
||
const rel = path.relative(this.cwd, full);
|
||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||
throw new Error(`Path outside sandbox root: ${key}`);
|
||
}
|
||
if (this._isDenied(full)) {
|
||
throw new Error(`Access denied: ${key}`);
|
||
}
|
||
return full;
|
||
}
|
||
|
||
// ls/glob/grep 需过滤结果,避免泄露敏感文件的存在性与内容
|
||
async ls(dirPath) {
|
||
const result = await super.ls(dirPath);
|
||
return { files: (result.files ?? []).filter(f => !this._isDenied(f.path)) };
|
||
}
|
||
|
||
async glob(pattern, searchPath = "/") {
|
||
const result = await super.glob(pattern, searchPath);
|
||
return { files: (result.files ?? []).filter(f => !this._isDenied(f.path)) };
|
||
}
|
||
|
||
async grep(pattern, dirPath = "/", globFilter = null, maxCount = null) {
|
||
const result = await super.grep(pattern, dirPath, globFilter, maxCount);
|
||
return { ...result, matches: (result.matches ?? []).filter(m => !this._isDenied(m.path)) };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建 bot 专用的受限后端,自动把 dataDir(conf.json agent.dataDir)加入 deny 列表。
|
||
* dataDir 位于沙箱根之外时无需 deny(沙箱本身不可达)。
|
||
*/
|
||
export function createRestrictedBackend({ rootDir, dataDir }) {
|
||
const deny = ["conf.json", "logs"];
|
||
if (dataDir) {
|
||
const rel = path.relative(path.resolve(rootDir), path.resolve(dataDir));
|
||
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
|
||
deny.push(rel.split(path.sep).join("/"));
|
||
}
|
||
}
|
||
return new RestrictedFilesystemBackend({ rootDir, deny });
|
||
}
|