diff --git a/README.md b/README.md index eeae296..68088e7 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ observations the entity reads as data: - `list_dir(%{path})` — list a directory under `:root` - `search(%{pattern, path})` — regex search returning `%{path, line, text}` matches -- `mix(%{task, args})` — run an allowlisted Mix task under `:root` +- `mix(%{task, args})` — run an explicitly allowlisted Mix task under `:root` - `compile_and_load(%{module, source})` — compile and hot-load a module (opt-in via `evolve: true` on the Familiar) diff --git a/docs/public-api.md b/docs/public-api.md index 817992c..bfd5cd2 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -226,7 +226,7 @@ Familiar only includes `compile_and_load` when constructed with `evolve: true`. Wards are maps. Common wards include: - `%{max_turns: n}` -- `%{allow_mix_tasks: ["compile", "format"]}` +- `%{allow_mix_tasks: ["test"]}` (empty by default; opt into each task explicitly) - `%{mix_timeout_ms: 60_000}` - `%{mix_max_output_bytes: 50_000}` - `%{max_depth: n}` @@ -243,6 +243,11 @@ Wards are maps. Common wards include: - `%{allow_compile_paths: paths}` - `%{allow_compile_signers: signers}` +The Familiar starts with no Mix task allowlist; set `allow_mix_tasks` (or +`run_tests: true`) only for workspaces you trust. Mix child processes receive +a minimal environment; pass task-specific variables through the gate's `env` +argument. + `compile_and_load` accepts exact module allowlists via `allow_compile_modules`. Deprecated `allow_compile_namespaces` wards are rejected loudly, and framework module names are not hot-loadable. diff --git a/lib/cantrip/familiar.ex b/lib/cantrip/familiar.ex index d3734cc..cd27f64 100644 --- a/lib/cantrip/familiar.ex +++ b/lib/cantrip/familiar.ex @@ -78,9 +78,9 @@ defmodule Cantrip.Familiar do read_file.(%{path: "README.md"}) search.(%{pattern: "defmodule", path: "lib"}) - Use `done.(value)` to finish the cast. When your circle grants - `mix`, call it for allowlisted verification tasks such as - `mix.(%{task: "compile"})`; do not assume arbitrary shell access. + Use `done.(value)` to finish the cast. When your circle grants `mix` and + its ward explicitly allows a verification task, call it for that task such + as `mix.(%{task: "test"})`; do not assume arbitrary shell access. Read directly when one file answers the next question. Spawn reader children when the work benefits from separate context, narrower @@ -254,8 +254,7 @@ defmodule Cantrip.Familiar do * `:run_tests` — include `test` in the Familiar's default Mix task allowlist (default: `false`) * `:allow_mix_tasks` — override the Familiar's Mix task allowlist - (default: `["compile", "format"]`, plus `"test"` when `:run_tests` - is true) + (default: `[]`, plus `"test"` when `:run_tests` is true) * `:system_prompt` — override the default system prompt (optional) * `:sandbox` — `:unrestricted` (default) runs Familiar code in the host BEAM for trusted operator-local work, so native Elixir affordances such @@ -424,8 +423,8 @@ defmodule Cantrip.Familiar do defp default_sandbox(nil), do: :unrestricted defp default_sandbox(_port_runner), do: :port - defp default_mix_tasks(true), do: ["compile", "format", "test"] - defp default_mix_tasks(false), do: ["compile", "format"] + defp default_mix_tasks(true), do: ["test"] + defp default_mix_tasks(false), do: [] # Mnesia table names are atoms, so derive a short fixed-shape name from # a hash instead of embedding user-controlled path text in the atom. diff --git a/lib/cantrip/gate/mix.ex b/lib/cantrip/gate/mix.ex index 4b80fad..720d6fe 100644 --- a/lib/cantrip/gate/mix.ex +++ b/lib/cantrip/gate/mix.ex @@ -5,6 +5,7 @@ defmodule Cantrip.Gate.Mix do @default_timeout_ms 60_000 @default_max_output_bytes 50_000 + @child_env_allowlist ~w(HOME PATH LANG LC_ALL TERM USER) @spec execute(Cantrip.Gate.Args.Mix.t() | map() | String.t(), list(map()), map()) :: map() def execute(args, wards, gate) when not is_struct(args, Cantrip.Gate.Args.Mix) do @@ -142,7 +143,7 @@ defmodule Cantrip.Gate.Mix do :stderr_to_stdout, {:args, [task | args]}, {:cd, cwd}, - {:env, env} + {:env, child_env(env)} ]) await_port( @@ -208,6 +209,32 @@ defmodule Cantrip.Gate.Mix do end end + # Port environments extend the BEAM environment. Clear every inherited + # variable first, then restore only non-secret process essentials and the + # caller's explicit task environment. + defp child_env(env) do + parent_env = System.get_env() + allowlist = MapSet.new(@child_env_allowlist) + explicit_names = MapSet.new(env, fn {name, _value} -> List.to_string(name) end) + + cleared = + parent_env + |> Enum.reject(fn {name, _value} -> MapSet.member?(allowlist, name) end) + |> Enum.map(fn {name, _value} -> {String.to_charlist(name), false} end) + + essentials = + @child_env_allowlist + |> Enum.reject(&MapSet.member?(explicit_names, &1)) + |> Enum.flat_map(fn name -> + case Map.get(parent_env, name) do + nil -> [] + value -> [{String.to_charlist(name), String.to_charlist(value)}] + end + end) + + cleared ++ essentials ++ env + end + defp positive_ward(wards, key, default) do case Cantrip.WardPolicy.get(wards, key, default) do value when is_integer(value) and value > 0 -> value diff --git a/test/familiar_test.exs b/test/familiar_test.exs index 0d764f5..cdfaabe 100644 --- a/test/familiar_test.exs +++ b/test/familiar_test.exs @@ -55,21 +55,14 @@ defmodule Cantrip.FamiliarTest do assert "mix" in Map.keys(cantrip.circle.gates) - assert Cantrip.WardPolicy.get(cantrip.circle.wards, :allow_mix_tasks) == [ - "compile", - "format" - ] + assert Cantrip.WardPolicy.get(cantrip.circle.wards, :allow_mix_tasks) == [] end test "run_tests opts into the mix test task" do llm = {FakeLLM, FakeLLM.new([])} {:ok, cantrip} = Familiar.new(llm: llm, root: System.tmp_dir!(), run_tests: true) - assert Cantrip.WardPolicy.get(cantrip.circle.wards, :allow_mix_tasks) == [ - "compile", - "format", - "test" - ] + assert Cantrip.WardPolicy.get(cantrip.circle.wards, :allow_mix_tasks) == ["test"] end test "allow_mix_tasks overrides the familiar mix allowlist" do @@ -138,7 +131,7 @@ defmodule Cantrip.FamiliarTest do assert prompt =~ "read_file.(%{path: \"README.md\"})" assert prompt =~ "search.(%{pattern: \"defmodule\", path: \"lib\"})" assert prompt =~ "When your circle grants" - assert prompt =~ "mix.(%{task: \"compile\"})" + assert prompt =~ "mix.(%{task: \"test\"})" assert prompt =~ "do not assume arbitrary shell access" assert prompt =~ "choose the answer shape" assert prompt =~ "speech-shaped task" diff --git a/test/mix_gate_test.exs b/test/mix_gate_test.exs index be6eaca..b38f26f 100644 --- a/test/mix_gate_test.exs +++ b/test/mix_gate_test.exs @@ -1,5 +1,5 @@ defmodule Cantrip.MixGateTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias Cantrip.Circle @@ -24,6 +24,7 @@ defmodule Cantrip.MixGateTest do printf 'args=%s\\n' "$*" printf 'cwd=%s\\n' "$(pwd)" printf 'env=%s\\n' "$CANTRIP_MIX_GATE_ENV" + printf 'sentinel=%s\\n' "$CANTRIP_MIX_GATE_INHERITED_SENTINEL" """) File.chmod!(mix_path, 0o755) @@ -74,6 +75,21 @@ defmodule Cantrip.MixGateTest do assert obs.result =~ "allow_mix_tasks" end + test "does not inherit the parent environment", %{root: root, mix_path: mix_path} do + key = "CANTRIP_MIX_GATE_INHERITED_SENTINEL" + System.put_env(key, "must-not-leak") + on_exit(fn -> System.delete_env(key) end) + + obs = + root + |> circle(mix_path, [%{allow_mix_tasks: ["test"]}]) + |> Cantrip.Gate.execute("mix", %{"task" => "test"}) + + assert obs.is_error == false + assert obs.result.stdout =~ "sentinel=\n" + refute obs.result.stdout =~ "must-not-leak" + end + test "rejects tasks outside the allowlist", %{root: root, mix_path: mix_path} do obs = root