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
239 changes: 239 additions & 0 deletions app/lib/wardwright/bash_canonicalizer.ex
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
Comment on lines +99 to +110
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
Comment on lines +126 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Avoid using File.cwd!/0 when :cwd is nil but :repo_root is provided to prevent mis-detecting redundant git -C.

In equivalent_context_path?/3, when cwd is nil but repo_root is set, expand_path/2 still falls back to File.cwd!/0. This makes the OS working directory affect whether git -C is seen as redundant and can cause us to strip a -C that should change context from the agent’s perspective.

Instead of using File.cwd!/0 here, either treat path as non-equivalent when cwd is nil, or require explicit cwd/repo_root values for these checks so equivalence is based only on explicit context, not ambient process state.


defp expand_path(path, cwd) do
if Path.type(path) == :absolute do
Path.expand(path)
else
Path.expand(path, cwd || File.cwd!())
end
end
Comment on lines +137 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using File.cwd!() can raise a File.Error exception if the current working directory is inaccessible or has been deleted. Since canonicalize/2 is used for preflight tool-call canonicalization, an unhandled exception here could crash the mediation pipeline.

Additionally, if we preserve quotes in do_tokenize/4, we need to strip quotes from the path before checking for equivalence. We can add a helper strip_quotes/1 here.

  defp expand_path(path, cwd) do
    path = strip_quotes(path)

    if Path.type(path) == :absolute do
      Path.expand(path)
    else
      fallback_cwd =
        case File.cwd() do
          {:ok, dir} -> dir
          {:error, _} -> "."
        end

      Path.expand(path, cwd || fallback_cwd)
    end
  end

  defp strip_quotes(path) do
    cond do
      String.starts_with?(path, "'") and String.ends_with?(path, "'") ->
        String.slice(path, 1..-2)

      String.starts_with?(path, "\"") and String.ends_with?(path, "\"") ->
        String.slice(path, 1..-2)

      true ->
        path
    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
Comment on lines +145 to +148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The dynamic_shell_variable?/1 check can be bypassed by using the standard Bash syntax ${VAR} instead of $VAR. Since the regex ~r/(^|[^\$])\$[_A-Za-z][_A-Za-z0-9]*/ expects a letter or underscore immediately after the $, it fails to match ${VAR}.

If bypassed, a command like FILES=$(rg --files); wc -l ${FILES} will be split into ["FILES=$(rg --files)", "wc -l ${FILES}"] and marked as "rewritten". When executed as separate commands, the variable state is lost, completely breaking the command.

Additionally, the regex matches escaped variables like \$VAR because [^\$] matches the backslash \.

Consider using a lookbehind to avoid matching escaped dollar signs, and support both $VAR and ${VAR} formats.

  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]*|\{[\s]*[_A-Za-z][_A-Za-z0-9]*[\s]*\})/, command)
  end

Comment on lines +145 to +148

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
Comment on lines +162 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Splitting && chains into a list of independent commands loses the conditional execution semantics of Bash. In Bash, cmd1 && cmd2 ensures cmd2 only runs if cmd1 succeeds. If these are split and executed sequentially by the caller without checking the exit status of the previous command, it can lead to dangerous behavior (for example, cd /safe/dir && rm -rf * would run rm -rf * in the current directory if the cd failed).

Consider either keeping && chains as a single command (or marking them as repair if they cannot be safely canonicalized together), or returning structured metadata indicating that the commands must be executed conditionally.

Comment on lines +162 to +165

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
Comment on lines +172 to +174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Bash, backslashes inside single quotes ('...') have no special meaning and do not act as escape characters. However, do_split_top_level/4 handles backslashes regardless of the current mode. This causes backslashes inside single quotes to incorrectly escape the following character (e.g., escaping a single quote, which is impossible in standard Bash single quotes).

We should restrict the backslash clause to only match when mode is :normal or :double_quote.

  defp do_split_top_level(<<"\\", char::binary-size(1), rest::binary>>, current, commands, mode) when mode in [:normal, :double_quote] 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(<<char::binary-size(1), rest::binary>>, 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(<<char::binary-size(1), rest::binary>>, 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
Comment on lines +209 to +211

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Bash, backslashes inside single quotes ('...') have no special meaning and do not act as escape characters. However, do_tokenize/4 handles backslashes regardless of the current mode. This causes backslashes inside single quotes to incorrectly escape the following character (e.g., escaping a single quote, which is impossible in standard Bash single quotes).

We should restrict the backslash clause to only match when mode is :normal or :double_quote.

  defp do_tokenize(<<"\\", char::binary-size(1), rest::binary>>, current, tokens, mode) when mode in [:normal, :double_quote] 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)
Comment on lines +213 to +223

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The tokenizer currently strips single and double quotes from all tokens during the tokenization phase. When a command is reconstructed (e.g., in canonicalize_git_c/5 using Enum.join(["git" | rest], " ")), the original quotes are completely lost.

For example, git -C /workspace/example-repo commit -m 'hello world' will be canonicalized to git commit -m hello world. In Bash, this is a major semantic change because world is no longer part of the commit message and is instead treated as a separate argument.

To fix this, the tokenizer should preserve quotes for all tokens, and the path comparison logic should strip quotes only from the path argument before checking for equivalence.

  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(<<char::binary-size(1), rest::binary>>, 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
69 changes: 69 additions & 0 deletions app/test/bash_canonicalizer_test.exs
Original file line number Diff line number Diff line change
@@ -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

Comment on lines +20 to +26
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
51 changes: 51 additions & 0 deletions docs/bash-command-canonicalization-eval.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +17 to +18
- `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 <current repo> status --short` -> `git status --short`
- top-level `&&` and `;` chains split into separate commands while preserving
separators inside quotes
- `git -C <other repo> ...` 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.
Loading