Current section
Files
Jump to
Current section
Files
lib/cmdc/sandbox/local.ex
defmodule CMDC.Sandbox.Local do
@moduledoc """
Sandbox 的本地 OS 默认实现,直接调用本地文件系统和 shell。
适合开发环境和受信任的执行场景。生产环境可替换为 Docker Sandbox 或其他隔离实现。
所有文件操作相对于 `:working_dir` 选项指定的目录执行。
## 路径安全防护(v0.6+ `:virtual_mode`,默认 true)
**v0.6 起 `:virtual_mode` 默认从 `false` → `true`**(唯一 minor breaking 改动),
拒绝以下"路径越界"操作以防止 Agent 误操作 / 注入攻击逃出 `working_dir`:
- path 含 `..` (`"../etc/passwd"`)
- path 以 `~` 开头 (`"~/.ssh/id_rsa"`)
- 绝对路径解析后不在 `working_dir` 内(`"/etc/passwd"` 而 working_dir=`/tmp`)
违反时返 `{:error, "路径越界(virtual_mode 防护...)"}` 字符串。`file_exists?/2`
在越界路径上返 `false`(行为类似不存在)。
## 显式 opt-out(v0.5 老代码迁移)
确实需要跨目录访问的合法场景(Agent 跨多 working_dir 操作 / 系统级文件读写 /
CLI 工具开发),显式传 `:virtual_mode false` 回退到 v0.5 行为:
{:ok, content} = CMDC.Sandbox.Local.read_file("/etc/hosts",
working_dir: "/tmp",
virtual_mode: false # 显式 opt-out,Logger.warning 1 次
)
Process 级别只会 warn 一次以避免日志风暴。**强烈建议**集成方先评估 working_dir
设置是否合理,再考虑 opt-out(如可重设 working_dir 为公共祖先目录则不需要回退)。
## 使用示例
{:ok, content} = CMDC.Sandbox.Local.read_file("lib/app.ex",
working_dir: "/path/to/project"
)
:ok = CMDC.Sandbox.Local.write_file("output.txt", "Hello",
working_dir: "/tmp"
)
{:ok, result} = CMDC.Sandbox.Local.execute("mix test",
working_dir: "/path/to/project",
timeout: 60_000
)
"""
require Logger
@behaviour CMDC.Sandbox
@default_timeout 30_000
@default_max_grep_results 100
@default_max_glob_results 500
@virtual_mode_warned_key {__MODULE__, :virtual_mode_disabled_warned}
# ==========================================================================
# 文件操作
# ==========================================================================
@impl true
def read_file(path, opts \\ []) do
working_dir = Keyword.get(opts, :working_dir, ".")
offset = Keyword.get(opts, :offset, 1)
limit = Keyword.get(opts, :limit, nil)
with {:ok, full_path} <- resolve_path(working_dir, path, opts) do
case File.read(full_path) do
{:ok, content} ->
result = apply_offset_limit(content, offset, limit)
{:ok, result}
{:error, :enoent} ->
{:error, "文件不存在: #{path}"}
{:error, :eacces} ->
{:error, "无权限读取文件: #{path}"}
{:error, reason} ->
{:error, "读取文件失败: #{inspect(reason)}"}
end
end
end
@impl true
def write_file(path, content, opts \\ []) do
working_dir = Keyword.get(opts, :working_dir, ".")
with {:ok, full_path} <- resolve_path(working_dir, path, opts),
:ok <- File.mkdir_p(Path.dirname(full_path)),
:ok <- File.write(full_path, content) do
:ok
else
{:error, :eacces} -> {:error, "无权限写入文件: #{path}"}
{:error, reason} when is_binary(reason) -> {:error, reason}
{:error, reason} -> {:error, "写入文件失败: #{inspect(reason)}"}
end
end
@impl true
def edit_file(path, old_string, new_string, opts \\ []) do
working_dir = Keyword.get(opts, :working_dir, ".")
with {:ok, full_path} <- resolve_path(working_dir, path, opts) do
case File.read(full_path) do
{:ok, content} ->
count = count_occurrences(content, old_string)
cond do
count == 0 ->
{:error, :not_found}
count > 1 ->
{:error, :not_unique}
true ->
new_content = String.replace(content, old_string, new_string, global: false)
write_replacement(full_path, new_content)
end
{:error, :enoent} ->
{:error, "文件不存在: #{path}"}
{:error, reason} ->
{:error, "读取文件失败: #{inspect(reason)}"}
end
end
end
@impl true
def list_dir(path, opts \\ []) do
working_dir = Keyword.get(opts, :working_dir, ".")
with {:ok, full_path} <- resolve_path(working_dir, path, opts) do
case File.ls(full_path) do
{:ok, entries} ->
dir_entries =
entries
|> Enum.sort()
|> Enum.map(&build_dir_entry(full_path, &1))
{:ok, dir_entries}
{:error, :enoent} ->
{:error, "目录不存在: #{path}"}
{:error, :enotdir} ->
{:error, "路径不是目录: #{path}"}
{:error, reason} ->
{:error, "列出目录失败: #{inspect(reason)}"}
end
end
end
defp build_dir_entry(base_path, name) do
entry_path = Path.join(base_path, name)
type = if File.dir?(entry_path), do: :directory, else: :file
size =
case File.stat(entry_path) do
{:ok, %{size: s}} -> s
_ -> nil
end
%{name: name, type: type, size: size}
end
defp write_replacement(path, content) do
case File.write(path, content) do
:ok -> {:ok, 1}
{:error, reason} -> {:error, "写入文件失败: #{inspect(reason)}"}
end
end
@impl true
def file_exists?(path, opts \\ []) do
working_dir = Keyword.get(opts, :working_dir, ".")
case resolve_path(working_dir, path, opts) do
{:ok, full_path} -> File.exists?(full_path)
# virtual_mode=true 时越界路径视为"不存在",避免泄漏文件系统结构
{:error, _} -> false
end
end
# ==========================================================================
# 搜索操作
# ==========================================================================
@impl true
def grep(pattern, path, opts \\ []) do
working_dir = Keyword.get(opts, :working_dir, ".")
include = Keyword.get(opts, :include, nil)
case_insensitive = Keyword.get(opts, :case_insensitive, false)
max_results = Keyword.get(opts, :max_results, @default_max_grep_results)
with {:ok, full_path} <- resolve_path(working_dir, path, opts) do
args = build_rg_args(pattern, full_path, include, case_insensitive)
case System.cmd("rg", args, stderr_to_stdout: false) do
{output, 0} ->
matches = parse_rg_output(output, max_results)
{:ok, matches}
{_output, 1} ->
{:ok, []}
{output, _code} ->
{:error, "grep 执行失败: #{output}"}
end
end
end
@impl true
def glob(pattern, path, opts \\ []) do
working_dir = Keyword.get(opts, :working_dir, ".")
max_results = Keyword.get(opts, :max_results, @default_max_glob_results)
with {:ok, full_path} <- resolve_path(working_dir, path, opts) do
glob_pattern = Path.join(full_path, pattern)
matches =
glob_pattern
|> Path.wildcard()
|> Enum.take(max_results)
|> Enum.map(fn p ->
type = if File.dir?(p), do: :directory, else: :file
relative = Path.relative_to(p, working_dir)
%{path: relative, type: type}
end)
{:ok, matches}
end
end
# ==========================================================================
# 命令执行
# ==========================================================================
@impl true
def execute(command, opts \\ []) do
working_dir = Keyword.get(opts, :working_dir, ".")
timeout = Keyword.get(opts, :timeout, @default_timeout)
env = Keyword.get(opts, :env, [])
shell = System.find_executable("bash") || "/bin/sh"
full_working_dir = Path.expand(working_dir)
task =
Task.async(fn ->
System.cmd(shell, ["-c", command],
cd: full_working_dir,
env: Enum.map(env, fn {k, v} -> {to_string(k), to_string(v)} end),
stderr_to_stdout: true
)
end)
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, {output, 0}} ->
{:ok, output}
{:ok, {output, exit_code}} ->
{:error, "命令退出码 #{exit_code}:\n#{output}"}
nil ->
{:error, "命令超时(#{timeout}ms)"}
end
end
# ==========================================================================
# 私有辅助
# ==========================================================================
# ==========================================================================
# 路径解析(v0.6+ virtual_mode 安全核心,默认 true)
# ==========================================================================
#
# virtual_mode=true(默认):
# - path 含 ".." → :path_outside_root
# - path 以 "~" 开头 → :path_outside_root
# - 解析后绝对路径必须在 working_dir 内 → :path_outside_root
#
# virtual_mode=false(显式 opt-out):
# - 老 v0.5 行为:absolute 直接用,relative join,0 防护
# - Process 级 Logger.warning 一次
defp resolve_path(working_dir, path, opts) do
virtual_mode = Keyword.get(opts, :virtual_mode, true)
if virtual_mode do
resolve_virtual(working_dir, path)
else
maybe_warn_virtual_mode_disabled()
{:ok, resolve_legacy(working_dir, path)}
end
end
defp resolve_virtual(working_dir, path) do
cond do
not is_binary(path) ->
{:error, "非法路径: #{inspect(path)}"}
String.contains?(path, "..") ->
{:error, virtual_mode_violation_msg(path)}
String.starts_with?(path, "~") ->
{:error, virtual_mode_violation_msg(path)}
true ->
full_path =
if Path.type(path) == :absolute do
Path.expand(path)
else
Path.expand(path, working_dir)
end
root = Path.expand(working_dir)
if path_within?(full_path, root) do
{:ok, full_path}
else
{:error, virtual_mode_violation_msg(path)}
end
end
end
defp resolve_legacy(working_dir, path) do
if Path.type(path) == :absolute do
path
else
Path.join(working_dir, path)
end
end
defp path_within?(full_path, root) do
full_path == root or String.starts_with?(full_path, root <> "/")
end
defp virtual_mode_violation_msg(path) do
"路径越界(virtual_mode 防护,请检查 .. / ~ / 跨目录绝对路径): #{path}"
end
defp maybe_warn_virtual_mode_disabled do
case Process.get(@virtual_mode_warned_key) do
true ->
:ok
_ ->
Logger.warning("""
[CMDC.Sandbox.Local] :virtual_mode 已被显式禁用,路径越界防护失效。
v0.7 计划保留该选项,但请尽量不依赖跨目录访问。
如需安全模式请移除 :virtual_mode false 或显式传 :virtual_mode true。
""")
Process.put(@virtual_mode_warned_key, true)
:ok
end
end
defp apply_offset_limit(content, offset, limit) do
lines = String.split(content, "\n")
start_idx = max(0, offset - 1)
sliced = Enum.drop(lines, start_idx)
sliced =
case limit do
nil -> sliced
n -> Enum.take(sliced, n)
end
Enum.join(sliced, "\n")
end
defp count_occurrences(content, substring) do
content
|> String.split(substring)
|> length()
|> Kernel.-(1)
end
defp build_rg_args(pattern, path, include, case_insensitive) do
args = ["--line-number", "--no-heading", "--with-filename", pattern, path]
args =
case include do
nil -> args
glob -> ["--glob", glob | args]
end
if case_insensitive, do: ["--ignore-case" | args], else: args
end
defp parse_rg_output(output, max_results) do
output
|> String.split("\n")
|> Enum.reject(&(&1 == ""))
|> Enum.take(max_results)
|> Enum.flat_map(&parse_rg_line/1)
end
defp parse_rg_line(line) do
case String.split(line, ":", parts: 3) do
[file, line_str, content] ->
case Integer.parse(line_str) do
{line_num, ""} ->
[%{file: file, line: line_num, content: content}]
_ ->
[]
end
_ ->
[]
end
end
end