%%%------------------------------------------------------------------- %%% @doc HK-AGENT / HK-BROWSER boundary :: the typed browser %%% capability interface and its authorization + argument checks. %%% %%% This module is the *only* place that (a) enumerates which %%% browser operations exist, (b) decides whether a given agent's %%% granted capability list permits a specific call, and %%% (c) validates that call's arguments -- before anything reaches %%% `hk_browser_session'. Per the security model %%% ("Validate all tool arguments at the control-plane boundary" / %%% "Do not treat an agent prompt as a security boundary"), this %%% check happens in `hk_agent_fsm' (the control plane), not inside %%% whatever produced the agent's plan. An agent with no granted %%% capability for an operation cannot reach Chromium for it at all %%% -- there is no fallback "just try it" path. %%% @end %%%------------------------------------------------------------------- -module(hk_browser_capability). -export([operations/0, is_granted/2, validate_args/2]). -type operation() :: create_session | close_session | open_tab | close_tab | navigate | back | forward | reload | read_page | query_element | click | type | scroll | screenshot | extract_links. -export_type([operation/0]). -spec operations() -> [operation()]. operations() -> [create_session, close_session, open_tab, close_tab, navigate, back, forward, reload, read_page, query_element, click, type, scroll, screenshot, extract_links]. %% @doc `Capabilities' is the list granted to an agent (see %% `#agent.capabilities'). A capability entry is either a bare atom %% (unrestricted grant for that operation) or `{Op, Constraints}' %% for operations that support finer scoping -- currently `navigate' %% supports `#{allowed_domains => [binary()]}'. -spec is_granted([term()], operation() | {operation(), map()}) -> boolean(). is_granted(Capabilities, {navigate, #{url := Url}}) -> lists:any( fun (navigate) -> true; ({navigate, #{allowed_domains := Domains}}) -> host_allowed(Url, Domains); (_) -> false end, Capabilities); is_granted(Capabilities, Op) when is_atom(Op) -> lists:any( fun (G) when G =:= Op -> true; ({G, _}) when G =:= Op -> true; (_) -> false end, Capabilities). %% @doc Structural + semantic validation of an operation's arguments. %% Returns `{ok, NormalizedArgs}' or `{error, Reason}'. This is %% deliberately independent of authorization (`is_granted/2') -- %% malformed arguments are rejected the same way whether or not the %% operation would otherwise be permitted. -spec validate_args(operation(), map()) -> {ok, map()} | {error, term()}. validate_args(navigate, #{url := Url} = Args) when is_binary(Url) -> case valid_url(Url) of true -> {ok, Args}; false -> {error, {invalid_url, Url}} end; validate_args(navigate, _) -> {error, missing_url}; validate_args(open_tab, #{url := Url} = Args) when is_binary(Url) -> case valid_url(Url) of true -> {ok, Args}; false -> {error, {invalid_url, Url}} end; validate_args(Op, #{selector := Selector} = Args) when Op =:= click; Op =:= type; Op =:= query_element -> case is_binary(Selector) andalso byte_size(Selector) > 0 andalso byte_size(Selector) < 2048 of true when Op =:= type -> case Args of #{text := Text} when is_binary(Text), byte_size(Text) =< 10000 -> {ok, Args}; #{text := _} -> {error, text_too_long}; _ -> {error, missing_text} end; true -> {ok, Args}; false -> {error, invalid_selector} end; validate_args(scroll, #{delta_y := Dy} = Args) when is_integer(Dy), Dy >= -100000, Dy =< 100000 -> {ok, Args}; validate_args(Op, Args) when Op =:= close_session; Op =:= close_tab; Op =:= back; Op =:= forward; Op =:= reload; Op =:= read_page; Op =:= screenshot; Op =:= extract_links; Op =:= create_session -> {ok, Args}; validate_args(Op, _Args) -> {error, {invalid_arguments, Op}}. %%% internal valid_url(Url) -> case uri_string:parse(Url) of #{scheme := Scheme} when Scheme =:= <<"http">>; Scheme =:= <<"https">>; Scheme =:= <<"about">>; Scheme =:= <<"data">> -> true; _ -> false end. host_allowed(Url, Domains) -> case uri_string:parse(Url) of #{host := Host} -> lists:member(Host, Domains); _ -> false end.