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` 选项指定的目录执行。
## 使用示例
{: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
)
"""
@behaviour CMDC.Sandbox
@default_timeout 30_000
@default_max_grep_results 100
@default_max_glob_results 500
# ==========================================================================
# 文件操作
# ==========================================================================
@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)
full_path = resolve_path(working_dir, path)
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
@impl true
def write_file(path, content, opts \\ []) do
working_dir = Keyword.get(opts, :working_dir, ".")
full_path = resolve_path(working_dir, path)
with :ok <- File.mkdir_p(Path.dirname(full_path)),
:ok <- File.write(full_path, content) do
:ok
else
{:error, :eacces} -> {:error, "无权限写入文件: #{path}"}
{: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, ".")
full_path = resolve_path(working_dir, path)
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
@impl true
def list_dir(path, opts \\ []) do
working_dir = Keyword.get(opts, :working_dir, ".")
full_path = resolve_path(working_dir, path)
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
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, ".")
full_path = resolve_path(working_dir, path)
File.exists?(full_path)
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)
full_path = resolve_path(working_dir, path)
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
@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)
full_path = resolve_path(working_dir, path)
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
# ==========================================================================
# 命令执行
# ==========================================================================
@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
# ==========================================================================
# 私有辅助
# ==========================================================================
defp resolve_path(working_dir, path) do
if Path.type(path) == :absolute do
path
else
Path.join(working_dir, path)
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