Current section
Files
Jump to
Current section
Files
src/tools/code/adk_code_toolset.erl
%% @doc Strict model toolset boundary for an external code sandbox.
%%
%% The toolset validates and bounds every model-controlled field before a
%% trusted `adk_code_executor' adapter sees it. It never evaluates code in the
%% VM, invokes a shell, opens a port, or accepts an executable path.
-module(adk_code_toolset).
-export([new/1, compile/1, capabilities/0, schemas/1, resolved_call/4]).
-define(MAX_LANGUAGES, 32).
-define(MAX_LANGUAGE_BYTES, 64).
-define(MAX_CODE_CEILING, 1048576).
-define(MAX_STDIN_CEILING, 1048576).
-define(MAX_FILES_CEILING, 64).
-define(MAX_FILE_CEILING, 4194304).
-define(MAX_TOTAL_FILES_CEILING, 16777216).
-define(MAX_OUTPUT_CEILING, 8388608).
-define(MAX_TIMEOUT_MS, 300000).
-type compiled() :: map().
-export_type([compiled/0]).
-spec new(map()) ->
{ok, adk_toolset:descriptor()} | {error, term()}.
new(Config) ->
case compile(Config) of
{ok, Compiled} -> adk_toolset:new(?MODULE, Compiled);
{error, _} = Error -> Error
end.
-spec compile(map()) -> {ok, compiled()} | {error, term()}.
compile(Config) when is_map(Config) ->
Known = [executor, languages, max_code_bytes, max_stdin_bytes,
max_files, max_file_bytes, max_total_file_bytes,
max_output_bytes, timeout, parallel_safe, context_keys],
Unknown = maps:without(Known, Config),
Executor = maps:get(executor, Config, undefined),
Languages0 = maps:get(languages, Config, []),
Limits = #{max_code_bytes => maps:get(max_code_bytes, Config, 65536),
max_stdin_bytes => maps:get(max_stdin_bytes, Config, 65536),
max_files => maps:get(max_files, Config, 16),
max_file_bytes => maps:get(max_file_bytes, Config, 1048576),
max_total_file_bytes => maps:get(
max_total_file_bytes, Config,
4194304),
max_output_bytes => maps:get(max_output_bytes, Config,
1048576)},
Timeout = maps:get(timeout, Config, 30000),
ParallelSafe = maps:get(parallel_safe, Config, false),
ContextKeys = maps:get(context_keys, Config,
[invocation_id, session_id, trace_id]),
case {map_size(Unknown), validate_executor(Executor),
validate_languages(Languages0), validate_limits(Limits),
valid_timeout(Timeout), is_boolean(ParallelSafe),
validate_context_keys(ContextKeys)} of
{Size, _, _, _, _, _, _} when Size > 0 ->
{error, {unknown_code_toolset_config,
lists:sort(maps:keys(Unknown))}};
{_, {error, _} = Error, _, _, _, _, _} -> Error;
{_, _, {error, _} = Error, _, _, _, _} -> Error;
{_, _, _, {error, _} = Error, _, _, _} -> Error;
{_, _, _, _, false, _, _} -> {error, invalid_code_timeout};
{_, _, _, _, _, false, _} -> {error, invalid_parallel_safe};
{_, _, _, _, _, _, {error, _} = Error} -> Error;
{0, {ok, SafeExecutor}, {ok, Languages}, {ok, SafeLimits},
true, true, {ok, SafeContextKeys}} ->
{ok, #{executor => SafeExecutor,
languages => Languages,
limits => SafeLimits,
timeout => Timeout,
parallel_safe => ParallelSafe,
context_keys => SafeContextKeys}}
end;
compile(_Config) ->
{error, invalid_code_toolset_config}.
-spec capabilities() -> map().
capabilities() ->
#{external_sandbox_required => true,
in_process_execution => false,
bounded_input => true,
bounded_output => true,
shell_fallback => false}.
-spec schemas(compiled()) -> [map()].
schemas(#{languages := Languages}) ->
[#{<<"name">> => <<"execute_code">>,
<<"description">> =>
<<"Execute code in the application's isolated external sandbox">>,
<<"parameters">> =>
#{<<"type">> => <<"object">>,
<<"properties">> =>
#{<<"language">> =>
#{<<"type">> => <<"string">>,
<<"enum">> => Languages},
<<"code">> => #{<<"type">> => <<"string">>},
<<"stdin">> => #{<<"type">> => <<"string">>},
<<"files">> =>
#{<<"type">> => <<"array">>,
<<"items">> =>
#{<<"type">> => <<"object">>,
<<"properties">> =>
#{<<"name">> =>
#{<<"type">> => <<"string">>},
<<"content">> =>
#{<<"type">> => <<"string">>}},
<<"required">> =>
[<<"name">>, <<"content">>],
<<"additionalProperties">> => false}}},
<<"required">> => [<<"language">>, <<"code">>],
<<"additionalProperties">> => false}}];
schemas(_Invalid) ->
erlang:error(invalid_compiled_code_toolset).
-spec resolved_call(compiled(), binary(), map(), map()) ->
{ok, adk_tool_executor:resolved_call()} | {error, term()}.
resolved_call(Compiled, <<"execute_code">>, Args, Context)
when is_map(Compiled), is_map(Args), is_map(Context) ->
case validate_request(Args, Compiled) of
{ok, Request} ->
SafeContext = sandbox_context(
Context, maps:get(context_keys, Compiled)),
Executor = maps:get(executor, Compiled),
MaxOutput = maps:get(max_output_bytes,
maps:get(limits, Compiled)),
Execute = fun() ->
invoke_executor(Executor, Request, SafeContext, MaxOutput)
end,
{ok, #{name => <<"execute_code">>,
args => Args,
execute => Execute,
parallel_safe => maps:get(parallel_safe, Compiled),
pause_capable => false,
timeout => maps:get(timeout, Compiled)}};
{error, _} = Error -> Error
end;
resolved_call(_Compiled, _Name, _Args, _Context) ->
{error, unknown_tool}.
validate_executor({Module, Handle}) when is_atom(Module),
Module =/= undefined ->
case code:ensure_loaded(Module) of
{module, Module} ->
case erlang:function_exported(Module, execute, 3) of
true -> {ok, {Module, Handle}};
false -> {error, {invalid_code_executor, Module}}
end;
{error, Reason} ->
{error, {code_executor_unavailable, Module, Reason}}
end;
validate_executor(_Executor) ->
{error, invalid_code_executor}.
validate_languages(Languages) when is_list(Languages), Languages =/= [] ->
case bounded_list_length(Languages, ?MAX_LANGUAGES) of
{ok, _} ->
case lists:all(fun valid_language/1, Languages) andalso
length(Languages) =:= length(lists:usort(Languages)) of
true -> {ok, Languages};
false -> {error, invalid_code_languages}
end;
_ -> {error, invalid_code_languages}
end;
validate_languages(_Languages) ->
{error, invalid_code_languages}.
valid_language(Language) when is_binary(Language),
byte_size(Language) > 0,
byte_size(Language) =< ?MAX_LANGUAGE_BYTES ->
valid_utf8(Language) andalso
lists:all(fun(C) ->
(C >= $a andalso C =< $z) orelse
(C >= $0 andalso C =< $9) orelse
C =:= $+ orelse C =:= $- orelse C =:= $_
end, binary_to_list(Language));
valid_language(_) -> false.
validate_limits(Limits) ->
Checks = [{max_code_bytes, ?MAX_CODE_CEILING},
{max_stdin_bytes, ?MAX_STDIN_CEILING},
{max_files, ?MAX_FILES_CEILING},
{max_file_bytes, ?MAX_FILE_CEILING},
{max_total_file_bytes, ?MAX_TOTAL_FILES_CEILING},
{max_output_bytes, ?MAX_OUTPUT_CEILING}],
case lists:all(
fun({Key, Ceiling}) ->
Value = maps:get(Key, Limits),
is_integer(Value) andalso Value > 0 andalso Value =< Ceiling
end, Checks) andalso
maps:get(max_file_bytes, Limits) =<
maps:get(max_total_file_bytes, Limits) of
true -> {ok, Limits};
false -> {error, invalid_code_limits}
end.
validate_context_keys(Keys) when is_list(Keys) ->
Allowed = [invocation_id, session_id, trace_id, principal],
case bounded_list_length(Keys, length(Allowed)) of
{ok, _} ->
case lists:all(fun(Key) -> lists:member(Key, Allowed) end, Keys)
andalso length(Keys) =:= length(lists:usort(Keys)) of
true -> {ok, Keys};
false -> {error, invalid_code_context_keys}
end;
_ -> {error, invalid_code_context_keys}
end;
validate_context_keys(_Keys) -> {error, invalid_code_context_keys}.
validate_request(Args, Compiled) ->
Allowed = [<<"language">>, <<"code">>, <<"stdin">>, <<"files">>],
Unknown = maps:without(Allowed, Args),
Language = maps:get(<<"language">>, Args, undefined),
Code = maps:get(<<"code">>, Args, undefined),
Stdin = maps:get(<<"stdin">>, Args, <<>>),
Files = maps:get(<<"files">>, Args, []),
Limits = maps:get(limits, Compiled),
case {map_size(Unknown),
lists:member(Language, maps:get(languages, Compiled)),
valid_text(Code, maps:get(max_code_bytes, Limits), false),
valid_text(Stdin, maps:get(max_stdin_bytes, Limits), true),
validate_files(Files, Limits)} of
{Size, _, _, _, _} when Size > 0 ->
{error, {invalid_code_request, unknown_fields}};
{0, true, true, true, {ok, SafeFiles}} ->
{ok, #{<<"language">> => Language,
<<"code">> => Code,
<<"stdin">> => Stdin,
<<"files">> => SafeFiles}};
{0, false, _, _, _} ->
{error, {invalid_code_request, language_not_allowed}};
{0, _, false, _, _} ->
{error, {invalid_code_request, invalid_code}};
{0, _, _, false, _} ->
{error, {invalid_code_request, invalid_stdin}};
{0, _, _, _, {error, Reason}} ->
{error, {invalid_code_request, Reason}}
end.
validate_files(Files, Limits) when is_list(Files) ->
case bounded_list_length(Files, maps:get(max_files, Limits)) of
{ok, _} -> validate_files(Files, Limits, [], 0, #{});
_ -> {error, too_many_files}
end;
validate_files(_Files, _Limits) -> {error, invalid_files}.
validate_files([], _Limits, Acc, _Total, _Names) ->
{ok, lists:reverse(Acc)};
validate_files([File | Rest], Limits, Acc, Total0, Names)
when is_map(File) ->
case maps:keys(File) of
Keys when length(Keys) =:= 2 ->
Name = maps:get(<<"name">>, File, undefined),
Content = maps:get(<<"content">>, File, undefined),
Total = case is_binary(Content) of
true -> Total0 + byte_size(Content);
false -> Total0
end,
case valid_file_name(Name) andalso
not maps:is_key(Name, Names) andalso
valid_text(Content, maps:get(max_file_bytes, Limits), true)
andalso Total =< maps:get(max_total_file_bytes, Limits) of
true ->
Safe = #{<<"name">> => Name, <<"content">> => Content},
validate_files(Rest, Limits, [Safe | Acc], Total,
Names#{Name => true});
false -> {error, invalid_file}
end;
_ -> {error, invalid_file}
end;
validate_files(_Improper, _Limits, _Acc, _Total, _Names) ->
{error, invalid_files}.
valid_file_name(Name) when is_binary(Name), byte_size(Name) > 0,
byte_size(Name) =< 256 ->
Parts = binary:split(Name, <<"/">>, [global]),
valid_utf8(Name) andalso
not filename_absolute(Name) andalso
binary:match(Name, <<"\\">>) =:= nomatch andalso
binary:match(Name, <<":">>) =:= nomatch andalso
lists:all(fun(<<>>) -> false;
(<<".">>) -> false;
(<<"..">>) -> false;
(Part) -> binary:match(Part, <<0>>) =:= nomatch
end, Parts);
valid_file_name(_) -> false.
filename_absolute(<<"/", _/binary>>) -> true;
filename_absolute(<<_Drive, $:, _/binary>>) -> true;
filename_absolute(_) -> false.
valid_text(Value, Max, AllowEmpty) when is_binary(Value) ->
Size = byte_size(Value),
(AllowEmpty orelse Size > 0) andalso Size =< Max andalso valid_utf8(Value);
valid_text(_Value, _Max, _AllowEmpty) -> false.
sandbox_context(Context, Keys) ->
Selected = maps:with(Keys, Context),
case adk_json:normalize(adk_secret_redactor:redact(Selected)) of
{ok, Safe} when is_map(Safe) -> Safe;
_ -> #{}
end.
invoke_executor({Module, Handle}, Request, Context, MaxOutput) ->
Raw = try Module:execute(Handle, Request, Context) of
Result -> Result
catch
Class:Reason -> {error, {executor_exception, Class, Reason}}
end,
normalize_executor_result(Raw, MaxOutput).
normalize_executor_result({ok, Output}, MaxOutput) when is_map(Output) ->
case adk_json:normalize(Output) of
{ok, Json} ->
case byte_size(jsx:encode(Json)) =< MaxOutput of
true -> {ok, Json};
false -> {error, code_output_too_large}
end;
{error, _} -> {error, invalid_code_output}
end;
normalize_executor_result({error, Reason}, _MaxOutput) ->
Safe0 = adk_secret_redactor:redact(Reason),
Safe = case adk_json:normalize(Safe0) of
{ok, Json} -> Json;
{error, _} -> <<"redacted_executor_error">>
end,
{error, #{<<"code">> => <<"sandbox_error">>, <<"detail">> => Safe}};
normalize_executor_result(_Other, _MaxOutput) ->
{error, invalid_code_executor_result}.
valid_timeout(Value) ->
is_integer(Value) andalso Value > 0 andalso Value =< ?MAX_TIMEOUT_MS.
bounded_list_length(List, Max) ->
bounded_list_length(List, Max, 0).
bounded_list_length([], _Max, Count) -> {ok, Count};
bounded_list_length([_ | Rest], Max, Count) when Count < Max ->
bounded_list_length(Rest, Max, Count + 1);
bounded_list_length([_ | _], _Max, _Count) -> too_many;
bounded_list_length(_Improper, _Max, _Count) -> improper.
valid_utf8(Value) ->
case unicode:characters_to_binary(Value, utf8, utf8) of
Value -> true;
_ -> false
end.