Current section

Files

Jump to
codex_sdk examples live_app_server_approvals.exs
Raw

examples/live_app_server_approvals.exs

Mix.Task.run("app.start")
Code.require_file(Path.expand("support/example_helper.exs", __DIR__))
alias CodexExamples.Support
Support.init!()
alias Codex.AppServer.ApprovalDecision
alias Codex.Auth
alias Codex.Config.LayerStack
alias Codex.Events
alias Codex.Items
alias Codex.Protocol.RequestPermissions
alias Codex.RunResultStreaming
defmodule CodexExamples.LiveAppServerApprovals do
@moduledoc false
@command_file_prompt """
Run `pwd` and `ls -la` in the current working directory, then fetch
`https://example.com` with a separate shell command before creating `tmp/approval_demo.txt`
(create `tmp/` first if needed) containing the current directory path. Keep shell commands
separate so approval events stay easy to read, then report exactly what completed.
"""
@command_approval_method "item/commandExecution/requestApproval"
@file_approval_method "item/fileChange/requestApproval"
@permissions_approval_method "item/permissions/requestApproval"
@demo_feature_flags ~w(request_permissions_tool exec_permission_approvals guardian_approval)
def main(argv) do
case run(argv) do
:ok ->
:ok
{:skip, reason} ->
IO.puts("SKIPPED: #{reason}")
{:error, reason} ->
Mix.raise("App-server approvals example failed: #{inspect(reason)}")
end
end
defp run(argv) do
command_file_prompt =
case argv do
[] -> @command_file_prompt
values -> Enum.join(values, " ")
end
with :ok <-
Support.ensure_local_execution_surface(
"this example provisions host-local approval fixtures and does not support --ssh-host"
),
{:ok, codex_opts} <- Support.codex_options(%{}, missing_cli: :skip),
:ok <- Support.ensure_app_server_supported(codex_opts),
{:ok, fixture} <- build_demo_fixture() do
try do
permissions_prompt = permissions_prompt(fixture)
with {:ok, conn, experimental_api?, init_fallback_reason} <-
connect_for_approvals_demo(codex_opts, fixture) do
try do
parent = self()
{:ok, approval_pid} =
Task.start_link(fn ->
:ok = Codex.AppServer.subscribe(conn)
send(parent, {:approvals_ready, self()})
approval_loop(conn, parent)
end)
:ok = await_approvals_ready!(approval_pid)
print_demo_intro(
command_file_prompt,
permissions_prompt,
fixture,
experimental_api?,
init_fallback_reason
)
{thread, audit} =
if experimental_api? do
{:ok, granular_thread} =
start_demo_thread(
codex_opts,
conn,
fixture,
granular_approval_policy(),
:auto_review
)
granular_audit =
new_audit_state()
|> run_demo_turn!(granular_thread, command_file_prompt, parent, "command/file")
if granular_audit == new_audit_state() do
IO.puts("""
The connected Codex build did not produce usable live events under the granular
approval policy. Retrying the command/file demo with legacy `:untrusted`
approvals for backwards compatibility while keeping the isolated feature-enabled
permissions turn below.
""")
{:ok, legacy_thread} =
start_demo_thread(codex_opts, conn, fixture, :untrusted, nil)
{legacy_thread,
new_audit_state()
|> run_demo_turn!(
legacy_thread,
command_file_prompt,
parent,
"command/file (legacy policy)"
)}
else
{granular_thread, granular_audit}
end
else
{:ok, legacy_thread} =
start_demo_thread(codex_opts, conn, fixture, :untrusted, nil)
{legacy_thread,
new_audit_state()
|> run_demo_turn!(
legacy_thread,
command_file_prompt,
parent,
"command/file (legacy policy)"
)}
end
audit =
if experimental_api? do
maybe_run_permissions_turn!(audit, thread, parent, permissions_prompt, fixture)
else
print_permissions_response_fallback(fixture)
audit
end
print_audit_summary(audit)
send(approval_pid, :stop)
:ok
after
:ok = Codex.AppServer.disconnect(conn)
end
end
after
cleanup_fixture(fixture)
end
else
{:skip, _reason} = skip ->
skip
{:error, reason} ->
{:error, reason}
end
end
defp run_demo_turn!(audit, thread, prompt, parent, label) do
case Codex.Thread.run_streamed(thread, prompt, %{timeout_ms: 120_000}) do
{:ok, stream} ->
IO.puts("\n--- #{label} turn ---")
stream
|> RunResultStreaming.raw_events()
|> Enum.each(&print_event(&1, parent))
IO.puts("\nusage: #{inspect(RunResultStreaming.usage(stream))}")
drain_audit_messages(audit)
{:error, reason} ->
Mix.raise("#{label} streaming run failed: #{inspect(reason)}")
end
end
defp start_demo_thread(codex_opts, conn, fixture, approval_policy, approvals_reviewer) do
%{
transport: {:app_server, conn},
working_directory: fixture.workspace_root,
ask_for_approval: approval_policy,
sandbox: :workspace_write
}
|> maybe_put(:approvals_reviewer, approvals_reviewer)
|> Support.thread_opts!()
|> then(&Codex.start_thread(codex_opts, &1))
end
defp maybe_run_permissions_turn!(audit, thread, parent, permissions_prompt, fixture) do
if MapSet.member?(audit.approval_request_methods, @permissions_approval_method) do
audit
else
IO.puts("""
No live permissions approval was observed in the first turn.
Running a supplemental turn that explicitly asks for write access outside the isolated
demo workspace:
#{fixture.permissions_target}
""")
updated_audit = run_demo_turn!(audit, thread, permissions_prompt, parent, "permissions")
if MapSet.member?(updated_audit.approval_request_methods, @permissions_approval_method) do
updated_audit
else
print_permissions_response_fallback(fixture)
updated_audit
end
end
end
defp approval_loop(conn, parent) do
receive do
:stop ->
:ok
{:codex_request, id, method, params} ->
IO.puts("\n[codex_request] method=#{method}")
send(parent, {:audit, {:request_received, method}})
case method do
@command_approval_method ->
maybe_log_execpolicy_hint(params)
:ok = Codex.AppServer.respond(conn, id, %{decision: "acceptForSession"})
IO.puts("[approval] responded to commandExecution request")
send(parent, {:audit, {:response_sent, method}})
@file_approval_method ->
:ok = Codex.AppServer.respond(conn, id, %{decision: "accept"})
IO.puts("[approval] responded to fileChange request")
send(parent, {:audit, {:response_sent, method}})
@permissions_approval_method ->
response = build_permissions_response(params)
IO.puts("[approval] requested permissions: #{describe_permissions_request(params)}")
:ok = Codex.AppServer.respond(conn, id, response)
IO.puts("[approval] responded to permissions request with structured grant payload")
send(parent, {:audit, {:permissions_granted, response}})
send(parent, {:audit, {:response_sent, method}})
_other ->
IO.puts("[codex_request] no handler configured for #{method}")
end
approval_loop(conn, parent)
_other ->
approval_loop(conn, parent)
end
end
defp maybe_log_execpolicy_hint(%{"proposedExecpolicyAmendment" => argv})
when is_list(argv) and argv != [] do
IO.puts(
"[approval] server proposed execpolicy amendment #{inspect(argv)}; accepting for session for demo stability"
)
end
defp maybe_log_execpolicy_hint(_params), do: :ok
defp connect_for_approvals_demo(codex_opts, fixture) do
experimental_opts = connect_opts(fixture, experimental_api: true)
case Codex.AppServer.connect(codex_opts, experimental_opts) do
{:ok, conn} ->
{:ok, conn, true, nil}
{:error, {:init_failed, reason}} ->
if experimental_api_rejected?(reason) do
case Codex.AppServer.connect(codex_opts, connect_opts(fixture, [])) do
{:ok, conn} ->
{:ok, conn, false, reason}
{:error, retry_reason} ->
{:error, {:experimental_api_init_failed, reason, retry_reason}}
end
else
{:error, {:init_failed, reason}}
end
{:error, reason} ->
{:error, reason}
end
end
defp granular_approval_policy do
%{
type: :granular,
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true
}
end
defp build_permissions_response(%{} = params) do
requested =
params
|> Map.get("permissions", %{})
|> RequestPermissions.RequestPermissionProfile.from_map()
ApprovalDecision.from_permissions_hook(:allow, requested)
end
defp describe_permissions_request(%{} = params) do
requested =
params
|> Map.get("permissions", %{})
|> RequestPermissions.RequestPermissionProfile.from_map()
requested
|> RequestPermissions.RequestPermissionProfile.to_map()
|> inspect()
end
defp print_permissions_response_fallback(fixture) do
params = %{
"permissions" => %{
"network" => %{"enabled" => false},
"fileSystem" => %{"write" => [fixture.permissions_target]}
}
}
response = build_permissions_response(params)
IO.puts("""
[permissions.fallback]
The connected build/model still did not emit `item/permissions/requestApproval`, even with
isolated feature flags enabled in a temporary `CODEX_HOME`. This example is printing the
exact SDK response shape it would send for a structured grant:
request: #{inspect(params["permissions"])}
response: #{inspect(response)}
""")
end
defp format_connect_reason(%{} = reason) do
reason
|> Map.take(["code", "message"])
|> inspect()
end
defp format_connect_reason(reason), do: inspect(reason)
defp experimental_api_rejected?(%{} = reason) do
message =
reason
|> Map.get("message", Map.get(reason, :message, ""))
|> to_string()
|> String.downcase()
String.contains?(message, "experimentalapi") or
String.contains?(message, "experimental api") or
(String.contains?(message, "capabilities") and
(String.contains?(message, "unknown field") or
String.contains?(message, "unexpected field") or
String.contains?(message, "invalid params")))
end
defp experimental_api_rejected?(_reason), do: false
defp permissions_prompt(fixture) do
"""
Before doing anything else, explicitly request additional write permission for
`#{fixture.permissions_target}` outside the current working directory. After the permission
decision resolves, do not write the file. Reply with one short sentence describing whether the
permission request was granted.
"""
end
defp print_demo_intro(
command_file_prompt,
permissions_prompt,
fixture,
experimental_api?,
init_fallback_reason
) do
IO.puts("""
Streaming over app-server with manual approvals.
workspace_root: #{fixture.workspace_root}
isolated_codex_home: #{fixture.codex_home}
isolated_feature_flags: #{Enum.join(fixture.feature_flags, ", ")}
copied_auth_files: #{inspect(fixture.copied_auth_files)}
command/file prompt: #{command_file_prompt}
permissions prompt: #{permissions_prompt}
This run prints:
- typed commandExecution/fileChange/requestApproval events when the server emits them
- typed permissions approval events with structured grant responses
- raw incoming app-server request methods
- guardian review started/completed notifications when emitted by Codex
- serverRequest/resolved notifications
- approval responses sent
- completion status for command/file items
- a deterministic permissions-response fallback if the connected build/model still never
emits item/permissions/requestApproval
- a reconnect without `initialize.capabilities.experimentalApi` when the connected build
rejects experimental app-server fields
- an automatic retry with legacy `:untrusted` approvals if the connected Codex build does
not produce usable live events under the granular policy
Stock Codex builds keep `request_permissions_tool`, `exec_permission_approvals`, and
`guardian_approval` disabled by default. This example enables them only inside the temporary
`CODEX_HOME` above, so it can exercise the live protocol without mutating your real settings
or writing inside this repository.
""")
if not experimental_api? do
IO.puts("""
The connected Codex build rejected `initialize.capabilities.experimentalApi`:
#{format_connect_reason(init_fallback_reason)}
Reconnected without experimental app-server fields. This run will show legacy
command/file approvals and the exact structured permissions fallback payload instead of
live guardian/request-permissions events.
""")
end
end
defp build_demo_fixture do
suffix = System.unique_integer([:positive])
temp_root = Path.join(System.tmp_dir!(), "codex_approvals_example_#{suffix}")
workspace_root = Path.join(temp_root, "workspace")
home_root = Path.join(temp_root, "home")
codex_home = Path.join(home_root, ".codex")
permissions_target = Path.join(temp_root, "outside/codex_sdk_permissions_demo.txt")
with :ok <- File.mkdir_p(workspace_root),
:ok <- File.mkdir_p(codex_home),
:ok <-
File.write(
Path.join(workspace_root, "README.txt"),
"Temporary approvals demo workspace\n"
),
{:ok, copied_auth_files} <- copy_auth_fixture_files(codex_home),
:ok <- write_demo_config(codex_home) do
{:ok,
%{
temp_root: temp_root,
workspace_root: workspace_root,
home_root: home_root,
codex_home: codex_home,
permissions_target: permissions_target,
feature_flags: @demo_feature_flags,
copied_auth_files: copied_auth_files
}}
else
{:error, reason} ->
cleanup_fixture(%{temp_root: temp_root})
{:error, {:fixture_setup_failed, reason}}
end
end
defp write_demo_config(codex_home) do
config_lines =
current_auth_store_lines() ++
[
"[features]",
"request_permissions_tool = true",
"exec_permission_approvals = true",
"guardian_approval = true"
]
File.write(Path.join(codex_home, "config.toml"), Enum.join(config_lines, "\n") <> "\n")
end
defp current_auth_store_lines do
cwd = File.cwd!()
with {:ok, layers} <- LayerStack.load(Auth.codex_home(), cwd),
%{} = config <- LayerStack.effective_config(layers),
mode when is_binary(mode) <-
Map.get(config, "cli_auth_credentials_store") ||
Map.get(config, :cli_auth_credentials_store),
true <- mode in ["auto", "file", "keyring"] do
["cli_auth_credentials_store = #{inspect(mode)}", ""]
else
_ -> []
end
end
defp copy_auth_fixture_files(codex_home) do
Auth.auth_paths()
|> Enum.reduce_while({:ok, []}, fn source, {:ok, copied} ->
case copy_auth_fixture_file(source, codex_home) do
:skip -> {:cont, {:ok, copied}}
{:ok, destination} -> {:cont, {:ok, [destination | copied]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:ok, copied} -> {:ok, Enum.reverse(copied)}
{:error, reason} -> {:error, reason}
end
end
defp copy_auth_fixture_file(source, _codex_home) when not is_binary(source), do: :skip
defp copy_auth_fixture_file(source, codex_home) do
if File.regular?(source) do
destination = auth_fixture_destination(source, codex_home)
with :ok <- File.mkdir_p(Path.dirname(destination)),
:ok <- File.cp(source, destination) do
{:ok, destination}
else
{:error, reason} -> {:error, {:auth_copy_failed, source, reason}}
end
else
:skip
end
end
defp auth_fixture_destination(source, codex_home) do
case Path.basename(source) do
"auth.json" -> Path.join(codex_home, "auth.json")
".credentials.json" -> Path.join(codex_home, ".credentials.json")
"credentials.json" -> Path.join(codex_home, ".credentials.json")
"codex.json" -> Path.join(codex_home, ".credentials.json")
other -> Path.join(codex_home, other)
end
end
defp connect_opts(fixture, extra_opts) do
[
init_timeout_ms: 30_000,
cwd: fixture.workspace_root,
process_env: isolated_process_env(fixture)
] ++
extra_opts
end
defp isolated_process_env(fixture) do
%{}
|> maybe_put("CODEX_HOME", fixture.codex_home)
|> maybe_put("HOME", System.get_env("HOME") || System.user_home!())
|> maybe_put("USERPROFILE", System.get_env("USERPROFILE"))
end
defp format_permission_profile(nil), do: nil
defp format_permission_profile(profile) do
profile
|> RequestPermissions.RequestPermissionProfile.from_map()
|> RequestPermissions.RequestPermissionProfile.to_map()
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp await_approvals_ready!(task_pid) when is_pid(task_pid) do
receive do
{:approvals_ready, ^task_pid} -> :ok
after
5_000 -> Mix.raise("Timed out waiting for approval subscription")
end
end
defp print_event(%Events.ItemAgentMessageDelta{item: %{"text" => delta}}, _parent)
when is_binary(delta) do
IO.write(delta)
end
defp print_event(%Events.CommandApprovalRequested{} = event, parent) do
IO.puts("""
[approval.request commandExecution]
item_id: #{event.item_id}
approval_id: #{inspect(event.approval_id)}
reason: #{inspect(event.reason)}
command: #{inspect(event.command)}
cwd: #{inspect(event.cwd)}
command_actions: #{inspect(event.command_actions)}
network_approval_context: #{inspect(event.network_approval_context)}
additional_permissions: #{inspect(format_permission_profile(event.additional_permissions))}
available_decisions: #{inspect(event.available_decisions)}
proposed_execpolicy_amendment: #{inspect(event.proposed_execpolicy_amendment)}
proposed_network_policy_amendments: #{inspect(event.proposed_network_policy_amendments)}
""")
send(parent, {:audit, {:request_received, @command_approval_method}})
end
defp print_event(%Events.FileApprovalRequested{} = event, parent) do
IO.puts("""
[approval.request fileChange]
item_id: #{event.item_id}
reason: #{inspect(event.reason)}
grant_root: #{inspect(event.grant_root)}
""")
send(parent, {:audit, {:request_received, @file_approval_method}})
end
defp print_event(%Events.PermissionsApprovalRequested{} = event, parent) do
IO.puts("""
[approval.request permissions]
item_id: #{event.item_id}
reason: #{inspect(event.reason)}
permissions: #{inspect(format_permission_profile(event.permissions))}
""")
send(parent, {:audit, {:request_received, @permissions_approval_method}})
end
defp print_event(%Events.ItemStarted{item: %Items.CommandExecution{} = item}, parent) do
IO.puts("""
[item.started commandExecution]
command: #{item.command}
cwd: #{inspect(item.cwd)}
status: #{inspect(item.status)}
""")
send(parent, {:audit, :command_proposed})
end
defp print_event(%Events.ItemStarted{item: %Items.FileChange{} = item}, parent) do
IO.puts("""
[item.started fileChange]
changes: #{length(item.changes)}
status: #{inspect(item.status)}
""")
send(parent, {:audit, :file_proposed})
end
defp print_event(%Events.ItemCompleted{item: %Items.CommandExecution{} = item}, parent) do
IO.puts("""
[item.completed commandExecution]
status: #{inspect(item.status)}
exit_code: #{inspect(item.exit_code)}
command: #{item.command}
""")
send(parent, {:audit, {:command_completed, item.status}})
end
defp print_event(%Events.ItemCompleted{item: %Items.FileChange{} = item}, parent) do
IO.puts("""
[item.completed fileChange]
status: #{inspect(item.status)}
changes: #{length(item.changes)}
""")
send(parent, {:audit, {:file_completed, item.status}})
end
defp print_event(%Events.ItemCompleted{item: %Items.AgentMessage{text: text}}, _parent) do
if is_binary(text) and String.trim(text) != "" do
IO.puts("\n\n[agent_message.completed]\n#{text}\n")
end
end
defp print_event(%Events.GuardianApprovalReviewStarted{} = event, parent) do
IO.puts("""
[guardian.review.started]
review_id: #{inspect(event.review_id)}
target_item_id: #{event.target_item_id}
status: #{inspect(event.review.status)}
risk_level: #{inspect(event.review.risk_level)}
rationale: #{inspect(event.review.rationale)}
""")
send(parent, {:audit, {:guardian_started, event.target_item_id}})
end
defp print_event(%Events.GuardianApprovalReviewCompleted{} = event, parent) do
IO.puts("""
[guardian.review.completed]
review_id: #{inspect(event.review_id)}
target_item_id: #{event.target_item_id}
decision_source: #{inspect(event.decision_source)}
status: #{inspect(event.review.status)}
risk_level: #{inspect(event.review.risk_level)}
rationale: #{inspect(event.review.rationale)}
""")
send(parent, {:audit, {:guardian_completed, event.review.status}})
end
defp print_event(%Events.ServerRequestResolved{} = event, parent) do
IO.puts("""
[server_request.resolved]
thread_id: #{event.thread_id}
request_id: #{inspect(event.request_id)}
""")
send(parent, {:audit, {:resolved_request, event.request_id}})
end
defp print_event(%Events.TurnCompleted{status: status}, _parent) do
IO.puts("\n[turn.completed] status=#{inspect(status)}")
end
defp print_event(_other, _parent), do: :ok
defp new_audit_state do
%{
command_proposed?: false,
file_proposed?: false,
approval_request_methods: MapSet.new(),
approval_response_methods: MapSet.new(),
permissions_grants: [],
guardian_started_items: [],
guardian_completed_statuses: [],
resolved_request_ids: [],
command_completed_statuses: [],
file_completed_statuses: []
}
end
defp drain_audit_messages(state) do
receive do
{:audit, :command_proposed} ->
drain_audit_messages(%{state | command_proposed?: true})
{:audit, :file_proposed} ->
drain_audit_messages(%{state | file_proposed?: true})
{:audit, {:request_received, method}} ->
methods = MapSet.put(state.approval_request_methods, method)
drain_audit_messages(%{state | approval_request_methods: methods})
{:audit, {:response_sent, method}} ->
methods = MapSet.put(state.approval_response_methods, method)
drain_audit_messages(%{state | approval_response_methods: methods})
{:audit, {:permissions_granted, response}} ->
drain_audit_messages(%{state | permissions_grants: [response | state.permissions_grants]})
{:audit, {:guardian_started, item_id}} ->
drain_audit_messages(%{
state
| guardian_started_items: [item_id | state.guardian_started_items]
})
{:audit, {:guardian_completed, status}} ->
drain_audit_messages(%{
state
| guardian_completed_statuses: [status | state.guardian_completed_statuses]
})
{:audit, {:resolved_request, request_id}} ->
drain_audit_messages(%{
state
| resolved_request_ids: [request_id | state.resolved_request_ids]
})
{:audit, {:command_completed, status}} ->
drain_audit_messages(%{
state
| command_completed_statuses: [status | state.command_completed_statuses]
})
{:audit, {:file_completed, status}} ->
drain_audit_messages(%{
state
| file_completed_statuses: [status | state.file_completed_statuses]
})
after
250 ->
state
end
end
defp print_audit_summary(audit) do
IO.puts("\nAudit summary:")
IO.puts(" command/file item proposed? #{audit.command_proposed? or audit.file_proposed?}")
IO.puts(" commandExecution proposed? #{audit.command_proposed?}")
IO.puts(" fileChange proposed? #{audit.file_proposed?}")
IO.puts(
" approval request methods: #{inspect(MapSet.to_list(audit.approval_request_methods))}"
)
IO.puts(
" approval response methods: #{inspect(MapSet.to_list(audit.approval_response_methods))}"
)
IO.puts(" permissions grants sent: #{inspect(Enum.reverse(audit.permissions_grants))}")
IO.puts(
" guardian review started items: #{inspect(Enum.reverse(audit.guardian_started_items))}"
)
IO.puts(
" guardian review completed statuses: #{inspect(Enum.reverse(audit.guardian_completed_statuses))}"
)
IO.puts(" resolved request ids: #{inspect(Enum.reverse(audit.resolved_request_ids))}")
IO.puts(
" commandExecution completed statuses: #{inspect(Enum.reverse(audit.command_completed_statuses))}"
)
IO.puts(
" fileChange completed statuses: #{inspect(Enum.reverse(audit.file_completed_statuses))}"
)
if MapSet.size(audit.approval_request_methods) == 0 do
IO.puts(
" note: no approval requests observed for this prompt/policy combination; check item events above to confirm whether tools were invoked."
)
end
if not MapSet.member?(audit.approval_request_methods, @command_approval_method) do
IO.puts(
" note: no live commandExecution approval was observed; the connected build/model may have allowed the requested shell commands without a separate command approval request."
)
end
if not MapSet.member?(audit.approval_request_methods, @file_approval_method) do
IO.puts(
" note: no live fileChange approval was observed; the model handled file updates without a separate fileChange approval item in this run."
)
end
if not MapSet.member?(audit.approval_request_methods, @permissions_approval_method) do
IO.puts(
" note: no live permissions approval was observed even after the supplemental turn; see [permissions.fallback] above for the exact structured response payload."
)
end
end
defp cleanup_fixture(%{temp_root: temp_root}) when is_binary(temp_root),
do: File.rm_rf(temp_root)
defp cleanup_fixture(_fixture), do: :ok
end
CodexExamples.LiveAppServerApprovals.main(System.argv())