#!/usr/bin/env elixir # Example: Complete Workflow - All Hooks Together with LIVE CLI # # Demonstrates multiple hooks working together to build a secure, audited workflow: # - `user_prompt_submit` context injection # - `pre_tool_use` audit logging + security allow/deny # - `post_tool_use` monitoring # # Run: mix run examples/hooks/complete_workflow.exs Code.require_file(Path.expand("../support/example_helper.exs", __DIR__)) alias ClaudeAgentSDK.{Client, ContentExtractor, Message, Options} alias ClaudeAgentSDK.Hooks.{Matcher, Output} alias Examples.Support Support.ensure_live!() Support.header!("Hooks Example: Complete Workflow (live)") defmodule CompleteWorkflowHooks do @moduledoc false @table :claude_agent_sdk_examples_complete_workflow @forbidden_patterns ["rm -rf", "dd if=", "mkfs"] def table_name, do: @table def put_allowed_dir!(dir) when is_binary(dir) do :ets.insert(@table, {:allowed_dir, dir}) end defp allowed_dir do case :ets.lookup(@table, :allowed_dir) do [{:allowed_dir, dir}] when is_binary(dir) -> dir _ -> "/tmp" end end defp inc(key) when is_atom(key) do _ = :ets.update_counter(@table, key, {2, 1}, {key, 0}) :ok end # PreToolUse: audit log (always allow) def audit_log(input, tool_use_id, _context) do inc(:pre) tool = input["tool_name"] IO.puts("\nšŸ“ [AUDIT] tool=#{tool} id=#{tool_use_id}") %{} end # PreToolUse: security validation (allow/deny) def security_validation(input, _tool_use_id, _context) do case input do %{"tool_name" => "Bash", "tool_input" => %{"command" => cmd}} when is_binary(cmd) -> if Enum.any?(@forbidden_patterns, &String.contains?(cmd, &1)) do inc(:denied) IO.puts("\n🚫 SECURITY: Blocked dangerous command: #{cmd}") Output.deny("Dangerous command blocked") |> Output.with_system_message("šŸ”’ Security policy violation") else inc(:allowed) IO.puts("\nāœ… SECURITY: Approved bash command") Output.allow("Security check passed") end %{"tool_name" => "Write", "tool_input" => %{"file_path" => path}} when is_binary(path) -> if String.starts_with?(path, allowed_dir()) do inc(:allowed) IO.puts("\nāœ… SECURITY: Approved file write: #{path}") Output.allow("Sandbox check passed") else inc(:denied) IO.puts("\n🚫 SECURITY: Blocked write outside sandbox: #{path}") Output.deny("Must operate within #{allowed_dir()}") |> Output.with_system_message("šŸ”’ Sandbox restriction") end _ -> %{} end end # UserPromptSubmit: context injection def add_context(_input, _tool_use_id, _context) do inc(:context) context_text = """ ## šŸ”’ Security Context - Bash restricted: #{Enum.join(@forbidden_patterns, ", ")} - Writes sandboxed to: #{allowed_dir()} """ Output.add_context("UserPromptSubmit", context_text) end # PostToolUse: monitoring def monitor_execution(input, tool_use_id, _context) do inc(:post) tool = input["tool_name"] is_error = get_in(input, ["tool_response", "is_error"]) || false status = if is_error, do: "āŒ", else: "āœ…" IO.puts("\nšŸ“Š MONITOR: tool=#{tool} #{status} id=#{tool_use_id}") %{} end end table = CompleteWorkflowHooks.table_name() case :ets.whereis(table) do :undefined -> :ok tid -> :ets.delete(tid) end :ets.new(table, [:named_table, :public, :set]) sandbox_dir = Path.join( System.tmp_dir!(), "claude_agent_sdk_complete_workflow_#{System.unique_integer([:positive])}" ) File.mkdir_p!(sandbox_dir) CompleteWorkflowHooks.put_allowed_dir!(sandbox_dir) hooks = %{ pre_tool_use: [ Matcher.new("*", [ &CompleteWorkflowHooks.audit_log/3, &CompleteWorkflowHooks.security_validation/3 ]) ], user_prompt_submit: [ Matcher.new(nil, [&CompleteWorkflowHooks.add_context/3]) ], post_tool_use: [ Matcher.new("*", [&CompleteWorkflowHooks.monitor_execution/3]) ] } options = %Options{ tools: ["Bash", "Write"], allowed_tools: ["Bash", "Write"], hooks: hooks, model: "haiku", max_turns: 2, permission_mode: :default } {:ok, client} = Client.start_link(options) run_prompt = fn prompt -> task = Task.async(fn -> Client.stream_messages(client) |> Enum.reduce_while([], fn message, acc -> acc = [message | acc] case message do %Message{type: :assistant} = msg -> text = ContentExtractor.extract_text(msg) if is_binary(text) and text != "", do: IO.puts("\nAssistant:\n#{text}\n") {:cont, acc} %Message{type: :result} -> {:halt, Enum.reverse(acc)} _ -> {:cont, acc} end end) end) Process.sleep(50) :ok = Client.send_message(client, prompt) Task.await(task, 180_000) end IO.puts("Sandbox directory: #{sandbox_dir}\n") IO.puts("Test 1: Safe bash command\n") messages1 = run_prompt.("Use the Bash tool to run this exact command: echo 'hello from complete workflow'") IO.puts("\nTest 2: Sandboxed file write\n") messages2 = run_prompt.("Use the Write tool to write exactly 'ok' to #{Path.join(sandbox_dir, "ok.txt")}.") IO.puts("\nTest 3: Dangerous bash command (should be denied)\n") messages3 = run_prompt.("Use the Bash tool to run this exact command: rm -rf /tmp/this_should_be_blocked") for {label, msgs} <- [ {"safe bash", messages1}, {"sandbox write", messages2}, {"dangerous bash", messages3} ] do case Enum.find(msgs, &(&1.type == :result)) do %Message{subtype: :success} -> :ok %Message{subtype: other} -> raise "Run #{label} did not succeed (result subtype: #{inspect(other)})" nil -> raise "Run #{label} returned no result message." end end pre = :ets.lookup(table, :pre) |> then(fn [{:pre, n}] when is_integer(n) -> n _ -> 0 end) post = :ets.lookup(table, :post) |> then(fn [{:post, n}] when is_integer(n) -> n _ -> 0 end) ctx = :ets.lookup(table, :context) |> then(fn [{:context, n}] when is_integer(n) -> n _ -> 0 end) allowed = :ets.lookup(table, :allowed) |> then(fn [{:allowed, n}] when is_integer(n) -> n _ -> 0 end) denied = :ets.lookup(table, :denied) |> then(fn [{:denied, n}] when is_integer(n) -> n _ -> 0 end) if ctx < 1 do raise "Expected user_prompt_submit hook to fire, but context=#{ctx}." end if pre < 2 do raise "Expected pre_tool_use hooks to fire at least twice, but pre=#{pre}." end if post < 1 do raise "Expected post_tool_use hook to fire at least once, but post=#{post}." end if allowed < 1 or denied < 1 do raise "Expected at least one allowed and one denied decision, but allowed=#{allowed} denied=#{denied}." end IO.puts( "\nāœ… Complete workflow checks passed (pre=#{pre} post=#{post} ctx=#{ctx} allowed=#{allowed} denied=#{denied})." ) Client.stop(client) :ets.delete(table) IO.puts("\nDone.") Support.halt_if_runner!()