Files
ThearchyHelios db328efd21 原子写和部署脚本的几处(复核 R-a11、R-a12、R-a13 追加写)
- R-a12 create_bytes:硬链接不了的文件系统(exFAT、一部分 SMB / FUSE,EPERM / ENOTSUP / EXDEV)不再首次启动就崩,退回 O_CREAT | O_EXCL 直接建(仍然只建一次,说明里写了写的那一会儿读的一方可能读到不完整);建成后 fsync 目录(同 raw_store)
- R-a13 file_write 的追加改回原地追加写(O_APPEND,core/atomic.append_bytes):不换文件(inode、权限、开着的句柄不变),原有内容不动,文件大了也不用整份读出来重写;取舍写在函数说明里——读的一方最多看到半截新内容。覆盖照旧先写临时文件再改名,并沿用原文件的权限
- R-a11 deploy.sh 改属主前认数据目录收紧:只认 agentlab.db、.secret_key、.agentlab-data 三样之一(不再认 uploads/ 这种普通目录名);.agentlab-data 是服务启动时(Settings.ensure_dirs)建的标记,只建一次不覆盖,库在 PostgreSQL、密钥由环境变量给的部署也认得出;老部署有 agentlab.db 或 .secret_key。deploy/README 写明这个文件别删
- 测试:模拟不能硬链接仍只建一次、权限 0600、不留临时文件;覆盖沿用原权限;追加不换 inode、权限不变;file_write 追加不换文件、覆盖照常;数据目录里有标记、重复启动不覆盖
2026-10-19 12:07:31 +08:00

114 lines
3.8 KiB
Python

from __future__ import annotations
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
from app.core import atomic
from app.core.config import settings
from app.tools.registry import ToolContext, register
_MAX_READ = 200_000
def _resolve(rel: str, ctx: ToolContext) -> Path:
"""把相对路径锁死在这次运行自己的工作目录里。
两道关都要过:session_dir() 先净化 session_id 再定根目录,然后 rel
先 resolve 再比对根。少了第一道,`../../etc/passwd` 里的越界检查就是
在一个被挪到 /etc 的"根"下面做的,检查照样通过——这正是之前的漏洞。
"""
root = settings.session_dir(ctx.sandbox_session)
root.mkdir(parents=True, exist_ok=True)
target = (root / rel.lstrip("/")).resolve()
if target != root and root not in target.parents:
raise ValueError(f"路径越界:{rel}")
return target
class FileReadArgs(BaseModel):
path: str = Field(description="相对工作目录的文件路径")
@register(
name="file_read",
category="文件",
description="读取工作目录下的文件内容。",
args_schema=FileReadArgs,
)
async def file_read(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
args = FileReadArgs(**kwargs)
try:
target = _resolve(args.path, ctx)
except ValueError as e:
return {"error": str(e)}
if not target.exists():
return {"error": f"文件不存在:{args.path}"}
if target.is_dir():
return {"error": f"{args.path} 是目录,请使用 file_list"}
data = target.read_bytes()[:_MAX_READ]
return {
"path": args.path,
"size": target.stat().st_size,
"content": data.decode("utf-8", errors="replace"),
"truncated": target.stat().st_size > _MAX_READ,
}
class FileWriteArgs(BaseModel):
path: str = Field(description="相对工作目录的文件路径")
content: str = Field(description="要写入的内容")
append: bool = Field(default=False, description="追加而不是覆盖")
@register(
name="file_write",
category="文件",
description="写入文件到工作目录。父目录会自动创建。",
args_schema=FileWriteArgs,
dangerous=True,
)
async def file_write(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
args = FileWriteArgs(**kwargs)
try:
target = _resolve(args.path, ctx)
except ValueError as e:
return {"error": str(e)}
# 覆盖:先写临时文件再改名(core/atomic),读的一方看到的要么是旧的整份、要么是新的整份,权限照旧。追加:原地追加写
# (O_APPEND),不整份重写——文件的 inode、权限不变,原有内容不动,读的一方最多看到半截新内容
if args.append:
atomic.append_bytes(target, args.content.encode("utf-8"))
else:
atomic.write_bytes(target, args.content.encode("utf-8"))
return {"path": args.path, "bytes": target.stat().st_size, "ok": True}
class FileListArgs(BaseModel):
path: str = Field(default=".", description="要列出的目录")
@register(
name="file_list",
category="文件",
description="列出工作目录下的文件。",
args_schema=FileListArgs,
)
async def file_list(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
args = FileListArgs(**kwargs)
try:
target = _resolve(args.path, ctx)
except ValueError as e:
return {"error": str(e)}
if not target.exists():
return {"path": args.path, "entries": []}
entries = [
{
"name": p.name,
"type": "dir" if p.is_dir() else "file",
"size": p.stat().st_size if p.is_file() else None,
}
for p in sorted(target.iterdir())[:500]
]
return {"path": args.path, "entries": entries}