diff --git a/app/lib/wardwright/bash_canonicalizer.ex b/app/lib/wardwright/bash_canonicalizer.ex new file mode 100644 index 0000000..4c5899a --- /dev/null +++ b/app/lib/wardwright/bash_canonicalizer.ex @@ -0,0 +1,239 @@ +defmodule Wardwright.BashCanonicalizer do + @moduledoc """ + Conservative Bash command canonicalization for agent tool calls. + + This module is intentionally narrow. It models approval friction cases where a + command is semantically equivalent to allowlisted atomic commands but is shaped + in a way a permission analyzer may not match. Unknown or dynamic shell behavior + returns a repair instruction instead of being rewritten. + """ + + @repair "repair" + @rewritten "rewritten" + @unchanged "unchanged" + + @type result :: %{ + required("status") => String.t(), + required("commands") => [String.t()], + required("diagnostics") => [map()] + } + + @doc """ + Canonicalizes a Bash command when equivalence is straightforward. + + Options: + + * `:cwd` - current working directory for path equivalence checks. + * `:repo_root` - current repository root for redundant `git -C` removal. + + The return value is a map so it can be serialized directly into eval reports, + receipts, or model-repair feedback. + """ + @spec canonicalize(String.t(), keyword()) :: result() + def canonicalize(command, opts \\ []) when is_binary(command) do + command = String.trim(command) + cwd = opts[:cwd] + repo_root = opts[:repo_root] + + cond do + command == "" -> + result(@unchanged, [], [%{"kind" => "empty_command"}]) + + dynamic_shell_variable?(command) -> + result(@repair, [command], [ + %{ + "kind" => "dynamic_shell_expansion", + "message" => + "Shell variable assignment and later expansion cannot be safely canonicalized; inline the expression or split into explicit commands." + } + ]) + + true -> + command + |> split_top_level_commands() + |> Enum.map(&canonicalize_simple_command(&1, cwd, repo_root)) + |> combine_results(command) + end + end + + def canonicalize(command, opts), do: canonicalize(to_string(command), opts) + + defp combine_results(parts, original_command) do + commands = Enum.map(parts, & &1.command) + diagnostics = Enum.flat_map(parts, & &1.diagnostics) + + cond do + Enum.any?(parts, &(&1.status == @repair)) -> + result(@repair, [original_command], diagnostics) + + commands != [original_command] -> + result(@rewritten, commands, diagnostics) + + true -> + result(@unchanged, commands, diagnostics) + end + end + + defp canonicalize_simple_command(command, cwd, repo_root) do + case tokenize(command) do + ["git", "-C", path | rest] when rest != [] -> + canonicalize_git_c(command, path, rest, cwd, repo_root) + + ["git", "--git-dir" | _rest] -> + %{ + command: command, + status: @repair, + diagnostics: [ + %{ + "kind" => "unsupported_git_context", + "message" => "`git --git-dir` changes repository context and must be repaired by the model." + } + ] + } + + _tokens -> + %{command: command, status: @unchanged, diagnostics: []} + end + end + + defp canonicalize_git_c(original, path, rest, cwd, repo_root) do + if equivalent_context_path?(path, cwd, repo_root) do + %{ + command: Enum.join(["git" | rest], " "), + status: @rewritten, + diagnostics: [ + %{ + "kind" => "removed_redundant_git_c", + "message" => "Removed redundant `git -C` because it targeted the active cwd or repo root." + } + ] + } + else + %{ + command: original, + status: @repair, + diagnostics: [ + %{ + "kind" => "git_c_external_context", + "message" => + "`git -C` targets a different path; rerun from that repo context or pass a trusted cwd outside the Bash command." + } + ] + } + end + end + + defp equivalent_context_path?(_path, nil, nil), do: false + + defp equivalent_context_path?(path, cwd, repo_root) do + expanded = expand_path(path, cwd) + + [cwd, repo_root] + |> Enum.reject(&is_nil/1) + |> Enum.map(&Path.expand/1) + |> Enum.any?(&(&1 == expanded)) + end + + defp expand_path(path, cwd) do + if Path.type(path) == :absolute do + Path.expand(path) + else + Path.expand(path, cwd || File.cwd!()) + end + end + + defp dynamic_shell_variable?(command) do + Regex.match?(~r/(^|[\s;])[_A-Za-z][_A-Za-z0-9]*=\$\(/, command) and + Regex.match?(~r/(^|[^\$])\$[_A-Za-z][_A-Za-z0-9]*/, command) + end + + defp split_top_level_commands(command) do + command + |> do_split_top_level([], [], :normal) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp do_split_top_level(<<>>, current, commands, _mode) do + [current |> Enum.reverse() |> IO.iodata_to_binary() | commands] + |> Enum.reverse() + end + + defp do_split_top_level(<<"&&", rest::binary>>, current, commands, :normal) do + command = current |> Enum.reverse() |> IO.iodata_to_binary() + do_split_top_level(rest, [], [command | commands], :normal) + end + + defp do_split_top_level(<<";", rest::binary>>, current, commands, :normal) do + command = current |> Enum.reverse() |> IO.iodata_to_binary() + do_split_top_level(rest, [], [command | commands], :normal) + end + + defp do_split_top_level(<<"\\", char::binary-size(1), rest::binary>>, current, commands, mode) do + do_split_top_level(rest, [char, "\\" | current], commands, mode) + end + + defp do_split_top_level(<<"'", rest::binary>>, current, commands, :normal) do + do_split_top_level(rest, ["'" | current], commands, :single_quote) + end + + defp do_split_top_level(<<"'", rest::binary>>, current, commands, :single_quote) do + do_split_top_level(rest, ["'" | current], commands, :normal) + end + + defp do_split_top_level(<<"\"", rest::binary>>, current, commands, :normal) do + do_split_top_level(rest, ["\"" | current], commands, :double_quote) + end + + defp do_split_top_level(<<"\"", rest::binary>>, current, commands, :double_quote) do + do_split_top_level(rest, ["\"" | current], commands, :normal) + end + + defp do_split_top_level(<>, current, commands, mode) do + do_split_top_level(rest, [char | current], commands, mode) + end + + defp tokenize(command) do + command + |> do_tokenize([], [], :normal) + |> Enum.reverse() + end + + defp do_tokenize(<<>>, current, tokens, _mode), do: finish_token(current, tokens) + + defp do_tokenize(<>, current, tokens, :normal) + when char in [" ", "\t", "\n"] do + do_tokenize(rest, [], finish_token(current, tokens), :normal) + end + + defp do_tokenize(<<"\\", char::binary-size(1), rest::binary>>, current, tokens, mode) do + do_tokenize(rest, [char | current], tokens, mode) + end + + defp do_tokenize(<<"'", rest::binary>>, current, tokens, :normal), + do: do_tokenize(rest, current, tokens, :single_quote) + + defp do_tokenize(<<"'", rest::binary>>, current, tokens, :single_quote), + do: do_tokenize(rest, current, tokens, :normal) + + defp do_tokenize(<<"\"", rest::binary>>, current, tokens, :normal), + do: do_tokenize(rest, current, tokens, :double_quote) + + defp do_tokenize(<<"\"", rest::binary>>, current, tokens, :double_quote), + do: do_tokenize(rest, current, tokens, :normal) + + defp do_tokenize(<>, current, tokens, mode) do + do_tokenize(rest, [char | current], tokens, mode) + end + + defp finish_token([], tokens), do: tokens + defp finish_token(current, tokens), do: [current |> Enum.reverse() |> IO.iodata_to_binary() | tokens] + + defp result(status, commands, diagnostics) do + %{ + "commands" => commands, + "diagnostics" => diagnostics, + "status" => status + } + end +end diff --git a/app/test/bash_canonicalizer_test.exs b/app/test/bash_canonicalizer_test.exs new file mode 100644 index 0000000..64a9bf7 --- /dev/null +++ b/app/test/bash_canonicalizer_test.exs @@ -0,0 +1,69 @@ +defmodule Wardwright.BashCanonicalizerTest do + use ExUnit.Case, async: true + + alias Wardwright.BashCanonicalizer + + @repo "/workspace/example-repo" + + test "removes redundant git -C targeting the current repo root" do + assert %{ + "commands" => ["git status --short"], + "diagnostics" => [%{"kind" => "removed_redundant_git_c"}], + "status" => "rewritten" + } = + BashCanonicalizer.canonicalize("git -C /workspace/example-repo status --short", + cwd: @repo, + repo_root: @repo + ) + end + + test "removes redundant quoted git -C targeting the current cwd" do + assert %{"commands" => ["git diff --stat"], "status" => "rewritten"} = + BashCanonicalizer.canonicalize(~s(git -C "/workspace/example-repo" diff --stat), + cwd: @repo + ) + end + + test "splits top-level command chains after canonicalizing each safe command" do + assert %{ + "commands" => ["git status --short", "git diff --stat"], + "status" => "rewritten" + } = + BashCanonicalizer.canonicalize( + "git -C /workspace/example-repo status --short && git -C /workspace/example-repo diff --stat", + cwd: @repo, + repo_root: @repo + ) + end + + test "does not split separators inside quotes" do + assert %{ + "commands" => [~s(printf 'ready && still one command')], + "status" => "unchanged" + } = + BashCanonicalizer.canonicalize(~s(printf 'ready && still one command'), cwd: @repo) + end + + test "asks for model repair when git -C targets a different repo context" do + assert %{ + "commands" => ["git -C /workspace/other-repo status --short"], + "diagnostics" => [%{"kind" => "git_c_external_context"}], + "status" => "repair" + } = + BashCanonicalizer.canonicalize("git -C /workspace/other-repo status --short", + cwd: @repo, + repo_root: @repo + ) + end + + test "asks for model repair for shell variable assignment and expansion" do + assert %{ + "commands" => ["FILES=$(rg --files | head -5); wc -l $FILES"], + "diagnostics" => [%{"kind" => "dynamic_shell_expansion"}], + "status" => "repair" + } = + BashCanonicalizer.canonicalize("FILES=$(rg --files | head -5); wc -l $FILES", + cwd: @repo + ) + end +end diff --git a/docs/bash-command-canonicalization-eval.md b/docs/bash-command-canonicalization-eval.md new file mode 100644 index 0000000..c0e7277 --- /dev/null +++ b/docs/bash-command-canonicalization-eval.md @@ -0,0 +1,51 @@ +# Bash Command Canonicalization Eval + +This branch starts a narrow Wardwright-side experiment for agent Bash tool calls +that are semantically allowlist-friendly but shaped in ways that trigger +permission prompts. + +It is inspired by +[wayfinder-router](https://github.com/itsthelore/wayfinder-router)'s core shape: +make a deterministic, offline decision from request structure before spending +latency, model budget, or human attention. Wayfinder applies that to +local-vs-cloud model routing; this branch tests the same kind of preflight +control point for tool-call shape. + +The first library surface is `Wardwright.BashCanonicalizer.canonicalize/2`. +It returns a JSON-serializable map: + +- `status: "rewritten"` when the command can be safely converted to one or more + atomic commands. +- `status: "unchanged"` when no rewrite is needed. +- `status: "repair"` when the model should be asked to retry with a simpler + command shape. + +Initial covered cases: + +- `git -C status --short` -> `git status --short` +- top-level `&&` and `;` chains split into separate commands while preserving + separators inside quotes +- `git -C ...` reported as model-repair, not rewritten +- shell variable assignment plus later expansion reported as model-repair + +Other Wayfinder-shaped Wardwright experiments worth comparing against this one: + +- prompt complexity scoring as a route fact before model selection +- deterministic request classification as a cheap guard before Dune/WASM policy + evaluation +- tool-call canonicalization as a preflight repair loop before permission + prompts or denials +- receipt-visible router explanations that show which structural features drove + a route, guard, or repair decision + +Run the focused library eval: + +```bash +cd app +mise exec -- mix test test/bash_canonicalizer_test.exs +``` + +This is intentionally deterministic before adding model-to-model rewrite passes. +The next useful step is to feed the `repair` diagnostics back into a simple model +retry stage and compare original command, canonical command(s), predicted +permission behavior, and execution result.