Skip to content

Evaluate Wayfinder-shaped Bash command canonicalization#78

Open
bglusman wants to merge 3 commits into
mainfrom
bash-command-canonicalization-eval
Open

Evaluate Wayfinder-shaped Bash command canonicalization#78
bglusman wants to merge 3 commits into
mainfrom
bash-command-canonicalization-eval

Conversation

@bglusman

@bglusman bglusman commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add a narrow Wardwright.BashCanonicalizer experiment for deterministic Bash tool-call preflight canonicalization.
  • Cover redundant git -C cleanup, top-level command-chain splitting, quoted separator preservation, external repo repair, and dynamic shell expansion repair.
  • Document how this borrows from https://github.com/itsthelore/wayfinder-router: make a deterministic offline routing/preflight decision from request structure before spending model budget or human approval attention.

Verification

  • git diff --check passed locally.
  • Not run: cd app && mise exec -- mix test test/bash_canonicalizer_test.exs because this worker context has no mix, elixir, or mise binary available.

Notes

This is intentionally a small first slice. Follow-up comparisons can evaluate prompt-complexity route facts, deterministic request classification before Dune/WASM policy evaluation, and receipt-visible router explanations.

Summary by Sourcery

Introduce a Wardwright Bash command canonicalizer for deterministic preflight normalization of agent tool calls and add focused tests and documentation for its initial behaviors.

New Features:

  • Add Wardwright.BashCanonicalizer module to canonicalize Bash commands for agent tool calls and surface rewrite or repair decisions via a structured result map.

Enhancements:

  • Implement conservative handling of git context switches, command-chain splitting, quoting preservation, and dynamic shell expansion detection in the Bash canonicalizer.

Documentation:

  • Add a Bash command canonicalization eval document describing the experiment, its statuses, covered cases, and how to run focused tests.

Tests:

  • Add an ExUnit test suite validating canonicalization, command splitting, and repair scenarios for representative Bash command shapes.

Copilot AI review requested due to automatic review settings June 28, 2026 13:16
@sourcery-ai

sourcery-ai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces a new Wardwright.BashCanonicalizer module that deterministically canonicalizes certain bash git commands (including splitting command chains and removing redundant git -C uses) and returns structured status/diagnostics, along with focused tests and documentation for this experimental evaluation.

File-Level Changes

Change Details Files
Add Wardwright.BashCanonicalizer for deterministic bash command canonicalization with structured results.
  • Implements canonicalize/2 to normalize commands or request model repair, handling empty inputs and dynamic shell-variable patterns.
  • Splits top-level command chains on && and ; while preserving separators within quotes and escapes.
  • Tokenizes commands to detect git -C and git --git-dir patterns and either rewrite or flag them for repair.
  • Normalizes redundant git -C invocations when targeting the current cwd or repo_root and leaves external contexts as repair cases.
  • Provides detailed diagnostics metadata in the result map for downstream eval/reporting.
app/lib/wardwright/bash_canonicalizer.ex
Add focused ExUnit coverage for bash canonicalization behaviors.
  • Tests removal of redundant git -C targeting cwd/repo_root including quoted paths.
  • Validates top-level command chain splitting while not splitting inside quotes.
  • Covers repair behavior for git -C with external repo contexts and dynamic shell variable assignment/expansion.
app/test/bash_canonicalizer_test.exs
Document the bash command canonicalization experiment and evaluation flow.
  • Describes the purpose and scope of the BashCanonicalizer, including statuses rewritten/unchanged/repair.
  • Explains relationship to wayfinder-router and other potential Wardwright routing/guard experiments.
  • Adds instructions for running the focused mix test suite for this eval.
docs/bash-command-canonicalization-eval.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="app/lib/wardwright/bash_canonicalizer.ex" line_range="126-135" />
<code_context>
+    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)
</code_context>
<issue_to_address>
**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.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +126 to +135
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

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the Wardwright.BashCanonicalizer module to canonicalize Bash commands for agent tool calls, along with tests and documentation. The review highlights several critical and high-severity issues: the tokenizer incorrectly strips quotes, which alters command semantics upon reconstruction; the dynamic shell variable check can be bypassed using ${VAR} syntax; splitting && chains discards conditional execution semantics, potentially leading to dangerous behavior; File.cwd!() may raise unhandled exceptions; and backslashes inside single quotes are incorrectly treated as escape characters during splitting and tokenization.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +213 to +223
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)

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)

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

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 +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

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 +137 to +143
defp expand_path(path, cwd) do
if Path.type(path) == :absolute do
Path.expand(path)
else
Path.expand(path, cwd || File.cwd!())
end
end

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

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

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

Comment on lines +209 to +211
defp do_tokenize(<<"\\", char::binary-size(1), rest::binary>>, current, tokens, mode) do
do_tokenize(rest, [char | current], tokens, mode)
end

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces an experimental Wardwright.BashCanonicalizer to deterministically normalize certain Bash tool-call shapes (e.g., redundant git -C, simple top-level chaining) into forms that are more allowlist/permission-analyzer friendly, along with focused ExUnit coverage and an eval document describing intent and scope.

Changes:

  • Added Wardwright.BashCanonicalizer.canonicalize/2 returning a JSON-serializable result (status, commands, diagnostics) and implementing initial rewrite/repair rules.
  • Added ExUnit tests covering redundant git -C removal, top-level command-chain splitting, quote-aware separator handling, and repair diagnostics.
  • Added a short evaluation document explaining the experiment and how to run the focused tests.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
docs/bash-command-canonicalization-eval.md Documents the experiment’s goals, statuses, and initial covered cases.
app/lib/wardwright/bash_canonicalizer.ex Implements the canonicalization/repair logic and supporting parsing helpers.
app/test/bash_canonicalizer_test.exs Adds targeted ExUnit tests for the new canonicalizer behaviors.

Comment on lines +99 to +110
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 +145 to +148
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 +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
Comment on lines +20 to +26
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 +17 to +18
- `status: "rewritten"` when the command can be safely converted to one or more
atomic commands.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants