test/openagents/forge/assignment_credential_reach_test.exs

58e6347eeb72 · 12 KB

defmodule OpenAgents.Forge.AssignmentCredentialReachTest do
  @moduledoc """
  The executable enumeration behind IDENTITY-010's list of sinks.

  IDENTITY-010 says the plaintext Computer assignment credential exists only in
  memory while the delegation starts, and is not persisted in a job, journal,
  prompt, output, shell environment, global Git configuration, or API response.
  That is a list of seven places someone thought of.
  `computer_control_api_test.exs` checks two of them — the create response and
  one job's report column — so a sink added later is outside what any proof
  here examines, and the credential leaking is the failure the list exists to
  prevent.

  This file enumerates the sinks rather than the absences, two ways.

  1. **Who can hold one.** The plaintext comes into existence in exactly one
     place, `OpenAgents.Forge.Assignments.persist_assignment/7`, and leaves that
     module through `create/1` and through
     `OpenAgents.Forge.AssignmentCredentialVault`. Every module that can hold
     one therefore carries a compiled import edge to one of those functions, and
     the edges are read from BEAM import tables rather than from source text,
     so a comment cannot add a caller and a rename cannot hide one.

  2. **Where one could land.** A real delegation is driven end to end with a
     real minted credential, and then every base table in the database is asked
     whether any row of it renders that credential. The population is
     PostgreSQL's own catalog, which cannot forget a table or a column, so a
     projection, a journal, or an audit row added tomorrow is scanned the day it
     lands rather than the day someone remembers it. The same run asserts the
     credential appears exactly once in the `agent` frame — under
     `assignment_credential` and under no other key — and in no log line
     emitted while the delegation runs.

  A positive control asserts the scanner can see a value that *is* persisted, so
  a scan that silently matched nothing would fail rather than pass.

  **What this does not close.** The shell environment and the global Git
  configuration in IDENTITY-010's list are on the delegated Computer, past this
  application's boundary; what is proven here is that the server puts the
  credential in one frame field and nowhere else. IDENTITY-010 records that.
  """

  use OpenAgentsWeb.ConnCase, async: false

  import Ecto.Query
  import ExUnit.CaptureLog, only: [with_log: 1]
  import OpenAgents.AccountsFixtures

  alias OpenAgents.ApiTokens
  alias OpenAgents.Forge.{AssignmentCredentialVault, Assignments}
  alias OpenAgents.Issues
  alias OpenAgents.Machines
  alias OpenAgents.Repo
  alias OpenAgents.Support.FakeController
  alias OpenAgents.Work

  @root "/tmp/openagents-assignment-credential-reach"

  # `config/test.exs` sets the primary log level to `:warning`, so an
  # `info` or `debug` line carrying the credential would never reach the
  # capture handler and the scan below would pass over it. The level is lowered
  # for this test and restored afterwards. This module is synchronous, and
  # ExUnit runs synchronous modules after the asynchronous ones, so no other
  # test is running while the level is down.
  setup do
    previous = Logger.level()
    Logger.configure(level: :debug)
    Logger.put_module_level(Ecto.Adapters.SQL, :none)

    on_exit(fn ->
      Logger.delete_module_level(Ecto.Adapters.SQL)
      Logger.configure(level: previous)
    end)

    :ok
  end

  # The one function that returns a freshly minted plaintext credential, and
  # every module that calls it. Both discard it; neither renders it.
  @create_callers [OpenAgentsWeb.AssignmentController, OpenAgentsWeb.IssueShowLive]

  # The vault holds the plaintext between the mint and the `agent` frame.
  # Writing to it and reading from it are one module each.
  @vault_writers [OpenAgents.Forge.Assignments]
  @vault_readers [OpenAgents.Work.DelegationServer]

  # Tables whose rows are not application state and are not scanned.
  @unscanned_tables ["schema_migrations"]

  describe "who can hold a plaintext credential" do
    test "the modules that receive a minted credential are exactly the ones named" do
      assert_exact_set(
        callers_of([{Assignments, :create, 1}]),
        @create_callers,
        "receives a freshly minted assignment credential"
      )
    end

    test "the vault is written by one module and read by one module" do
      assert_exact_set(
        callers_of([{AssignmentCredentialVault, :put, 2}]),
        @vault_writers,
        "puts a plaintext credential into the vault"
      )

      assert_exact_set(
        callers_of([{AssignmentCredentialVault, :take, 1}]),
        @vault_readers,
        "takes a plaintext credential out of the vault"
      )
    end
  end

  describe "where a plaintext credential could land" do
    test "a delegation writes the credential to no table, no log line, and no response" do
      user = github_user("assignment-credential-reach")
      {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(user)
      repository = repository_with_member_fixture(user)
      {:ok, issue} = Issues.create_issue(repository, %{title: "Credential reach"})
      machine = paired_machine(user, "credential-reach", ["codex"], true)
      test_process = self()

      start_supervised!(
        {FakeController,
         machine_id: machine.id,
         script: fn {:agent, request_id, payload, caller} ->
           send(test_process, {:assignment_request, request_id, payload, caller})
         end}
      )

      {{response, frame, request_id, caller}, log} =
        with_log(fn ->
          response =
            build_conn()
            |> put_api_token(user, ["computer:control"])
            |> post(
              ~p"/api/v1/conversations/#{conversation.id}/computers/#{machine.id}/assignments",
              %{
                "repository_id" => repository.id,
                "issue_number" => issue.number,
                "branch" => "agent/credential-reach-#{issue.number}",
                "agent_id" => "codex",
                "prompt" => "Implement the issue",
                "cwd" => @root
              }
            )
            |> json_response(202)

          assert_receive {:assignment_request, request_id, frame, caller}, 1_000
          {response, frame, request_id, caller}
        end)

      assignment_id = response["assignment"]["id"]
      credential = frame["assignment_credential"]
      assert is_binary(credential)
      assert String.starts_with?(credential, "oa_assignment_")

      # The frame delivers it once, under one key.
      assert keys_holding(frame, credential) == ["assignment_credential"], """
      The `agent` frame carries the plaintext credential under more than the one
      key IDENTITY-010 names.
      """

      refute inspect(response) =~ credential

      # The scanner works: a value that is persisted is found.
      assert tables_holding(assignment_id) != [], """
      The database scan found the assignment id in no table, so it is not
      reading rows and would report a leak as clean.
      """

      assert tables_holding(credential) == [], """
      The plaintext assignment credential is persisted while the delegation is
      running, in #{inspect(tables_holding(credential))}. IDENTITY-010 says it
      exists only in memory.
      """

      refute log =~ credential

      job =
        Repo.one!(
          from j in Work.Job,
            where: fragment("?->>'assignment_id'", j.delegation) == ^assignment_id
        )

      [{job_pid, _value}] = Horde.Registry.lookup(OpenAgents.HordeRegistry, {:work_job, job.id})
      reference = Process.monitor(job_pid)

      {_result, completion_log} =
        with_log(fn ->
          FakeController.exit(caller, request_id, %{"status" => "completed", "output" => "done"})
          assert_receive {:DOWN, ^reference, :process, ^job_pid, :normal}, 1_000
        end)

      refute completion_log =~ credential

      assert tables_holding(credential) == [], """
      The plaintext assignment credential is persisted once the delegation
      finishes, in #{inspect(tables_holding(credential))} — a report, a journal,
      an activity row, or a projection added since IDENTITY-010's list was
      written.
      """

      assignment = Repo.get!(OpenAgents.Forge.Assignment, assignment_id)
      assert assignment.state == "completed"
      assert Assignments.credential(assignment).revoked_at
      assert AssignmentCredentialVault.take(assignment_id) == nil
    end
  end

  # ── enumeration helpers ────────────────────────────────────────────────

  # Every base table PostgreSQL's own catalog knows about, so a table added
  # later is scanned without anyone adding it here.
  defp base_tables do
    %Postgrex.Result{rows: rows} =
      Repo.query!("""
      SELECT table_name
      FROM information_schema.tables
      WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
      ORDER BY table_name
      """)

    rows |> List.flatten() |> Enum.reject(&(&1 in @unscanned_tables))
  end

  # Casting a row to text renders every column of it, so a new column is
  # covered by the same query as the old ones.
  defp tables_holding(value) do
    Enum.filter(base_tables(), fn table ->
      statement =
        "SELECT count(*) FROM public.\"" <>
          table <> "\" AS scanned WHERE strpos(scanned::text, $1) > 0"

      %Postgrex.Result{rows: [[count]]} = Repo.query!(statement, [value])

      count > 0
    end)
  end

  defp keys_holding(map, value) when is_map(map) do
    for {key, item} <- map, is_binary(item), String.contains?(item, value), do: key
  end

  # Read from each compiled module's import table rather than from source text.
  defp callers_of(mfas) do
    wanted = MapSet.new(mfas)
    {:ok, modules} = :application.get_key(:openagents, :modules)

    Enum.filter(modules, fn module ->
      with path when is_list(path) <- :code.which(module),
           {:ok, {^module, [imports: imports]}} <- :beam_lib.chunks(path, [:imports]) do
        Enum.any?(imports, &MapSet.member?(wanted, &1))
      else
        _unreadable -> false
      end
    end)
  end

  defp assert_exact_set(actual, declared, what) do
    actual = MapSet.new(actual)
    declared = MapSet.new(declared)

    assert MapSet.difference(actual, declared) |> MapSet.to_list() == [],
           """
           Something that #{what} is not named in
           test/openagents/forge/assignment_credential_reach_test.exs. Amend
           IDENTITY-010 in INVARIANTS.md, then add it here.

           Undeclared: #{inspect(MapSet.difference(actual, declared) |> MapSet.to_list())}
           """

    assert MapSet.difference(declared, actual) |> MapSet.to_list() == [],
           """
           This test names something that no longer #{what}. Amend IDENTITY-010
           in INVARIANTS.md, then remove it here.

           Stale: #{inspect(MapSet.difference(declared, actual) |> MapSet.to_list())}
           """
  end

  # ── fixtures ───────────────────────────────────────────────────────────

  defp paired_machine(user, name, agent_ids, scoped_forge_credentials_enabled) do
    assert {:ok, pairing} =
             Machines.start_pairing(%{
               "name" => name,
               "tier" => "curated",
               "platform" => "linux-x64",
               "agent_version" => "0.4.0",
               "roots" => [@root]
             })

    assert {:ok, machine} =
             Machines.approve_pairing(user, pairing.code,
               scoped_forge_credentials_enabled: scoped_forge_credentials_enabled
             )

    assert {:ok, machine} =
             Machines.store_probe(machine, %{
               "acp_agents" => Enum.map(agent_ids, &%{"id" => &1, "version" => "1.0"})
             })

    machine
  end

  defp put_api_token(conn, user, scopes) do
    {:ok, _token, plaintext} =
      ApiTokens.create(user, %{
        name: "Assignment credential reach test",
        scopes: scopes,
        lifetime_days: 1
      })

    put_req_header(conn, "authorization", "Bearer " <> plaintext)
  end
end