欢迎光临
我们一直在努力

WorkBuddy_新建任务与工作模式2_工作目录与权限边界

工作目录与权限边界

摘要

在构建 AI 编码助手、自动化脚本或 Agent 系统时,"让 AI 在指定文件夹内工作"看似只是一个配置项,实际上涉及路径规范化、沙箱隔离、符号链接逃逸、并发写入冲突等一系列工程问题。本文从工作目录(Working Directory)的概念出发,系统讲解如何为 AI 任务指定授权文件夹,如何建立可靠的权限边界,并给出可直接运行的 Python 实现,涵盖路径校验、沙箱封装、审计日志与最小权限原则的落地方式。

引言

很多开发者第一次给 AI 助手配置目录时,做法是这样的:

# 危险示范:直接把用户输入当作工作目录
workdir = input("请输入工作目录:")
os.chdir(workdir)

这段代码在演示环境里能跑,但一旦上线就会暴露三类问题:

  • 越界访问:AI 生成的代码路径可能是 ../../etc/passwd,直接突破你期望的目录范围。
  • 符号链接逃逸:目录内一个指向外部的软链接,会让"看起来在沙箱内"的路径实际落到沙箱外。
  • 权限过宽:AI 只需要读写某个项目目录,却继承了进程的全部权限(包括删除、执行、访问家目录)。
  • 因此,"指定文件夹"不是一句 os.chdir,而是一套工作目录 + 权限边界的设计。本文的目标是把这个设计讲清楚,并给出可复用的代码。

    正文

    一、工作目录到底是什么:三个容易混淆的概念

    在讨论权限之前,先把三个概念分清,否则后面的边界设计会自相矛盾。

    概念含义典型用途
    进程工作目录(CWD) 进程启动时的当前目录,影响相对路径解析 决定 open("a.txt") 落在哪里
    任务授权目录(Workspace Root) 业务上允许 AI 操作的最大范围 权限校验的基准
    沙箱根(Sandbox Root) 实际隔离环境中的挂载点 容器/chroot 内的可见根目录

    关键点:CWD 不等于授权目录。CWD 只是相对路径的解析起点,攻击者可以通过 ../ 跳出 CWD。真正的边界必须由"授权目录"来定义,并且每次文件操作都要做校验。

    一个常见的错误认知是"chdir 到目标目录就安全了"。实际上:

    import os

    os.chdir("/home/user/project")
    # 下面这行依然可以访问授权范围之外的文件
    open("../../.ssh/id_rsa").read()

    chdir 只改变解析起点,不提供任何限制。所以我们需要显式的路径校验层。

    二、路径规范化:权限校验的第一步

    校验一个路径是否在授权目录内,不能简单用字符串 startswith,因为存在这些问题:

    • .. 未展开:/workspace/../etc 字符串上以 /workspace 开头,实际指向 /etc
    • 符号链接:/workspace/link 可能指向 /etc
    • 大小写与分隔符差异(Windows)
    • 相对路径与绝对路径混用

    正确做法是先用 os.path.realpath(或 pathlib.Path.resolve)把路径规范化,再做前缀比较,并且比较时要带上路径分隔符,避免 /workspace2 被误判为 /workspace 的子路径。

    import os
    from pathlib import Path

    class PathGuard:
    """负责校验路径是否落在授权目录内。"""

    def __init__(self, workspace_root: str):
    # 授权目录本身也要规范化,防止传入带 .. 或软链接的路径
    self.root = Path(workspace_root).resolve(strict=True)
    if not self.root.is_dir():
    raise ValueError(f"授权目录不存在或不是目录: {workspace_root}")

    def resolve(self, user_path: str) –> Path:
    """
    将用户提供的路径解析为绝对路径,并校验是否越界。
    越界时抛出 PermissionError。
    """

    # 相对路径以授权目录为基准,而不是进程 CWD
    candidate = Path(user_path)
    if not candidate.is_absolute():
    candidate = self.root / candidate

    # realpath 会展开 .. 与符号链接,得到真实路径
    real = candidate.resolve(strict=False)

    # 关键:用 os.path.commonpath 判断包含关系,避免前缀误判
    try:
    common = os.path.commonpath([str(self.root), str(real)])
    except ValueError:
    # 不同盘符(Windows)会抛 ValueError,直接视为越界
    raise PermissionError(f"路径不在授权目录内: {user_path}")

    if common != str(self.root):
    raise PermissionError(f"路径不在授权目录内: {user_path}")

    return real

    测试一下它的行为:

    guard = PathGuard("/tmp/workspace")

    # 正常路径
    print(guard.resolve("src/main.py")) # /tmp/workspace/src/main.py

    # 越界路径
    try:
    guard.resolve("../../etc/passwd")
    except PermissionError as e:
    print("拦截:", e) # 拦截: 路径不在授权目录内

    # 前缀相似的兄弟目录
    try:
    guard.resolve("/tmp/workspace2/secret")
    except PermissionError as e:
    print("拦截:", e) # 拦截: 路径不在授权目录内

    注意 resolve(strict=False) 的取舍:对于"即将创建的新文件",目标还不存在,strict=True 会抛异常。但 strict=False 会保留不存在的尾部路径,同时对已存在的部分做软链接展开,这正是我们需要的语义。

    三、符号链接逃逸:最容易被忽视的漏洞

    即使做了 realpath 校验,仍有一个时间窗口问题(TOCTOU):校验时路径合法,使用前被人替换成软链接。更直接的场景是:授权目录内本来就存在指向外部的软链接。

    # 假设 /tmp/workspace/evil 是指向 /etc 的软链接
    # resolve 会把它展开成 /etc,从而被 commonpath 拦截 —— 这是正确的

    但如果你的校验逻辑只检查"路径字符串以 root 开头",就会漏掉。所以必须用 realpath 后的结果做比较,而不是原始输入。

    对于高安全要求的场景,还应在打开文件时使用 O_NOFOLLOW 防止最后一级是软链接:

    import os

    def safe_open_read(guard: PathGuard, user_path: str) –> bytes:
    """以只读方式安全打开文件,拒绝最后一级软链接。"""
    real = guard.resolve(user_path)

    # O_NOFOLLOW: 如果 real 本身是软链接则失败
    # 注意 real 已被 resolve 展开,这里主要防御 TOCTOU 竞态
    fd = os.open(real, os.O_RDONLY | os.O_NOFOLLOW)
    try:
    with os.fdopen(fd, "rb") as f:
    return f.read()
    except Exception:
    os.close(fd)
    raise

    需要说明的是,O_NOFOLLOW 只作用于路径的最后一级,中间目录的软链接仍需依赖 realpath 校验。两者结合才能覆盖大部分场景。若要彻底杜绝竞态,需要 openat2(Linux 5.6+)配合 RESOLVE_BENEATH 标志,这已超出本文范围,但值得在生产环境中评估。

    四、构建任务级沙箱:把边界封装成对象

    单点校验容易遗漏。更好的工程实践是把"授权目录 + 允许的操作"封装成一个沙箱对象,AI 只能通过这个对象访问文件系统,从 API 层面杜绝直接调用 open。

    import os
    import shutil
    from pathlib import Path
    from typing import Iterable

    class WorkspaceSandbox:
    """
    任务级沙箱:所有文件操作都必须经过这里。
    通过白名单控制可执行的操作类型。
    """

    ALLOWED_OPS = {"read", "write", "list", "mkdir", "delete"}

    def __init__(self, root: str, allowed_ops: Iterable[str] = ("read", "list")):
    self.guard = PathGuard(root)
    self.allowed_ops = set(allowed_ops)
    unknown = self.allowed_ops – self.ALLOWED_OPS
    if unknown:
    raise ValueError(f"未知操作: {unknown}")

    def _check_op(self, op: str):
    if op not in self.allowed_ops:
    raise PermissionError(f"操作未授权: {op}")

    def read(self, path: str) –> str:
    self._check_op("read")
    real = self.guard.resolve(path)
    if not real.is_file():
    raise FileNotFoundError(path)
    return real.read_text(encoding="utf-8")

    def write(self, path: str, content: str) –> None:
    self._check_op("write")
    real = self.guard.resolve(path)
    real.parent.mkdir(parents=True, exist_ok=True)
    # 先写临时文件再原子替换,避免并发写入产生半截文件
    tmp = real.with_suffix(real.suffix + ".tmp")
    tmp.write_text(content, encoding="utf-8")
    os.replace(tmp, real)

    def list_dir(self, path: str = ".") –> list[str]:
    self._check_op("list")
    real = self.guard.resolve(path)
    if not real.is_dir():
    raise NotADirectoryError(path)
    return sorted(p.name for p in real.iterdir())

    def mkdir(self, path: str) –> None:
    self._check_op("mkdir")
    real = self.guard.resolve(path)
    real.mkdir(parents=True, exist_ok=True)

    def delete(self, path: str) –> None:
    self._check_op("delete")
    real = self.guard.resolve(path)
    # 禁止删除授权根目录本身
    if real == self.guard.root:
    raise PermissionError("不允许删除授权根目录")
    if real.is_dir():
    shutil.rmtree(real)
    elif real.exists():
    real.unlink()

    使用示例,模拟一个只读任务:

    # 只读任务:AI 只能读取和列目录
    sandbox = WorkspaceSandbox("/tmp/workspace", allowed_ops=["read", "list"])

    print(sandbox.list_dir("."))
    print(sandbox.read("README.md"))

    try:
    sandbox.write("hack.txt", "x")
    except PermissionError as e:
    print("拦截:", e) # 拦截: 操作未授权: write

    这种设计的价值在于:权限边界从"约定"变成了"类型系统的一部分"。AI 生成的代码即便想越权,也必须显式调用未授权的方法,从而在日志和异常中留下痕迹。

    五、最小权限与审计:让边界可观测

    权限边界不仅要"挡得住",还要"看得见"。建议至少做两件事:

    1. 审计日志:记录每一次路径解析与操作尝试,包括被拒绝的请求。被拒绝的请求往往是最有价值的安全信号。

    import json
    import logging
    import time

    audit_logger = logging.getLogger("workspace.audit")
    audit_logger.setLevel(logging.INFO)

    handler = logging.FileHandler("/tmp/workspace_audit.log", encoding="utf-8")
    handler.setFormatter(logging.Formatter("%(message)s"))
    audit_logger.addHandler(handler)

    def audit(op: str, user_path: str, result: str, detail: str = ""):
    record = {
    "ts": time.time(),
    "op": op,
    "path": user_path,
    "result": result, # "allow" / "deny"
    "detail": detail,
    }
    audit_logger.info(json.dumps(record, ensure_ascii=False))

    把审计接入沙箱的校验入口:

    class AuditedSandbox(WorkspaceSandbox):
    def _check_op(self, op: str):
    try:
    super()._check_op(op)
    audit(op, "-", "allow")
    except PermissionError as e:
    audit(op, "-", "deny", str(e))
    raise

    def read(self, path: str) –> str:
    try:
    data = super().read(path)
    audit("read", path, "allow")
    return data
    except PermissionError as e:
    audit("read", path, "deny", str(e))
    raise

    2. 最小权限:默认只给 read 和 list,只有明确需要修改的任务才追加 write、mkdir、delete。在操作系统层面也应遵循同样原则——运行 AI 任务的进程不要用 root,最好用专用用户,并通过容器或 chroot 把可见文件系统进一步收窄。

    # 按任务类型分配权限
    TASK_POLICIES = {
    "code_review": ["read", "list"],
    "refactor": ["read", "list", "write", "mkdir"],
    "cleanup": ["read", "list", "delete"],
    }

    def build_sandbox(task_type: str, root: str) –> AuditedSandbox:
    ops = TASK_POLICIES.get(task_type)
    if ops is None:
    raise ValueError(f"未知任务类型: {task_type}")
    return AuditedSandbox(root, allowed_ops=ops)

    这样,“新建任务时指定文件夹"就变成了一个明确的策略选择:选目录 + 选任务类型 → 得到确定的权限集合,而不是一个模糊的"给个路径就能随便操作”。

    六、并发与清理:边界之外的工程细节

    当多个 AI 任务共享同一授权目录时,还有两个细节需要注意:

    • 并发写入:上文 write 使用临时文件 + os.replace 原子替换,保证读方不会读到半截内容。若需更严格的互斥,可在任务级加文件锁。
    • 临时目录隔离:每个任务使用独立的子目录,任务结束后清理,避免不同任务互相污染。

    import tempfile

    def run_task(task_type: str, base_root: str):
    """为单个任务创建隔离子目录,任务结束后自动清理。"""
    task_dir = tempfile.mkdtemp(prefix="task_", dir=base_root)
    try:
    sandbox = build_sandbox(task_type, task_dir)
    # … 将 sandbox 交给 AI 执行 …
    return sandbox
    finally:
    shutil.rmtree(task_dir, ignore_errors=True)

    注意 mkdtemp 创建的目录权限默认是 0700,只有当前用户可访问,这本身就是一层额外的保护。

    总结

    "新建任务时指定文件夹"这件事,拆开看是三个层次:

  • 路径层:用 realpath 规范化,用 commonpath 做包含判断,杜绝 .. 与前缀误判。
  • API 层:把授权目录封装成沙箱对象,用操作白名单把权限边界变成代码约束,而非口头约定。
  • 运维层:按任务类型分配最小权限、记录审计日志、隔离临时目录、以非特权用户运行。
  • 核心原则只有一条:边界必须由代码强制,而不是由调用方的自觉保证。AI 生成的代码不会"心怀恶意",但它会诚实地执行你给它的路径——所以路径校验和权限收窄,必须发生在它拿到句柄之前。

    文中给出的 PathGuard、WorkspaceSandbox、AuditedSandbox 三个类可以直接组合使用,也可以按需裁剪。如果你的场景涉及容器化部署,建议在此之上再叠加一层文件系统级隔离(容器挂载、chroot、openat2),形成纵深防御。

    赞(0)
    未经允许不得转载:171主机测评 » WorkBuddy_新建任务与工作模式2_工作目录与权限边界
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址