Packages
execution_plane_process
0.1.0
Execution Plane process launch, stdio, PTY, and process-session runtime.
Current section
Files
Jump to
Current section
Files
lib/execution_plane/process/tre_rhai.ex
defmodule ExecutionPlane.Process.TreRhai do
@moduledoc """
Local TRE/Rhai process lane for invoking `rex-runner`.
The caller supplies a governed TRE envelope with refs and hashes. This module
resolves script/policy material through an explicit materializer, writes a
bounded local runner workspace, invokes the runner with a cleared environment,
and returns a structured receipt. It does not accept raw policy/script material
in the public envelope.
"""
alias ExecutionPlane.Runtimes.Process, as: ProcessRuntime
alias ExecutionPlane.Runtimes.Process.{Exit, RunResult}
alias GroundPlane.Boundary.Codec, as: BoundaryCodec
@version "nshkr.execution_plane.tre.v1"
@receipt_contract "ExecutionPlane.TreRhaiReceipt.v1"
@runner_output_format "json"
@default_max_wall_clock_ms 30_000
@lane_max_wall_clock_ms 300_000
@lane_max_output_bytes 1_048_576
@lane_max_artifact_bytes 104_857_600
@raw_material_keys MapSet.new(~w(
cedar_entities
cedar_policy
cedar_policy_source
cedar_policy_text
cedar_schema
cedar_schema_text
policy_source
policy_text
schema_text
script_source
))
@required_refs ~w(
authority_ref
policy_bundle_ref
policy_bundle_hash
cedar_schema_ref
cedar_schema_hash
script_ref
script_hash
trace_id
)
@type receipt :: map()
@type materializer :: (map() -> {:ok, map()} | {:error, term()})
@spec execute(map() | keyword(), keyword()) :: {:ok, receipt()} | {:error, receipt()}
def execute(envelope_attrs, opts \\ []) when is_list(opts) do
envelope = normalize_map(envelope_attrs)
case validate_envelope(envelope) do
:ok ->
execute_validated(envelope, opts)
{:deny, failure_class, reason} ->
{:error, receipt(envelope, "denied", false, failure_class, reason, %{})}
{:error, failure_class, reason} ->
{:error, receipt(envelope, "failed", false, failure_class, reason, %{})}
end
end
defp execute_validated(envelope, opts) do
root = Keyword.get_lazy(opts, :work_root, &default_work_root/0)
cleanup? = Keyword.get(opts, :cleanup?, true)
File.mkdir_p!(root)
try do
with {:ok, material} <- materialize(envelope, opts),
:ok <- verify_material(envelope, material, opts),
{:ok, files} <- write_runner_files(root, envelope, material),
{:ok, runner_path} <- runner_path(opts),
{:ok, run_result} <- run_runner(runner_path, files, envelope) do
receipt_from_runner(envelope, run_result, files, runner_path)
else
{:error, failure_class, reason} ->
{:error, receipt(envelope, "failed", false, failure_class, reason, %{})}
end
after
if cleanup?, do: File.rm_rf(root)
end
end
defp validate_envelope(envelope) do
with :ok <- require_version(envelope),
:ok <- reject_raw_material(envelope),
:ok <- require_refs(envelope),
:ok <- require_limits(envelope),
:ok <- reject_unresolved_scopes(envelope) do
require_declared_actions_allowed(envelope)
end
end
defp require_version(%{"version" => @version}), do: :ok
defp require_version(%{"version" => version}) do
{:error, "invalid_envelope", "unsupported TRE envelope version #{inspect(version)}"}
end
defp require_version(_envelope),
do: {:error, "invalid_envelope", "missing TRE envelope version"}
defp require_refs(envelope) do
missing =
Enum.reject(@required_refs, fn key ->
present_string?(Map.get(envelope, key))
end)
case missing do
[] -> :ok
keys -> {:error, "invalid_envelope", "missing required TRE refs: #{Enum.join(keys, ", ")}"}
end
end
defp require_limits(envelope) do
case Map.get(envelope, "limits") do
%{} = limits ->
validate_limits(limits)
_other ->
{:error, "limits_missing", "TRE limits are required"}
end
end
defp validate_limits(limits) do
with :ok <- validate_positive_limit(limits, "wall_clock_ms", @lane_max_wall_clock_ms),
:ok <- validate_positive_limit(limits, "max_output_bytes", @lane_max_output_bytes),
:ok <- validate_positive_limit(limits, "max_artifact_bytes", @lane_max_artifact_bytes) do
validate_zero_limit(limits, "max_process_spawns")
end
end
defp validate_positive_limit(limits, key, max) do
value = Map.get(limits, key)
cond do
not positive_int?(value) ->
{:error, "limits_invalid", "limits.#{key} must be positive"}
value > max ->
{:error, "limits_invalid", "limits.#{key} exceeds lane maximum"}
true ->
:ok
end
end
defp validate_zero_limit(limits, key) do
value = Map.get(limits, key)
cond do
not non_negative_int?(value) ->
{:error, "limits_invalid", "limits.#{key} must be present"}
value != 0 ->
{:deny, "policy_denied", "local TRE lane requires #{key}=0"}
true ->
:ok
end
end
defp reject_unresolved_scopes(envelope) do
envelope
|> string_list("resource_scope_refs")
|> Enum.any?(&String.starts_with?(&1, "unresolved://"))
|> case do
true -> {:deny, "resource_scope_unresolvable", "resource scope refs must resolve"}
false -> :ok
end
end
defp require_declared_actions_allowed(envelope) do
declared = string_list(envelope, "declared_actions")
allowed = string_list(envelope, "allowed_actions")
denied = declared -- allowed
case denied do
[] ->
:ok
actions ->
{:deny, "policy_denied", "declared actions not allowed: #{Enum.join(actions, ", ")}"}
end
end
defp reject_raw_material(value) do
if raw_material?(value) do
{:deny, "raw_material_rejected",
"TRE envelope must carry refs and hashes, not raw Cedar/Rhai material"}
else
:ok
end
end
defp materialize(envelope, opts) do
case Keyword.get(opts, :materializer) do
fun when is_function(fun, 1) ->
case fun.(envelope) do
{:ok, %{} = material} ->
{:ok, normalize_map(material)}
{:error, reason} ->
{:error, "materialization_failed", inspect(reason)}
other ->
{:error, "materialization_failed", "invalid materializer result #{inspect(other)}"}
end
_other ->
{:error, "materializer_missing", "TRE lane requires an explicit materializer"}
end
end
defp verify_material(envelope, material, opts) do
with {:ok, script_source} <- material_source(material, "script_source"),
{:ok, _policy_source} <- material_source(material, "policy_source") do
if Keyword.get(opts, :verify_script_hash?, true) do
verify_hash("script_hash", envelope["script_hash"], script_source)
else
:ok
end
end
end
defp material_source(material, key) do
case Map.get(material, key) do
source when is_binary(source) and source != "" -> {:ok, source}
_other -> {:error, "materialization_failed", "#{key} is required"}
end
end
defp verify_hash(field, expected, source) do
actual = sha256(source)
if actual == expected do
:ok
else
{:error, "hash_mismatch", "#{field} #{expected} does not match materialized source"}
end
end
defp write_runner_files(root, envelope, material) do
script_file = Path.join(root, "script.rhai")
policy_file = Path.join(root, "policy.cedar")
args_file = Path.join(root, "script-arguments.json")
envelope_file = Path.join(root, "runner-envelope.json")
script_source = Map.fetch!(material, "script_source")
policy_source = Map.fetch!(material, "policy_source")
script_arguments = Map.get(material, "script_arguments", %{})
runner_envelope =
envelope
|> Map.take(
@required_refs ++ ["declared_actions", "allowed_actions", "resource_scope_refs", "limits"]
)
|> Map.put("runner_contract", @version)
|> Map.put("script_arguments_hash", boundary_digest(script_arguments))
|> Map.put("materialized_files", %{
"script_file" => "script.rhai",
"policy_file" => "policy.cedar",
"script_arguments_file" => "script-arguments.json"
})
File.write!(script_file, script_source)
File.write!(policy_file, policy_source)
File.write!(args_file, Jason.encode!(script_arguments))
File.write!(envelope_file, Jason.encode!(runner_envelope))
{:ok,
%{
"script_file" => script_file,
"policy_file" => policy_file,
"script_arguments_file" => args_file,
"runner_envelope_file" => envelope_file,
"runner_envelope_hash" => boundary_digest(runner_envelope)
}}
rescue
error -> {:error, "materialization_failed", Exception.message(error)}
end
defp runner_path(opts) do
case Keyword.get(opts, :runner_path) || System.find_executable("rex-runner") do
path when is_binary(path) and path != "" ->
if File.exists?(path) do
{:ok, path}
else
{:error, "runner_unavailable", "TRE runner not found at #{path}"}
end
_other ->
{:error, "runner_unavailable", "rex-runner was not found"}
end
end
defp run_runner(runner_path, files, envelope) do
timeout_ms = get_in(envelope, ["limits", "wall_clock_ms"]) || @default_max_wall_clock_ms
case ProcessRuntime.run(
command: runner_path,
argv: [
"--script-file",
files["script_file"],
"--policy-file",
files["policy_file"],
"--script-arguments-file",
files["script_arguments_file"],
"--output-format",
@runner_output_format
],
cwd: Path.dirname(files["runner_envelope_file"]),
env: %{},
clear_env?: true,
timeout: timeout_ms,
stderr: :separate,
close_stdin: true,
surface_kind: "local_subprocess"
) do
{:ok, %RunResult{} = result} -> {:ok, result}
{:error, reason} -> {:error, "runner_failed", inspect(reason)}
end
end
defp receipt_from_runner(envelope, %RunResult{} = result, files, runner_path) do
max_output_bytes = get_in(envelope, ["limits", "max_output_bytes"]) || @lane_max_output_bytes
cond do
byte_size(result.stdout) > max_output_bytes ->
{:error,
receipt(
envelope,
"failed",
true,
"output_limit_exceeded",
"runner output exceeded limit",
files
)}
not Exit.successful?(result.exit) ->
{:error,
receipt(
envelope,
"failed",
true,
"runner_failed",
"runner exited non-zero",
files,
%{result: result, runner_path: runner_path}
)}
true ->
classify_runner_output(envelope, result, files, runner_path)
end
end
defp classify_runner_output(envelope, %RunResult{} = result, files, runner_path) do
runtime = %{result: result, runner_path: runner_path}
case Jason.decode(result.stdout) do
{:ok, %{"status" => "SUCCESS"} = output} ->
{:ok,
receipt(
envelope,
"succeeded",
true,
nil,
nil,
files,
Map.put(runtime, :runner_output, output)
)}
{:ok,
%{"status" => "ERROR", "error" => %{"error_type" => "ACCESS_DENIED_EXCEPTION"} = error} =
output} ->
{:error,
receipt(
envelope,
"denied",
true,
"policy_denied",
Map.get(error, "message", "runner denied operation"),
files,
Map.put(runtime, :runner_output, output)
)}
{:ok, %{"status" => "ERROR", "error" => %{} = error} = output} ->
{:error,
receipt(
envelope,
"failed",
true,
"runner_error",
Map.get(error, "message", "runner returned error"),
files,
Map.put(runtime, :runner_output, output)
)}
{:ok, output} ->
{:error,
receipt(
envelope,
"failed",
true,
"runner_error",
"runner returned unsupported output status",
files,
Map.put(runtime, :runner_output, output)
)}
{:error, error} ->
{:error,
receipt(
envelope,
"failed",
true,
"runner_output_invalid",
Exception.message(error),
files,
runtime
)}
end
end
defp receipt(envelope, status, spawned?, failure_class, failure_reason, files, runtime \\ %{})
defp receipt(
envelope,
status,
spawned?,
failure_class,
failure_reason,
files,
runtime
) do
result = Map.get(runtime, :result)
runner_path = Map.get(runtime, :runner_path)
runner_output = Map.get(runtime, :runner_output, %{})
receipt_ref =
"execution-plane-tre-receipt://#{URI.encode_www_form(envelope["trace_id"] || "unknown")}/#{status}"
%{
"contract_version" => @receipt_contract,
"receipt_ref" => receipt_ref,
"status" => status,
"spawned?" => spawned?,
"env_policy" => "clear",
"runner" => "rex-runner",
"runner_name" => runner_path && Path.basename(runner_path),
"runner_exit" => runner_exit(result),
"runner_output" => runner_output,
"runner_output_hash" => result && sha256(result.stdout || ""),
"runner_stderr_hash" => result && sha256(result.stderr || ""),
"runner_envelope_hash" => Map.get(files, "runner_envelope_hash"),
"authority_ref" => envelope["authority_ref"],
"policy_bundle_ref" => envelope["policy_bundle_ref"],
"policy_bundle_hash" => envelope["policy_bundle_hash"],
"cedar_schema_ref" => envelope["cedar_schema_ref"],
"cedar_schema_hash" => envelope["cedar_schema_hash"],
"script_ref" => envelope["script_ref"],
"script_hash" => envelope["script_hash"],
"trace_id" => envelope["trace_id"],
"resource_scope_refs" => string_list(envelope, "resource_scope_refs"),
"declared_actions" => string_list(envelope, "declared_actions"),
"allowed_actions" => string_list(envelope, "allowed_actions"),
"limits" => Map.get(envelope, "limits", %{}),
"artifact_refs" => artifact_refs(envelope, status),
"event_refs" => event_refs(envelope, status),
"failure_class" => failure_class,
"failure_reason" => failure_reason
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
end
defp artifact_refs(envelope, "succeeded") do
["tre-artifact://#{URI.encode_www_form(envelope["trace_id"])}/runner-output"]
end
defp artifact_refs(_envelope, _status), do: []
defp event_refs(envelope, status) do
["tre-event://#{URI.encode_www_form(envelope["trace_id"] || "unknown")}/#{status}"]
end
defp runner_exit(nil), do: nil
defp runner_exit(%RunResult{exit: %Exit{} = exit}) do
Exit.to_map(exit)
|> stringify_keys()
end
defp normalize_map(attrs) when is_list(attrs), do: attrs |> Map.new() |> normalize_map()
defp normalize_map(%{} = attrs) do
Map.new(attrs, fn {key, value} -> {to_string(key), normalize_value(value)} end)
end
defp normalize_value(%{} = value), do: normalize_map(value)
defp normalize_value(values) when is_list(values), do: Enum.map(values, &normalize_value/1)
defp normalize_value(value), do: value
defp stringify_keys(%{} = attrs),
do: Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
defp string_list(envelope, key) do
case Map.get(envelope, key, []) do
values when is_list(values) -> Enum.map(values, &to_string/1)
nil -> []
value -> [to_string(value)]
end
end
defp present_string?(value), do: is_binary(value) and String.trim(value) != ""
defp positive_int?(value), do: is_integer(value) and value > 0
defp non_negative_int?(value), do: is_integer(value) and value >= 0
defp raw_material?(%{} = value) do
Enum.any?(value, fn {key, nested_value} ->
MapSet.member?(@raw_material_keys, to_string(key)) or raw_material?(nested_value)
end)
end
defp raw_material?(values) when is_list(values), do: Enum.any?(values, &raw_material?/1)
defp raw_material?(_value), do: false
defp sha256(value) do
"sha256:" <> Base.encode16(:crypto.hash(:sha256, IO.iodata_to_binary(value)), case: :lower)
end
defp boundary_digest(value) do
value
|> ExecutionPlane.Boundary.dump_value()
|> BoundaryCodec.digest()
end
defp default_work_root do
Path.join(System.tmp_dir!(), "execution-plane-tre-#{System.unique_integer([:positive])}")
end
end