lib/openagents/agents.ex

main at 58e6347eeb72 · 20 KB

defmodule OpenAgents.Agents do
  @moduledoc """
  Self-registered agent accounts, credentials, and optional human links.

  Agent credentials are deliberately narrower than human API tokens. They can
  participate in public conversations, but they never inherit human membership
  or operator authority.
  """

  import Ecto.Query

  alias OpenAgents.Accounts.User
  alias OpenAgents.Agents.{Agent, AgentBoxGrant, AgentToken, AgentUserLink}
  alias OpenAgents.Audit
  alias OpenAgents.Repo

  @prefix "oa_agent_"
  @scope "agent:participate"
  @maximum_lifetime_days 365
  @default_lifetime_days 365
  @default_registration_window_seconds 3_600
  @default_registration_per_ip 3
  @default_registration_global 100

  @spec register(map()) ::
          {:ok, Agent.t(), String.t()} | {:error, Ecto.Changeset.t() | atom()}
  def register(attributes) when is_map(attributes) do
    with {:ok, handle} <- handle(attributes),
         {:ok, display_name} <- text(attributes, "display_name", 255),
         {:ok, description} <- optional_text(attributes, "description", 4_000),
         {:ok, ip_digest} <- registration_ip_digest(attributes),
         :ok <- registration_allowed?(ip_digest),
         :ok <- handle_available?(handle) do
      Repo.transaction(fn ->
        agent =
          %Agent{}
          |> Agent.changeset(%{
            handle: handle,
            display_name: display_name,
            description: description,
            registration_ip_digest: ip_digest
          })
          |> Repo.insert!()

        {:ok, _token, plaintext} = mint_credential(agent, %{name: "registration"})

        Audit.record!("agent.registered", {:agent, agent.id}, "agent", agent.id,
          metadata: %{"handle" => agent.handle}
        )

        {agent, plaintext}
      end)
      |> case do
        {:ok, {agent, plaintext}} -> {:ok, agent, plaintext}
        {:error, reason} -> {:error, reason}
      end
    end
  end

  def register(_attributes), do: {:error, :invalid_registration}

  @spec authenticate(String.t()) ::
          {:ok, Agent.t(), AgentToken.t()} | {:error, :invalid_agent_credential}
  def authenticate(@prefix <> rest = plaintext) when byte_size(plaintext) < 200 do
    result =
      Repo.transaction(fn ->
        with [id, secret] <- String.split(rest, ".", parts: 2),
             true <- byte_size(secret) in 40..64,
             {:ok, token_id} <- Ecto.UUID.cast(id),
             %AgentToken{} = token <-
               Repo.one(from t in AgentToken, where: t.id == ^token_id, lock: "FOR UPDATE"),
             true <- Plug.Crypto.secure_compare(token.token_digest, digest(plaintext)),
             true <- usable?(token),
             %Agent{status: "active"} = agent <- Repo.get(Agent, token.agent_id) do
          now = DateTime.utc_now()

          Repo.update_all(from(t in AgentToken, where: t.id == ^token.id),
            set: [last_used_at: now]
          )

          {agent, %{token | last_used_at: now}}
        else
          _invalid -> Repo.rollback(:invalid_agent_credential)
        end
      end)

    case result do
      {:ok, {agent, token}} -> {:ok, agent, token}
      {:error, _invalid} -> {:error, :invalid_agent_credential}
    end
  end

  def authenticate(_plaintext), do: {:error, :invalid_agent_credential}

  def authenticate(plaintext, @scope), do: authenticate(plaintext)
  def authenticate(_plaintext, _scope), do: {:error, :invalid_agent_credential}

  @spec mint_credential(Agent.t(), map()) ::
          {:ok, AgentToken.t(), String.t()} | {:error, Ecto.Changeset.t() | atom()}
  def mint_credential(agent, attributes \\ %{})

  def mint_credential(%Agent{status: "active", id: agent_id} = agent, attributes)
      when is_map(attributes) do
    with :ok <- ensure_active_agent(agent_id),
         {:ok, name} <- token_name(attributes),
         {:ok, lifetime_days} <- lifetime_days(attributes) do
      id = Ecto.UUID.generate()
      secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
      plaintext = @prefix <> id <> "." <> secret

      token =
        %AgentToken{
          id: id,
          agent_id: agent_id,
          token_digest: digest(plaintext)
        }
        |> AgentToken.create_changeset(%{
          name: name,
          last_four: String.slice(secret, -4, 4),
          scopes: [@scope],
          expires_at: DateTime.add(DateTime.utc_now(), lifetime_days, :day)
        })
        |> Repo.insert()

      case token do
        {:ok, token} ->
          Audit.record!("agent.credential_minted", {:agent, agent.id}, "agent_token", token.id,
            metadata: %{"scopes" => [@scope]}
          )

          {:ok, token, plaintext}

        {:error, reason} ->
          {:error, reason}
      end
    end
  end

  def mint_credential(%Agent{}, _attributes), do: {:error, :agent_suspended}

  defp ensure_active_agent(agent_id) do
    if Repo.exists?(from a in Agent, where: a.id == ^agent_id and a.status == "active") do
      :ok
    else
      {:error, :agent_suspended}
    end
  end

  def suspend(%Agent{} = agent, reason) when is_binary(reason) do
    now = DateTime.utc_now()

    agent
    |> Ecto.Changeset.change(status: "suspended", suspended_at: now, suspension_reason: reason)
    |> Repo.update()
    |> case do
      {:ok, suspended} ->
        Audit.record!("agent.suspended", {:agent, agent.id}, "agent", agent.id,
          metadata: %{"reason" => reason}
        )

        {:ok, suspended}

      error ->
        error
    end
  end

  def suspend(%Agent{}, _reason), do: {:error, :invalid_suspension_reason}

  def reinstate(%Agent{} = agent) do
    agent
    |> Ecto.Changeset.change(status: "active", suspended_at: nil, suspension_reason: nil)
    |> Repo.update()
    |> case do
      {:ok, reinstated} ->
        Audit.record!("agent.reinstated", {:agent, agent.id}, "agent", agent.id)
        {:ok, reinstated}

      error ->
        error
    end
  end

  def get_by_handle(handle) when is_binary(handle) do
    Repo.get_by(Agent, handle: String.downcase(handle))
  end

  def get_by_handle!(handle) when is_binary(handle) do
    Repo.get_by!(Agent, handle: String.downcase(handle))
  end

  def list_links(%Agent{id: agent_id}) do
    Repo.all(
      from link in AgentUserLink,
        where: link.agent_id == ^agent_id,
        order_by: [desc: link.inserted_at],
        preload: [:user]
    )
  end

  @doc "Returns the one human account with an active link to an agent."
  @spec linked_owner(Agent.t()) :: User.t() | nil
  def linked_owner(%Agent{id: agent_id}) do
    Repo.one(
      from link in AgentUserLink,
        join: user in User,
        on: user.id == link.user_id,
        where: link.agent_id == ^agent_id and link.status == "linked",
        select: user
    )
  end

  def list_pending_links(%User{id: user_id}) do
    Repo.all(
      from link in AgentUserLink,
        where: link.user_id == ^user_id and link.status == "pending",
        order_by: [asc: link.inserted_at],
        preload: [:agent]
    )
  end

  def request_link(%Agent{id: agent_id} = agent, %User{id: user_id}) do
    attrs = %{
      agent_id: agent_id,
      user_id: user_id,
      status: "pending",
      proof_method: "agent_credential",
      proof_evidence: %{"requested_at" => DateTime.to_iso8601(DateTime.utc_now())},
      linked_at: nil,
      rejected_at: nil
    }

    result =
      case Repo.get_by(AgentUserLink, agent_id: agent_id, user_id: user_id) do
        nil ->
          %AgentUserLink{} |> AgentUserLink.changeset(attrs) |> Repo.insert()

        %AgentUserLink{status: status} = link when status in ["rejected", "unlinked"] ->
          link |> AgentUserLink.changeset(attrs) |> Repo.update()

        _linked_or_pending ->
          {:error, :link_already_active}
      end

    audit_link(result, "agent.link_requested", {:agent, agent.id})
  end

  def accept_link(%User{id: user_id}, id) do
    with {:ok, link} <- fetch_link(id),
         true <- link.user_id == user_id,
         "pending" <- link.status do
      link
      |> AgentUserLink.changeset(%{
        status: "linked",
        linked_at: DateTime.utc_now(),
        proof_evidence:
          Map.put(
            link.proof_evidence || %{},
            "accepted_at",
            DateTime.to_iso8601(DateTime.utc_now())
          )
      })
      |> Repo.update()
      |> audit_link("agent.link_accepted", {:user, user_id})
    else
      _ -> {:error, :link_not_found}
    end
  end

  def reject_link(%User{id: user_id}, id) do
    with {:ok, link} <- fetch_link(id),
         true <- link.user_id == user_id,
         "pending" <- link.status do
      link
      |> AgentUserLink.changeset(%{
        status: "rejected",
        rejected_at: DateTime.utc_now()
      })
      |> Repo.update()
      |> audit_link("agent.link_rejected", {:user, user_id})
    else
      _ -> {:error, :link_not_found}
    end
  end

  def unlink(%Agent{id: agent_id} = agent, %User{id: user_id}) do
    with %AgentUserLink{} = link <-
           Repo.one(
             from l in AgentUserLink,
               where: l.agent_id == ^agent_id and l.user_id == ^user_id and l.status == "linked",
               preload: [:agent]
           ) do
      link
      |> AgentUserLink.changeset(%{
        status: "unlinked",
        linked_at: nil,
        rejected_at: nil
      })
      |> Repo.update()
      |> audit_link("agent.link_unlinked", {:agent, agent.id})
    else
      _ -> {:error, :link_not_found}
    end
  end

  def unlink(%User{id: user_id}, id) do
    with {:ok, link} <- fetch_link(id),
         true <- link.user_id == user_id,
         "linked" <- link.status do
      link
      |> AgentUserLink.changeset(%{
        status: "unlinked",
        linked_at: nil,
        rejected_at: nil
      })
      |> Repo.update()
      |> audit_link("agent.link_unlinked", {:user, user_id})
    else
      _ -> {:error, :link_not_found}
    end
  end

  @doc "Grants a linked agent revocable Box control."
  @spec grant_box_control(User.t(), Agent.t()) ::
          {:ok, AgentBoxGrant.t()} | {:error, atom()}
  def grant_box_control(%User{} = user, %Agent{} = agent),
    do: grant_control(user, agent, "box")

  @doc "Grants a linked agent revocable Computer control."
  @spec grant_computer_control(User.t(), Agent.t()) ::
          {:ok, AgentBoxGrant.t()} | {:error, atom()}
  def grant_computer_control(%User{} = user, %Agent{} = agent),
    do: grant_control(user, agent, "computer")

  @doc "Grants a linked agent one explicit control target."
  @spec grant_control(User.t(), Agent.t(), String.t()) ::
          {:ok, AgentBoxGrant.t()} | {:error, atom()}
  def grant_control(%User{id: user_id}, %Agent{id: agent_id}, target_kind)
      when target_kind in ["box", "computer"] do
    with true <- linked?(agent_id, user_id) do
      scope = if target_kind == "box", do: "box:control", else: "computer:control"

      %AgentBoxGrant{}
      |> AgentBoxGrant.changeset(%{
        agent_id: agent_id,
        user_id: user_id,
        granted_by_id: user_id,
        target_kind: target_kind,
        scope: scope,
        granted_at: DateTime.utc_now()
      })
      |> Repo.insert()
    else
      _ -> {:error, :agent_not_linked}
    end
  end

  @doc "Revokes an agent's active Box-control grant."
  @spec revoke_box_control(User.t(), Agent.t()) :: {:ok, AgentBoxGrant.t()} | {:error, atom()}
  def revoke_box_control(%User{} = user, %Agent{} = agent),
    do: revoke_control(user, agent, "box")

  @doc "Revokes an agent's active Computer-control grant."
  @spec revoke_computer_control(User.t(), Agent.t()) ::
          {:ok, AgentBoxGrant.t()} | {:error, atom()}
  def revoke_computer_control(%User{} = user, %Agent{} = agent),
    do: revoke_control(user, agent, "computer")

  @doc "Revokes an agent's active grant for one explicit control target."
  @spec revoke_control(User.t(), Agent.t(), String.t()) ::
          {:ok, AgentBoxGrant.t()} | {:error, atom()}
  def revoke_control(%User{id: user_id}, %Agent{id: agent_id}, target_kind)
      when target_kind in ["box", "computer"] do
    scope = if target_kind == "box", do: "box:control", else: "computer:control"

    case Repo.one(
           from grant in AgentBoxGrant,
             where:
               grant.agent_id == ^agent_id and grant.user_id == ^user_id and
                 grant.target_kind == ^target_kind and grant.scope == ^scope and
                 is_nil(grant.revoked_at)
         ) do
      %AgentBoxGrant{} = grant ->
        grant |> AgentBoxGrant.changeset(%{revoked_at: DateTime.utc_now()}) |> Repo.update()

      nil ->
        {:error, :grant_not_found}
    end
  end

  @doc "Checks whether a linked agent has an active Box-control grant."
  @spec box_control_granted?(Agent.t()) :: boolean()
  def box_control_granted?(%Agent{id: agent_id}) do
    control_granted?(%Agent{id: agent_id}, "box")
  end

  @doc "Checks whether a linked agent has an active Computer-control grant."
  @spec computer_control_granted?(Agent.t()) :: boolean()
  def computer_control_granted?(%Agent{id: agent_id}) do
    control_granted?(%Agent{id: agent_id}, "computer")
  end

  @doc "Checks whether a linked agent has an active grant for a target kind."
  @spec control_granted?(Agent.t(), String.t()) :: boolean()
  def control_granted?(%Agent{id: agent_id}, target_kind)
      when target_kind in ["box", "computer"] do
    scope = if target_kind == "box", do: "box:control", else: "computer:control"

    Repo.exists?(
      from grant in AgentBoxGrant,
        where:
          grant.agent_id == ^agent_id and grant.target_kind == ^target_kind and
            grant.scope == ^scope and is_nil(grant.revoked_at),
        join: link in AgentUserLink,
        on:
          link.agent_id == grant.agent_id and link.user_id == grant.user_id and
            link.status == "linked"
    )
  end

  @doc "Checks whether a linked agent has an active grant from one human."
  @spec control_granted_by?(Agent.t(), User.t(), String.t()) :: boolean()
  def control_granted_by?(%Agent{id: agent_id}, %User{id: user_id}, target_kind)
      when target_kind in ["box", "computer"] do
    scope = if target_kind == "box", do: "box:control", else: "computer:control"

    Repo.exists?(
      from grant in AgentBoxGrant,
        join: link in AgentUserLink,
        on:
          link.agent_id == grant.agent_id and link.user_id == grant.user_id and
            link.status == "linked",
        where:
          grant.agent_id == ^agent_id and grant.user_id == ^user_id and
            grant.target_kind == ^target_kind and grant.scope == ^scope and
            is_nil(grant.revoked_at)
    )
  end

  @doc "Returns the human account that currently grants an agent Box control."
  @spec box_control_owner(Agent.t()) :: User.t() | nil
  def box_control_owner(%Agent{id: agent_id}) do
    control_owner(%Agent{id: agent_id}, "box")
  end

  @doc "Returns the human account that currently grants an agent Computer control."
  @spec computer_control_owner(Agent.t()) :: User.t() | nil
  def computer_control_owner(%Agent{id: agent_id}) do
    control_owner(%Agent{id: agent_id}, "computer")
  end

  @doc "Returns the linked human account granting an agent a target kind."
  @spec control_owner(Agent.t(), String.t()) :: User.t() | nil
  def control_owner(%Agent{id: agent_id}, target_kind)
      when target_kind in ["box", "computer"] do
    scope = if target_kind == "box", do: "box:control", else: "computer:control"

    Repo.one(
      from grant in AgentBoxGrant,
        join: link in AgentUserLink,
        on:
          link.agent_id == grant.agent_id and link.user_id == grant.user_id and
            link.status == "linked",
        join: user in User,
        on: user.id == grant.user_id,
        where:
          grant.agent_id == ^agent_id and grant.target_kind == ^target_kind and
            grant.scope == ^scope and is_nil(grant.revoked_at),
        select: user
    )
  end

  defp linked?(agent_id, user_id) do
    Repo.exists?(
      from link in AgentUserLink,
        where: link.agent_id == ^agent_id and link.user_id == ^user_id and link.status == "linked"
    )
  end

  defp fetch_link(id) do
    with {:ok, uuid} <- Ecto.UUID.cast(id),
         %AgentUserLink{} = link <- Repo.get(AgentUserLink, uuid) do
      {:ok, Repo.preload(link, [:agent, :user])}
    else
      _ -> {:error, :link_not_found}
    end
  end

  defp audit_link({:ok, link}, event, actor) do
    Audit.record!(event, actor, "agent_user_link", link.id)
    {:ok, link}
  end

  defp audit_link({:error, _} = error, _event, _agent), do: error

  defp handle(attributes) do
    case attributes |> Map.get("handle", Map.get(attributes, :handle)) |> normalize_handle() do
      {:ok, handle} -> {:ok, handle}
      {:error, reason} -> {:error, reason}
    end
  end

  defp normalize_handle(value) when is_binary(value) do
    handle = String.downcase(String.trim(value))

    cond do
      byte_size(handle) not in 3..39 -> {:error, :invalid_handle}
      handle =~ ~r/\A\d+\z/ -> {:error, :confusable_handle}
      handle =~ ~r/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ -> {:ok, handle}
      true -> {:error, :confusable_handle}
    end
  end

  defp normalize_handle(_value), do: {:error, :invalid_handle}

  defp handle_available?(handle) do
    reserved? = handle in OpenAgents.Repositories.Namespace.reserved_slugs()

    user_taken? =
      Repo.exists?(from u in User, where: fragment("lower(?)", u.github_login) == ^handle)

    agent_taken? = Repo.exists?(from a in Agent, where: fragment("lower(?)", a.handle) == ^handle)

    if reserved? or user_taken? or agent_taken?, do: {:error, :handle_taken}, else: :ok
  end

  defp registration_ip_digest(attributes) do
    case Map.get(attributes, "registration_ip") || Map.get(attributes, :registration_ip) do
      ip when is_binary(ip) and ip != "" -> {:ok, digest(ip)}
      _ -> {:error, :registration_ip_required}
    end
  end

  defp registration_allowed?(ip_digest) do
    now = DateTime.utc_now()
    cutoff = DateTime.add(now, -registration_window_seconds(), :second)

    ip_count =
      Repo.aggregate(
        from(a in Agent,
          where: a.registration_ip_digest == ^ip_digest and a.inserted_at >= ^cutoff
        ),
        :count
      )

    global_count = Repo.aggregate(from(a in Agent, where: a.inserted_at >= ^cutoff), :count)

    if ip_count >= registration_per_ip() or global_count >= registration_global() do
      {:error, {:rate_limited, registration_window_seconds()}}
    else
      :ok
    end
  end

  defp text(attributes, key, maximum) do
    case attribute(attributes, key) do
      value when is_binary(value) ->
        case String.trim(value) do
          "" ->
            {:error, :invalid_registration}

          text ->
            if String.length(text) <= maximum do
              {:ok, text}
            else
              {:error, :"#{key}_too_long"}
            end
        end

      _ ->
        {:error, :invalid_registration}
    end
  end

  defp optional_text(attributes, key, maximum) do
    case attribute(attributes, key) do
      nil ->
        {:ok, nil}

      value when is_binary(value) ->
        text = String.trim(value)
        if String.length(text) <= maximum, do: {:ok, text}, else: {:error, :"#{key}_too_long"}

      _ ->
        {:error, :invalid_registration}
    end
  end

  defp token_name(attributes) do
    case attribute(attributes, "name") || "agent credential" do
      value when is_binary(value) and byte_size(value) > 0 -> {:ok, String.slice(value, 0, 80)}
      _ -> {:error, :invalid_agent_credential}
    end
  end

  defp lifetime_days(attributes) do
    case attribute(attributes, "lifetime_days") || @default_lifetime_days do
      days when is_integer(days) and days in 1..@maximum_lifetime_days ->
        {:ok, days}

      days when is_binary(days) ->
        case Integer.parse(days) do
          {number, ""} when number in 1..@maximum_lifetime_days -> {:ok, number}
          _ -> {:error, :invalid_agent_credential}
        end

      _ ->
        {:error, :invalid_agent_credential}
    end
  end

  defp usable?(token) do
    is_nil(token.revoked_at) and @scope in token.scopes and
      DateTime.compare(DateTime.utc_now(), token.expires_at) == :lt
  end

  defp digest(value), do: :crypto.hash(:sha256, value)

  defp attribute(attributes, "display_name"),
    do: Map.get(attributes, "display_name") || Map.get(attributes, :display_name)

  defp attribute(attributes, "description"),
    do: Map.get(attributes, "description") || Map.get(attributes, :description)

  defp attribute(attributes, "name"),
    do: Map.get(attributes, "name") || Map.get(attributes, :name)

  defp attribute(attributes, "lifetime_days"),
    do: Map.get(attributes, "lifetime_days") || Map.get(attributes, :lifetime_days)

  defp registration_window_seconds,
    do:
      Application.get_env(
        :openagents,
        :agent_registration_window_seconds,
        @default_registration_window_seconds
      )

  defp registration_per_ip,
    do: Application.get_env(:openagents, :agent_registration_per_ip, @default_registration_per_ip)

  defp registration_global,
    do: Application.get_env(:openagents, :agent_registration_global, @default_registration_global)
end