Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 6 additions & 1 deletion docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand All @@ -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.
Expand Down
13 changes: 6 additions & 7 deletions lib/cantrip/familiar.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 28 additions & 1 deletion lib/cantrip/gate/mix.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
13 changes: 3 additions & 10 deletions test/familiar_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
18 changes: 17 additions & 1 deletion test/mix_gate_test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
defmodule Cantrip.MixGateTest do
use ExUnit.Case, async: true
use ExUnit.Case, async: false

alias Cantrip.Circle

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down