Source:
docs/dev-guides/8-path-safety.md(formal development standards) +design-docs/issues/002-arbitrary-file-read-audit.md(security audit review). This document distills 6 coding standards that must be followed. Violating any one of them may introduce a path traversal (LFI) vulnerability.
R1. File download: use ConfinedDir.resolve() + send_file, do not use send_from_directory
Reason:send_from_directory(dir, user_input) internally performs a user_input second path resolution, creating a TOCTOU inconsistency with the preceding security check.
# ❌ BAD — 安全检查用 resolved target,发送用原始 filename,两次解析不一致
target = (scratch_dir / filename).resolve()
target.relative_to(scratch_dir.resolve()) # 检查通过
return send_from_directory(str(scratch_dir), filename) # 再次解析
✅ GOOD — check and send use the same resolved path
scratch_jail = workspace.confined_scratch target = scratch_jail.resolve(filename) return send_file(target) # directly use the already-validated path
`send_file(Path)` 会根据扩展名自动推断 MIME type,无需额外处理。
---
## R2. 路径安全检查:用 `ConfinedDir`,禁止 `str.startswith`
**原因**:`str(path).startswith(str(root))` 存在前缀碰撞缺陷(如 `/workspace` vs `/workspace_evil`)。
```python
# ❌ BAD
if not str(resolved).startswith(str(root_resolved) + os.sep):
raise ValueError("escape")
# ✅ GOOD — 统一走 ConfinedDir,内部使用 Path.is_relative_to()
jail = ConfinedDir(root_resolved, mkdir=False)
target = jail.resolve(user_input)
R3. Agent tools reuse Workspace.confined_*
Reason: Agent tool parameters are generated by the LLM and must be treated as indirect user input. Do not hand-write Path(root) / rel_path or resolve() + relative_to()inside the tool; at the entry point reuse the ConfinedDir。
# ❌ BAD — 手写路径拼接和校验
def _tool_read_file(self, args, workspace_path):
target = (workspace_path / rel_path).resolve()
target.relative_to(workspace_path)
# ✅ GOOD — 入口拿到 ConfinedDir,工具只调用 jail.resolve()
def _execute_tool(self, name, args):
workspace_jail = self.workspace.confined_root
scratch_jail = self.workspace.confined_scratch
return self._tool_read_file(args, workspace_jail)
def _tool_read_file(self, args, workspace_jail):
target = workspace_jail.resolve(args.get("path", ""))
R4. Prefer ConfinedDir, prohibit bare path concatenation
Reason:Path(root) / user_input is a high-frequency entry point for path traversal.ConfinedDir encapsulates three layers of defense (reject absolute paths → reject .. segments → resolve + is_relative_to)。
from data_formulator.security.path_safety import ConfinedDir
# ❌ BAD — 手动拼接 + 手动校验,容易遗漏
local_file = tmp_path / blob_relative_name
local_file.parent.mkdir(parents=True, exist_ok=True)
local_file.write_bytes(data)
# ✅ GOOD — ConfinedDir 自动校验 + 创建父目录
jail = ConfinedDir(tmp_path, mkdir=False)
jail.write(blob_relative_name, data) # 自动校验 + 写入
Hierarchical relationship of existing secure APIs
用户输入(filename / relative_path / blob key)
│
▼
safe_data_filename() / secure_filename() ← 第一层:输入清洗
│
▼
ConfinedDir.resolve() ← 第二层:路径约束
│
▼
安全的 Path 对象
Scenarios that use Workspace.get_file_path() do not need to manually call ConfinedDir, because it already includes equivalent validation internally.
R5. Host filesystem access must have a deployment mode guard
Reason: Desktop single-user mode allows access to the local filesystem (expected behavior), but under multi-user/cloud deployment it is equivalent to opening server read permissions.
Standard: Any new Loader / Connector that involves directly reading the host filesystem (not through the Workspace API) must:
- In
data_loader/__init__.py's_enforce_deployment_restrictions(), register a disable rule - Ensure
create_connector()rejects disabled types
# data_loader/__init__.py — 参考 local_folder 的处理方式
def _enforce_deployment_restrictions():
backend = os.environ.get("WORKSPACE_BACKEND", "local")
if backend != "local":
for key in ("local_folder", "your_new_local_loader"):
if key in DATA_LOADERS:
del DATA_LOADERS[key]
DISABLED_LOADERS[key] = f"{key} disabled in multi-user mode"
Criterion: If the Loader's constructor accepts a user-controllable local path (such as root_dir), it needs a deployment guard.
R6. Multi-user deployment must enable the sandbox
Reason:not_a_sandbox mode, LLM-generated code executes directly in the host process and can bypass all path checks.
app.py already detects this configuration at startup and outputs a logger.critical warning. New sandbox modes or deployment scripts should ensure:
WORKSPACE_BACKEND != "local"when,SANDBOXmust bedockerorlocal- CI/CD deployment templates set by default
SANDBOX=docker
Quick reference: security checklist when adding new code
| Scenario | What must be done |
|---|---|
| Add a new file download route | Use ConfinedDir.resolve() to get the path, then send_file(resolved_path); do not use send_from_directory |
| Add a new Agent tool (read file/list directory) | At the entry point reuse workspace.confined_root / workspace.confined_scratch, inside the tool only call jail.resolve() |
| Path containment check | Use ConfinedDir.resolve(), do not hand-write Path.is_relative_to() or str.startswith() |
Path(root) / variable mode |
Switch to ConfinedDir or Workspace.get_file_path() |
| Add local filesystem Loader | In _enforce_deployment_restrictions() register multi-user disable |
| Deployment configuration | Multi-user mode must SANDBOX=docker or SANDBOX=local |
Reference documentation
docs/dev-guides/8-path-safety.md— Server-side path security development specificationdesign-docs/6-path-safety-confined-dir.md— Remaining unfinished implementation items status pagedesign-docs/issues/002-arbitrary-file-read-audit.md— Security audit review reportpy-src/data_formulator/security/path_safety.py— ConfinedDir source code