lib/openagents/tools/workspace_bash.ex

58e6347eeb72 · 15 KB

defmodule OpenAgents.Tools.WorkspaceBash do
  @moduledoc """
  Runs one bounded shell command inside an explicit, noncanonical agent
  workspace.

  The command never runs against the canonical application checkout or the
  Forge data directory: the workspace root passes the same fail-closed checks
  as the workspace file tools. The child process starts with an emptied
  environment, its own session and process group, and — where the host
  supports unprivileged network namespaces — no network access. Output is
  captured as one interleaved stdout/stderr stream, redacted, and bounded to
  the last #{2_000} lines or 50 KiB; when the preview is truncated the full
  redacted output is stored as a bounded-lifetime host artifact.
  """

  @behaviour OpenAgents.Tools.Tool

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

  @default_timeout_seconds 30
  @maximum_timeout_seconds 120
  @maximum_preview_lines 2_000
  @maximum_preview_bytes 50 * 1_024
  @maximum_captured_bytes 1_024 * 1_024
  @artifact_lifetime_seconds 24 * 60 * 60
  @path "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

  @impl true
  def specification do
    %Tool{
      module_id: "openagents.tool.workspace_bash.v1",
      name: "bash",
      version: 1,
      description:
        "Runs one shell command inside the assigned agent workspace and returns its exit " <>
          "status with bounded, interleaved output.",
      input_schema: %{
        "type" => "object",
        "properties" => %{
          "command" => %{"type" => "string", "maxLength" => 4_000},
          "timeout_seconds" => %{
            "type" => "integer",
            "minimum" => 1,
            "maximum" => @maximum_timeout_seconds
          }
        },
        "required" => ["command"],
        "additionalProperties" => false
      },
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
      side_effect: :reversible_write,
      required_scope: "browser_conversation",
      required_authority: "command.execute",
      executor: %{id: "openagents.workspace", disclosure: "the assigned agent workspace"},
      maintainer: "OpenAgents",
      attribution: ["OpenAgentsInc/openagents.com"],
      policy_facets: %{"privacy" => "browser_conversation", "residency" => "host"},
      module_metadata:
        Metadata.first_party("command.execute", "browser_conversation",
          effect: :reversible_write,
          privacy: "browser_conversation",
          residency: "host",
          surfaces: ["text", "voice"],
          approval_class: "exact_current_user_consent",
          approval_enforcement: "host_receipt"
        ),
      timeout_ms: (@maximum_timeout_seconds + 10) * 1_000,
      maximum_input_bytes: 8_192,
      maximum_output_bytes: 96 * 1_024,
      implementation: __MODULE__
    }
  end

  @impl true
  def execute(%{"command" => command} = arguments, context) when is_binary(command) do
    with :ok <- validate_command(command),
         {:ok, timeout_ms} <- timeout_ms(arguments),
         {:ok, workspace} <- WorkspaceFiles.resolve_root(context, :write),
         {:ok, launcher} <- launcher(workspace.root),
         {:ok, run} <-
           reserve(workspace.root, fn -> run_command(launcher, workspace, command, timeout_ms) end) do
      build_result(workspace, command, launcher, run)
    end
  end

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

  @doc "Removes expired command-output artifacts from the host artifact store."
  def purge_expired_artifacts(now \\ DateTime.utc_now()) do
    store =
      Application.get_env(
        :openagents,
        :workspace_snapshot_dir,
        Path.join(System.tmp_dir!(), "openagents-workspace-snapshots")
      )

    with true <- is_binary(store), {:ok, entries} <- File.ls(store) do
      Enum.each(entries, fn entry ->
        directory = Path.join(store, entry)
        manifest = Path.join(directory, "manifest.json")

        with {:ok, encoded} <- File.read(manifest),
             {:ok, %{"kind" => "command_output", "expires_at" => expires_at}} <-
               Jason.decode(encoded),
             {:ok, expiry, _offset} <- DateTime.from_iso8601(expires_at),
             :lt <- DateTime.compare(expiry, now) do
          File.rm_rf(directory)
        else
          _keep -> :ok
        end
      end)
    end

    :ok
  end

  defp validate_command(command) do
    cond do
      String.trim(command) == "" -> {:error, :invalid_command}
      not String.valid?(command) -> {:error, :invalid_command}
      String.contains?(command, "\0") -> {:error, :invalid_command}
      true -> :ok
    end
  end

  defp timeout_ms(arguments) do
    case Map.get(arguments, "timeout_seconds", @default_timeout_seconds) do
      seconds when is_integer(seconds) and seconds >= 1 and seconds <= @maximum_timeout_seconds ->
        {:ok, seconds * 1_000}

      _invalid ->
        {:error, :invalid_command_timeout}
    end
  end

  # One command at a time per workspace: a second concurrent call is refused
  # instead of queued, so a stuck command cannot pile up hidden work.
  defp reserve(root, operation) do
    case :global.trans({{__MODULE__, root}, self()}, fn -> {:ok, operation.()} end, [node()], 0) do
      :aborted -> {:error, :command_concurrency_limit}
      result -> result
    end
  end

  # The launch chain is `env -i` (emptied environment), then `unshare -r -n`
  # when the host supports unprivileged network namespaces (network denied by
  # default), then `setsid -w` (own session and process group so the whole
  # tree can be cancelled, forwarding the exit status), then `sh -c`.
  defp launcher(root) do
    sh = System.find_executable("sh")
    env = System.find_executable("env")
    setsid = System.find_executable("setsid")

    if is_nil(sh) or is_nil(env) do
      {:error, :command_executor_unavailable}
    else
      {network, sandbox} = network_sandbox()

      environment = [
        "-i",
        "PATH=#{@path}",
        "HOME=#{root}",
        "LANG=C.UTF-8",
        "LC_ALL=C.UTF-8",
        "TERM=dumb"
      ]

      session = if setsid, do: [setsid, "-w"], else: []
      # The user command runs in an inner shell so that signal-terminated
      # commands surface as a conventional 128+signal exit status.
      chain = environment ++ sandbox ++ session ++ [sh, "-c", ~S(sh -c "$0")]
      {:ok, %{path: env, prefix: chain, network: network}}
    end
  end

  defp network_sandbox do
    case :persistent_term.get({__MODULE__, :network_sandbox}, :unknown) do
      :unknown ->
        sandbox = probe_network_sandbox()
        :persistent_term.put({__MODULE__, :network_sandbox}, sandbox)
        sandbox

      sandbox ->
        sandbox
    end
  end

  defp probe_network_sandbox do
    with unshare when is_binary(unshare) <- System.find_executable("unshare"),
         {_output, 0} <- System.cmd(unshare, ["-r", "-n", "true"], stderr_to_stdout: true) do
      {"denied", [unshare, "-r", "-n"]}
    else
      _unavailable -> {"unrestricted", []}
    end
  rescue
    _error -> {"unrestricted", []}
  end

  defp run_command(launcher, workspace, command, timeout_ms) do
    parent = self()

    port =
      Port.open({:spawn_executable, launcher.path}, [
        :binary,
        :exit_status,
        :stderr_to_stdout,
        :hide,
        {:args, launcher.prefix ++ [command]},
        {:cd, workspace.root}
      ])

    os_pid =
      case Port.info(port, :os_pid) do
        {:os_pid, pid} -> pid
        _closed -> nil
      end

    janitor = start_janitor(parent, os_pid)
    started = System.monotonic_time(:millisecond)
    outcome = collect(port, os_pid, [], 0, started + timeout_ms)
    send(janitor, :done)
    Map.put(outcome, :duration_ms, System.monotonic_time(:millisecond) - started)
  end

  # If the tool task is killed mid-command (host timeout or cancellation from
  # any client), the janitor outlives it and still tears down the process tree.
  defp start_janitor(parent, os_pid) do
    spawn(fn ->
      reference = Process.monitor(parent)

      receive do
        :done -> :ok
        {:DOWN, ^reference, :process, ^parent, _reason} -> kill_tree(os_pid)
      end
    end)
  end

  defp collect(port, os_pid, chunks, bytes, deadline) do
    remaining = max(deadline - System.monotonic_time(:millisecond), 0)

    receive do
      {^port, {:data, chunk}} ->
        chunks = [chunk | chunks]
        bytes = bytes + byte_size(chunk)

        if bytes > @maximum_captured_bytes do
          chunks = shutdown(port, os_pid, chunks)
          %{status: :output_limited, exit_status: nil, output: captured(chunks)}
        else
          collect(port, os_pid, chunks, bytes, deadline)
        end

      {^port, {:exit_status, status}} ->
        %{status: :exited, exit_status: status, output: captured(chunks)}
    after
      remaining ->
        chunks = shutdown(port, os_pid, chunks)
        %{status: :timed_out, exit_status: nil, output: captured(chunks)}
    end
  end

  defp shutdown(port, os_pid, chunks) do
    kill_tree(os_pid)
    drain(port, chunks)
  end

  defp drain(port, chunks) do
    receive do
      {^port, {:data, chunk}} -> drain(port, [chunk | chunks])
      {^port, {:exit_status, _status}} -> chunks
    after
      2_000 ->
        close_port(port)
        chunks
    end
  end

  defp close_port(port) do
    Port.close(port)
    :ok
  rescue
    ArgumentError -> :ok
  end

  # Kills the spawned process and every descendant. Descendants started by
  # `setsid -w` lead their own process groups, so each group whose leader is a
  # descendant is killed as a group; the launcher's own group (shared with the
  # runtime) is never signalled.
  defp kill_tree(nil), do: :ok

  defp kill_tree(os_pid) do
    kill = System.find_executable("kill")

    if is_binary(kill) do
      descendants = descendant_pids(os_pid)

      Enum.each(descendants, fn pid ->
        _group = System.cmd(kill, ["-KILL", "--", "-#{pid}"], stderr_to_stdout: true)
      end)

      Enum.each(descendants ++ [Integer.to_string(os_pid)], fn pid ->
        _process = System.cmd(kill, ["-KILL", "--", "#{pid}"], stderr_to_stdout: true)
      end)
    end

    :ok
  end

  defp descendant_pids(os_pid) do
    case System.cmd("ps", ["-eo", "pid=,ppid="], stderr_to_stdout: true) do
      {table, 0} ->
        children =
          table
          |> String.split("\n", trim: true)
          |> Enum.reduce(%{}, fn line, acc ->
            case String.split(line) do
              [pid, ppid] -> Map.update(acc, ppid, [pid], &[pid | &1])
              _other -> acc
            end
          end)

        collect_descendants(children, [Integer.to_string(os_pid)], [])

      _failure ->
        []
    end
  end

  defp collect_descendants(_children, [], found), do: found

  defp collect_descendants(children, [pid | rest], found) do
    next = Map.get(children, pid, [])
    collect_descendants(children, next ++ rest, next ++ found)
  end

  defp captured(chunks), do: chunks |> Enum.reverse() |> IO.iodata_to_binary()

  defp build_result(workspace, command, launcher, run) do
    output = run.output |> scrub() |> Redaction.redact_text()
    {preview, truncated} = bound_output(output)

    with {:ok, artifact} <- maybe_store_artifact(workspace, output, truncated) do
      {status, exit_code, signal} = classify(run)
      receipt = receipt(workspace, command, output)

      result =
        %{
          "schema" => "openagents.workspace_bash_result.v1",
          "command" => Redaction.redact_text(command),
          "status" => status,
          "exit_code" => exit_code,
          "signal" => signal,
          "timed_out" => run.status == :timed_out,
          "duration_ms" => run.duration_ms,
          "network" => launcher.network,
          "output" => preview,
          "output_bytes" => byte_size(output),
          "returned_bytes" => byte_size(preview),
          "truncated" => truncated,
          "artifact_ref" => artifact && artifact.ref,
          "artifact_expires_at" => artifact && artifact.expires_at,
          "workspace_ref" => workspace.ref,
          "effect_receipt" => receipt
        }

      refs = [receipt | if(artifact, do: [artifact.ref], else: [])]

      case command_error(status) do
        nil ->
          {:ok, %ExecutionResult{result: result, target_receipt_refs: refs}}

        error ->
          {:ok,
           %ExecutionResult{
             result: result,
             status: "failed",
             error: error,
             target_receipt_refs: refs
           }}
      end
    end
  end

  defp classify(%{status: :timed_out}), do: {"timed_out", nil, nil}
  defp classify(%{status: :output_limited}), do: {"output_limited", nil, nil}
  defp classify(%{exit_status: 127}), do: {"command_not_found", 127, nil}

  defp classify(%{exit_status: status}) when is_integer(status) and status > 128,
    do: {"signaled", status, status - 128}

  defp classify(%{exit_status: status}), do: {"exited", status, nil}

  defp command_error("timed_out"),
    do: %{"code" => "command_timed_out", "message" => "The command exceeded its time limit."}

  defp command_error("output_limited"),
    do: %{
      "code" => "command_output_limit",
      "message" => "The command exceeded the captured output limit."
    }

  defp command_error(_status), do: nil

  defp scrub(output) do
    if String.valid?(output) do
      output
    else
      output
      |> String.chunk(:valid)
      |> Enum.map_join(fn chunk -> if String.valid?(chunk), do: chunk, else: "\uFFFD" end)
    end
  end

  defp bound_output(output) do
    lines = String.split(output, "\n")

    preview =
      lines
      |> Enum.take(-@maximum_preview_lines)
      |> Enum.join("\n")
      |> tail_bytes(@maximum_preview_bytes)

    {preview, preview != output}
  end

  defp tail_bytes(text, limit) when byte_size(text) <= limit, do: text

  defp tail_bytes(text, limit) do
    text
    |> binary_part(byte_size(text) - limit, limit)
    |> trim_partial_prefix(3)
  end

  defp trim_partial_prefix(text, 0), do: text

  defp trim_partial_prefix(text, attempts) do
    case text do
      <<_first, rest::binary>> ->
        if String.valid?(text), do: text, else: trim_partial_prefix(rest, attempts - 1)

      _empty ->
        text
    end
  end

  defp maybe_store_artifact(_workspace, _output, false), do: {:ok, nil}

  defp maybe_store_artifact(workspace, output, true) do
    id = Ecto.UUID.generate()

    expires_at =
      DateTime.utc_now() |> DateTime.add(@artifact_lifetime_seconds) |> DateTime.to_iso8601()

    with {:ok, store} <- WorkspaceFiles.snapshot_root(workspace.root),
         directory = Path.join(store, id),
         :ok <- File.mkdir_p(directory),
         :ok <- File.chmod(directory, 0o700),
         :ok <- secure_write(Path.join(directory, "output"), output),
         :ok <-
           secure_write(
             Path.join(directory, "manifest.json"),
             Jason.encode!(%{
               "kind" => "command_output",
               "bytes" => byte_size(output),
               "expires_at" => expires_at,
               "workspace_ref" => workspace.ref
             })
           ) do
      _sweep = purge_expired_artifacts()
      {:ok, %{ref: "workspace-artifact:" <> id, expires_at: expires_at}}
    else
      _failure -> {:error, :workspace_artifact_failed}
    end
  end

  defp secure_write(path, content) do
    with :ok <- File.write(path, content, [:binary, :exclusive]) do
      File.chmod(path, 0o600)
    end
  end

  defp receipt(workspace, command, output) do
    identity =
      WorkspaceFiles.digest(workspace.ref <> "\0" <> command) |> binary_part(0, 24)

    "workspace-command:" <>
      identity <> ":" <> (WorkspaceFiles.digest(output) |> binary_part(0, 32))
  end
end