lib/openagents/tools/code_check.ex

58e6347eeb72 · 5 KB

defmodule OpenAgents.Tools.CodeCheck do
  @moduledoc """
  A cheap, safe pre-commit gate for Elixir source: full syntax check via
  `Code.string_to_quoted/2` always; a compile probe (`Code.compile_string/1`
  in a throwaway task, immediately purged) ONLY when none of the modules the
  source defines is already loaded — redefining a loaded module would be an
  ungoverned hot-load, so for running modules the sidecar build at promotion
  remains the compile gate and this tool says so honestly.
  """

  @behaviour OpenAgents.Tools.Tool

  alias OpenAgents.Modules.Metadata
  alias OpenAgents.Tools.{ExecutionResult, Tool}

  @impl true
  def specification do
    %Tool{
      module_id: "sarah.tool.code_check.v1",
      name: "code_check",
      version: 1,
      description:
        "Checks Elixir source before committing: always a full syntax parse; additionally a " <>
          "compile probe when the code's modules are not already loaded in the running " <>
          "system. Pass content as the complete file text. Returns ok, or the error with " <>
          "line information.",
      input_schema: %{
        "type" => "object",
        "properties" => %{
          "content" => %{"type" => "string", "maxLength" => 200_000}
        },
        "required" => ["content"],
        "additionalProperties" => false
      },
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
      side_effect: :read_only,
      required_scope: "browser_conversation",
      required_authority: "code.execute",
      executor: %{
        id: "sarah.repository.self",
        disclosure: "the OpenAgents runtime, parsing candidate code in a throwaway process"
      },
      maintainer: "OpenAgents",
      attribution: ["OpenAgentsInc/openagents.com"],
      policy_facets: %{
        "privacy" => "browser_conversation",
        "residency" => "application_process",
        "consent" => "not_applicable"
      },
      module_metadata:
        Metadata.first_party("code.execute", "browser_conversation",
          effect: :read_only,
          privacy: "browser_conversation",
          residency: "application_process",
          surfaces: ["text"]
        ),
      timeout_ms: 15_000,
      maximum_input_bytes: 262_144,
      maximum_output_bytes: 16_384,
      implementation: __MODULE__
    }
  end

  @impl true
  def execute(%{"content" => content}, _context) when is_binary(content) do
    case Code.string_to_quoted(content, columns: true) do
      {:ok, quoted} ->
        {compile_status, compile_detail} = compile_probe(content, quoted)

        {:ok,
         %ExecutionResult{
           result: %{
             "schema" => "sarah.code_check_result.v1",
             "syntax" => "ok",
             "compile" => compile_status,
             "detail" => compile_detail
           }
         }}

      {:error, {location, message, token}} ->
        {:ok,
         %ExecutionResult{
           result: %{
             "schema" => "sarah.code_check_result.v1",
             "syntax" => "error",
             "compile" => "skipped",
             "detail" =>
               "line #{location_line(location)}: #{format_message(message)}#{inspect(token)}"
           }
         }}
    end
  end

  def execute(_arguments, _context), do: {:error, :invalid_code_content}

  defp location_line(location) when is_list(location), do: Keyword.get(location, :line, 0)
  defp location_line(line) when is_integer(line), do: line
  defp location_line(_location), do: 0

  defp format_message({prefix, suffix}), do: "#{prefix}#{suffix} "
  defp format_message(message) when is_binary(message), do: message <> " "
  defp format_message(other), do: inspect(other) <> " "

  # Compile only when it cannot redefine anything the running system has
  # loaded; purge everything the probe defined either way.
  defp compile_probe(content, quoted) do
    defined = defined_modules(quoted)
    loaded = Enum.filter(defined, &Code.ensure_loaded?/1)

    cond do
      loaded != [] ->
        {"skipped",
         "modules already loaded in the running system (#{inspect(loaded)}); the sidecar " <>
           "build at promotion is the compile gate for running code"}

      true ->
        task =
          Task.async(fn ->
            try do
              modules = Code.compile_string(content)

              for {module, _binary} <- modules do
                :code.purge(module)
                :code.delete(module)
              end

              {"ok", nil}
            rescue
              error in CompileError ->
                {"error", "#{error.line}: #{error.description}"}

              error ->
                {"error", Exception.message(error)}
            end
          end)

        case Task.yield(task, 10_000) || Task.shutdown(task) do
          {:ok, result} -> result
          _timeout -> {"error", "compile probe timed out"}
        end
    end
  end

  defp defined_modules(quoted) do
    {_quoted, modules} =
      Macro.prewalk(quoted, [], fn
        {:defmodule, _meta, [{:__aliases__, _amet, parts} | _body]} = node, acc
        when is_list(parts) ->
          {node, [Module.concat(parts) | acc]}

        node, acc ->
          {node, acc}
      end)

    Enum.uniq(modules)
  end
end